dragnn_layout.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /**
  2. * @fileoverview Cytoscape layout function for DRAGNN graphs.
  3. *
  4. * Currently, the algorithm has 3 stages:
  5. *
  6. * 1. Initial layout
  7. * 2. Spring-based resolution
  8. * 3. Re-layout based on component order and direction
  9. *
  10. * In the future, if we propagated a few more pieces of information, we could
  11. * probably skip the spring-based step altogether.
  12. */
  13. 'use strict';
  14. const _ = require('lodash');
  15. // default layout options
  16. const defaults = {
  17. horizontal: true,
  18. ready() {}, // on layoutready
  19. stop() {}, // on layoutstop
  20. };
  21. /**
  22. * Partitions nodes into component nodes and step nodes.
  23. *
  24. * @param {!Array} nodes Nodes to partition.
  25. * @return {!Object<string, Object>} dictionary with two keys, 'component' and
  26. * 'step', both of which are list of Cytoscape nodes.
  27. */
  28. function partitionNodes(nodes) {
  29. // Split nodes into components and steps per component.
  30. const partition = {component: [], step: []};
  31. _.each(nodes, function(node) {
  32. partition[node.hasClass('step') ? 'step' : 'component'].push(node);
  33. });
  34. return partition;
  35. }
  36. /**
  37. * Partitions step nodes by their component name.
  38. *
  39. * @param {!Array} nodes Nodes to partition.
  40. * @return {!Object<string, Object>} dictionary keys as component names,
  41. * values as children of that component.
  42. */
  43. function partitionStepNodes(nodes) {
  44. const partition = {};
  45. _.each(nodes, (node) => {
  46. const key = node.data('parent');
  47. if (partition[key] === undefined) {
  48. partition[key] = [node];
  49. } else {
  50. partition[key].push(node);
  51. }
  52. });
  53. return partition;
  54. }
  55. /**
  56. * Initializes the custom Cytoscape layout. This needs to be an old-style class,
  57. * because of how it's called in Cytoscape.
  58. *
  59. * @param {!Object} options Options to initialize with. These will be passed
  60. * through to the intermediate "cose" layout.
  61. */
  62. function DragnnLayout(options) {
  63. this.options = _.extend({}, defaults, options);
  64. this.horizontal = this.options.horizontal;
  65. }
  66. /**
  67. * Calculates the step position, given an effective component index, and step
  68. * index.
  69. *
  70. * @param {number} componentIdx Zero-based (display) index of the component.
  71. * @param {number} stepIdx Zero-based (display) index of the step.
  72. * @return {!Object<string, number>} Position dictionary (x and y)
  73. */
  74. DragnnLayout.prototype.stepPosition = function(componentIdx, stepIdx) {
  75. return (
  76. this.horizontal ? {'x': stepIdx * 30, 'y': 220 * componentIdx} :
  77. {'x': 320 * componentIdx, 'y': stepIdx * 30});
  78. };
  79. /**
  80. * The main method for our DRAGNN-specific layout. See module docstring.
  81. *
  82. * Cytoscape automatically injects `this.trigger` methods and `options.cy`,
  83. * `options.eles` variables.
  84. *
  85. * @return {DragnnLayout} `this`, for chaining.
  86. */
  87. DragnnLayout.prototype.run = function() {
  88. const eles = this.options.eles; // elements to consider in the layout
  89. const cy = this.options.cy;
  90. this.trigger('layoutstart');
  91. const visible = _.filter(eles.nodes(), function(n) {
  92. return n.visible();
  93. });
  94. const partition = partitionNodes(visible);
  95. const stepPartition = partitionStepNodes(partition.step);
  96. // Initialize components as horizontal or vertical "strips".
  97. _.each(stepPartition, (stepNodes) => {
  98. _.each(stepNodes, (node, idx) => {
  99. node.position(this.stepPosition(node.data('componentIdx'), idx));
  100. });
  101. });
  102. // Next do a cose layout, and then run finalLayout().
  103. cy.layout(_.extend({}, this.options, {
  104. name: 'cose',
  105. animate: false,
  106. ready: this.finalLayout.bind(this, partition, stepPartition, cy)
  107. }));
  108. return this;
  109. };
  110. /**
  111. * Gets a list of components, by their current visual position.
  112. *
  113. * @param {!Array} componentNodes Cytoscape component nodes.
  114. * @return {!Array<string, Object>} List of (componentName, position dict)
  115. * pairs.
  116. */
  117. DragnnLayout.prototype.sortedComponents = function(componentNodes) {
  118. // Position dictionaries are mutable, so copy them to avoid confusion.
  119. const copyPosition = (pos) => {
  120. return {x: pos.x, y: pos.y};
  121. };
  122. const componentPositions = _.map(componentNodes, (node) => {
  123. return [node.id(), copyPosition(node.position())];
  124. });
  125. return _.sortBy(componentPositions, (x) => {
  126. return this.horizontal ? x[1].y : x[1].x;
  127. });
  128. };
  129. /**
  130. * Computes the final, fancy layout. This will use two components from the
  131. * spring model,
  132. *
  133. * - the order of components
  134. * - directionality within components
  135. *
  136. * and redo layout in a way that's visually appealing (but may not be minimizing
  137. * distance).
  138. *
  139. * @param {!Object<string, Object>} partition Result of partitionNodes().
  140. * @param {!Object<string, Object>} stepPartition Result of
  141. * partitionStepNodes().
  142. * @param {!Object} cy Cytoscape controller.
  143. */
  144. DragnnLayout.prototype.finalLayout = function(partition, stepPartition, cy) {
  145. // Helper to abstract the horizontal vs. vertical layout.
  146. const compDim = this.horizontal ? 'y' : 'x';
  147. const stepDim = this.horizontal ? 'x' : 'y';
  148. const sorted = this.sortedComponents(partition.component);
  149. // Computes dictionaries from old --> new component positions.
  150. const newCompPos = _.fromPairs(_.map(sorted, (x, i) => {
  151. return [x[0], this.stepPosition(i, 0)];
  152. }));
  153. // Component --> slope for "step index --> position" function.
  154. const nodesPerComponent = {};
  155. const stepSlope = {};
  156. _.each(stepPartition, (stepNodes) => {
  157. const nodeOffset = (node) => {
  158. return node.relativePosition()[stepDim];
  159. };
  160. const name = _.head(stepNodes).data('parent');
  161. const slope =
  162. (nodeOffset(_.last(stepNodes)) - nodeOffset(_.head(stepNodes))) /
  163. stepNodes.length;
  164. nodesPerComponent[name] = stepNodes.length;
  165. stepSlope[name] =
  166. Math.sign(slope) * Math.min(300, Math.max(100, Math.abs(slope)));
  167. });
  168. // Reset ordering of components based on whether they are actually
  169. // left-to-right. In the future, we may want to do the whole layout based on
  170. // the master spec (what remains is slope magnitude and component order); then
  171. // we can also skip the initial layout and CoSE intermediate layout.
  172. if (this.options.masterSpec) {
  173. _.each(this.options.masterSpec.component, (component) => {
  174. const name = component.name;
  175. const transitionParams = component.transition_system.parameters || {};
  176. // null/undefined should default to true.
  177. const leftToRight = transitionParams.left_to_right != 'false';
  178. // If the slope isn't going in the direction it should, according to the
  179. // master spec, reverse it.
  180. if ((leftToRight ? 1 : -1) != Math.sign(stepSlope[name])) {
  181. stepSlope[name] = -stepSlope[name];
  182. }
  183. });
  184. }
  185. // Set new node positions. As before, component nodes auto-size to fit.
  186. _.each(stepPartition, (stepNodes) => {
  187. const component = _.head(stepNodes).data('parent');
  188. const newPos = newCompPos[component];
  189. _.each(stepNodes, function(node, i) {
  190. // Keep things near the component centers.
  191. const x = i - (nodesPerComponent[component] / 2);
  192. const offset = {};
  193. offset[compDim] = 40 * Math.log(1.1 + (i % 5)) * (1 - 2 * (i % 2));
  194. offset[stepDim] = stepSlope[component] * x / 2;
  195. node.position({'x': newPos.x + offset.x, 'y': newPos.y + offset.y});
  196. });
  197. });
  198. // Set the curvature of edges. For now, we only bend edges within components,
  199. // by bending them away from the component center.
  200. _.each(this.options.eles.edges().filter(':visible'), function(edge) {
  201. const src = edge.source();
  202. const dst = edge.target();
  203. const srcPos = src.position();
  204. const dstPos = dst.position();
  205. const stepDiff = dstPos[stepDim] - srcPos[stepDim];
  206. if (src.data('componentIdx') == dst.data('componentIdx')) {
  207. const avgRelPosition =
  208. (src.relativePosition()[compDim] + dst.relativePosition()[compDim]);
  209. // Only bend longer edges.
  210. if (Math.abs(stepDiff) > 250) {
  211. const amount = stepDiff / 10;
  212. const direction = Math.sign(avgRelPosition + 0.001);
  213. edge.data('curvature', direction * amount);
  214. }
  215. }
  216. });
  217. // trigger layoutready when each node has had its position set at least once
  218. this.one('layoutready', this.options.ready);
  219. this.trigger('layoutready');
  220. // trigger layoutstop when the layout stops (e.g. finishes)
  221. this.one('layoutstop', this.options.stop);
  222. this.trigger('layoutstop');
  223. // For some reason (not sure yet), this needs to happen on the next tick.
  224. // (It's not that the component nodes need to resize--that happens even if
  225. // the selection is limited to node.step).
  226. setTimeout(() => {
  227. cy.fit(cy.$('node:visible'), 30);
  228. }, 10);
  229. };
  230. module.exports = DragnnLayout;