diff --git a/client/app/scripts/charts/__tests__/node-layout-test.js b/client/app/scripts/charts/__tests__/node-layout-test.js new file mode 100644 index 000000000..d0a92c974 --- /dev/null +++ b/client/app/scripts/charts/__tests__/node-layout-test.js @@ -0,0 +1,234 @@ +jest.dontMock('../nodes-layout'); +jest.dontMock('../../constants/naming'); // edge naming: 'source-target' + +import { fromJS, Map } from 'immutable'; + +describe('NodesLayout', () => { + const NodesLayout = require('../nodes-layout'); + + function getNodeCoordinates(nodes) { + const coords = []; + nodes + .sortBy(node => node.get('id')) + .forEach(node => { + coords.push(node.get('x')); + coords.push(node.get('y')); + }); + return coords; + } + + let options; + let nodes; + let coords; + let resultCoords; + + const nodeSets = { + initial4: { + nodes: fromJS({ + n1: {id: 'n1'}, + n2: {id: 'n2'}, + n3: {id: 'n3'}, + n4: {id: 'n4'} + }), + edges: fromJS({ + 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, + 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}, + 'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'} + }) + }, + removeEdge24: { + nodes: fromJS({ + n1: {id: 'n1'}, + n2: {id: 'n2'}, + n3: {id: 'n3'}, + n4: {id: 'n4'} + }), + edges: fromJS({ + 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, + 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} + }) + }, + removeNode2: { + nodes: fromJS({ + n1: {id: 'n1'}, + n3: {id: 'n3'}, + n4: {id: 'n4'} + }), + edges: fromJS({ + 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, + 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} + }) + }, + removeNode23: { + nodes: fromJS({ + n1: {id: 'n1'}, + n4: {id: 'n4'} + }), + edges: fromJS({ + 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} + }) + } + }; + + beforeEach(() => { + options = { + nodeCache: Map(), + edgeCache: Map() + }; + }); + + it('detects unseen nodes', () => { + const set1 = fromJS({ + n1: {id: 'n1'} + }); + const set12 = fromJS({ + n1: {id: 'n1'}, + n2: {id: 'n2'} + }); + const set13 = fromJS({ + n1: {id: 'n1'}, + n3: {id: 'n3'} + }); + let hasUnseen; + hasUnseen = NodesLayout.hasUnseenNodes(set12, set1); + expect(hasUnseen).toBeTruthy(); + hasUnseen = NodesLayout.hasUnseenNodes(set13, set1); + expect(hasUnseen).toBeTruthy(); + hasUnseen = NodesLayout.hasUnseenNodes(set1, set12); + expect(hasUnseen).toBeFalsy(); + hasUnseen = NodesLayout.hasUnseenNodes(set1, set13); + expect(hasUnseen).toBeFalsy(); + hasUnseen = NodesLayout.hasUnseenNodes(set12, set13); + expect(hasUnseen).toBeTruthy(); + }); + + it('lays out initial nodeset in a rectangle', () => { + const result = NodesLayout.doLayout( + nodeSets.initial4.nodes, + nodeSets.initial4.edges); + // console.log('initial', result.get('nodes')); + nodes = result.nodes.toJS(); + + expect(nodes.n1.x).toBeLessThan(nodes.n2.x); + expect(nodes.n1.y).toEqual(nodes.n2.y); + expect(nodes.n1.x).toEqual(nodes.n3.x); + expect(nodes.n1.y).toBeLessThan(nodes.n3.y); + expect(nodes.n3.x).toBeLessThan(nodes.n4.x); + expect(nodes.n3.y).toEqual(nodes.n4.y); + }); + + it('keeps nodes in rectangle after removing one edge', () => { + let result = NodesLayout.doLayout( + nodeSets.initial4.nodes, + nodeSets.initial4.edges); + + options.cachedLayout = result; + options.nodeCache = options.nodeCache.merge(result.nodes); + options.edgeCache = options.edgeCache.merge(result.edge); + coords = getNodeCoordinates(result.nodes); + + result = NodesLayout.doLayout( + nodeSets.removeEdge24.nodes, + nodeSets.removeEdge24.edges, + options + ); + nodes = result.nodes.toJS(); + // console.log('remove 1 edge', nodes, result); + + resultCoords = getNodeCoordinates(result.nodes); + expect(resultCoords).toEqual(coords); + }); + + it('keeps nodes in rectangle after removed edge reappears', () => { + let result = NodesLayout.doLayout( + nodeSets.initial4.nodes, + nodeSets.initial4.edges); + + coords = getNodeCoordinates(result.nodes); + options.cachedLayout = result; + options.nodeCache = options.nodeCache.merge(result.nodes); + options.edgeCache = options.edgeCache.merge(result.edge); + result = NodesLayout.doLayout( + nodeSets.removeEdge24.nodes, + nodeSets.removeEdge24.edges, + options + ); + + options.cachedLayout = result; + options.nodeCache = options.nodeCache.merge(result.nodes); + options.edgeCache = options.edgeCache.merge(result.edge); + result = NodesLayout.doLayout( + nodeSets.initial4.nodes, + nodeSets.initial4.edges, + options + ); + + nodes = result.nodes.toJS(); + // console.log('re-add 1 edge', nodes, result); + + resultCoords = getNodeCoordinates(result.nodes); + expect(resultCoords).toEqual(coords); + }); + + it('keeps nodes in rectangle after node dissappears', () => { + let result = NodesLayout.doLayout( + nodeSets.initial4.nodes, + nodeSets.initial4.edges); + + options.cachedLayout = result; + options.nodeCache = options.nodeCache.merge(result.nodes); + options.edgeCache = options.edgeCache.merge(result.edge); + result = NodesLayout.doLayout( + nodeSets.removeNode2.nodes, + nodeSets.removeNode2.edges, + options + ); + + nodes = result.nodes.toJS(); + + resultCoords = getNodeCoordinates(result.nodes); + expect(resultCoords.slice(0,2)).toEqual(coords.slice(0,2)); + expect(resultCoords.slice(2,6)).toEqual(coords.slice(4,8)); + }); + + it('keeps nodes in rectangle after removed node reappears', () => { + let result = NodesLayout.doLayout( + nodeSets.initial4.nodes, + nodeSets.initial4.edges); + + nodes = result.nodes.toJS(); + + coords = getNodeCoordinates(result.nodes); + options.cachedLayout = result; + options.nodeCache = options.nodeCache.merge(result.nodes); + options.edgeCache = options.edgeCache.merge(result.edge); + + result = NodesLayout.doLayout( + nodeSets.removeNode23.nodes, + nodeSets.removeNode23.edges, + options + ); + + nodes = result.nodes.toJS(); + + expect(nodes.n1.x).toBeLessThan(nodes.n4.x); + expect(nodes.n1.y).toBeLessThan(nodes.n4.y); + + options.cachedLayout = result; + options.nodeCache = options.nodeCache.merge(result.nodes); + options.edgeCache = options.edgeCache.merge(result.edge); + result = NodesLayout.doLayout( + nodeSets.removeNode2.nodes, + nodeSets.removeNode2.edges, + options + ); + + nodes = result.nodes.toJS(); + // console.log('re-add 1 node', nodes); + + resultCoords = getNodeCoordinates(result.nodes); + expect(resultCoords.slice(0,2)).toEqual(coords.slice(0,2)); + expect(resultCoords.slice(2,6)).toEqual(coords.slice(4,8)); + }); + +}); diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 7fb918129..1d0245e28 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -2,6 +2,7 @@ const _ = require('lodash'); const d3 = require('d3'); const debug = require('debug')('scope:nodes-chart'); const React = require('react'); +const makeMap = require('immutable').Map; const timely = require('timely'); const Spring = require('react-motion').Spring; @@ -28,8 +29,8 @@ const NodesChart = React.createClass({ getInitialState: function() { return { - nodes: {}, - edges: {}, + nodes: makeMap(), + edges: makeMap(), nodeScale: d3.scale.linear(), shiftTranslate: [0, 0], panTranslate: [0, 0], @@ -62,8 +63,8 @@ const NodesChart = React.createClass({ if (nextProps.topologyId !== this.props.topologyId) { _.assign(state, { autoShifted: false, - nodes: {}, - edges: {} + nodes: makeMap(), + edges: makeMap() }); } // FIXME add PureRenderMixin, Immutables, and move the following functions to render() @@ -96,66 +97,88 @@ const NodesChart = React.createClass({ const adjacency = hasSelectedNode ? AppStore.getAdjacentNodes(this.props.selectedNodeId) : null; const onNodeClick = this.props.onNodeClick; - _.each(nodes, function(node) { - node.highlighted = _.includes(this.props.highlightedNodeIds, node.id) - || this.props.selectedNodeId === node.id; - node.focused = hasSelectedNode - && (this.props.selectedNodeId === node.id || adjacency.includes(node.id)); - node.blurred = hasSelectedNode && !node.focused; - }, this); + // highlighter functions + const setHighlighted = node => { + const highlighted = _.includes(this.props.highlightedNodeIds, node.get('id')) + || this.props.selectedNodeId === node.get('id'); + return node.set('highlighted', highlighted); + }; + const setFocused = node => { + const focused = hasSelectedNode + && (this.props.selectedNodeId === node.get('id') || adjacency.includes(node.get('id'))); + return node.set('focused', focused); + }; + const setBlurred = node => { + return node.set('blurred', hasSelectedNode && !node.get('focused')); + }; - return _.chain(nodes) - .sortBy(function(node) { - if (node.blurred) { - return 0; - } - if (node.highlighted) { - return 2; - } - return 1; - }) - .map(function(node) { - return ( - { + if (node.get('blurred')) { + return 0; + } + if (node.get('highlighted')) { + return 2; + } + return 1; + }; + + return nodes + .toIndexedSeq() + .map(setHighlighted) + .map(setFocused) + .map(setBlurred) + .sortBy(sortNodes) + .map(node => { + return ( ); - }) - .value(); + }); }, renderGraphEdges: function(edges) { const selectedNodeId = this.props.selectedNodeId; const hasSelectedNode = selectedNodeId && this.props.nodes.has(selectedNodeId); - return _.map(edges, function(edge) { - const highlighted = _.includes(this.props.highlightedEdgeIds, edge.id); - const blurred = hasSelectedNode - && edge.source.id !== selectedNodeId - && edge.target.id !== selectedNodeId; - return ( - - ); - }, this); + const setHighlighted = edge => { + return edge.set('highlighted', _.includes(this.props.highlightedEdgeIds, edge.get('id'))); + }; + const setBlurred = edge => { + return (edge.set('blurred', hasSelectedNode + && edge.get('source') !== selectedNodeId + && edge.get('target') !== selectedNodeId)); + }; + + return edges + .toIndexedSeq() + .map(setHighlighted) + .map(setBlurred) + .map(edge => { + return ( + + ); + }); }, renderMaxNodesError: function(show) { + const errorHint = 'We\u0027re working on it, but for now, try a different view?'; return ( ); }, @@ -187,7 +210,7 @@ const NodesChart = React.createClass({ translate = shiftTranslate; wasShifted = true; } - const svgClassNames = this.state.maxNodesExceeded || _.size(nodeElements) === 0 ? 'hide' : ''; + const svgClassNames = this.state.maxNodesExceeded || nodeElements.size === 0 ? 'hide' : ''; const errorEmpty = this.renderEmptyTopologyError(AppStore.isTopologyEmpty()); const errorMaxNodesExceeded = this.renderMaxNodesError(this.state.maxNodesExceeded); @@ -219,34 +242,22 @@ const NodesChart = React.createClass({ }, initNodes: function(topology) { - const centerX = this.props.width / 2; - const centerY = this.props.height / 2; - const nodes = {}; - - topology.forEach(function(node, id) { - nodes[id] = {}; - - // use cached positions if available - _.defaults(nodes[id], { - x: centerX, - y: centerY - }); - + return topology.map((node, id) => { // copy relevant fields to state nodes - _.assign(nodes[id], { + return makeMap({ id: id, label: node.get('label_major'), pseudo: node.get('pseudo'), subLabel: node.get('label_minor'), - rank: node.get('rank') + rank: node.get('rank'), + x: 0, + y: 0 }); }); - - return nodes; }, - initEdges: function(topology, nodes) { - const edges = {}; + initEdges: function(topology, stateNodes) { + let edges = makeMap(); topology.forEach(function(node, nodeId) { const adjacency = node.get('adjacency'); @@ -255,20 +266,20 @@ const NodesChart = React.createClass({ const edge = [nodeId, adjacent]; const edgeId = edge.join(Naming.EDGE_ID_SEPARATOR); - if (!edges[edgeId]) { - const source = nodes[edge[0]]; - const target = nodes[edge[1]]; + if (!edges.has(edgeId)) { + const source = edge[0]; + const target = edge[1]; - if (!source || !target) { - debug('Missing edge node', edge[0], source, edge[1], target); + if (!stateNodes.has(source) || !stateNodes.has(target)) { + debug('Missing edge node', edge[0], edge[1]); } - edges[edgeId] = { + edges = edges.set(edgeId, makeMap({ id: edgeId, value: 1, source: source, target: target - }; + })); } }); } @@ -278,55 +289,65 @@ const NodesChart = React.createClass({ }, centerSelectedNode: function(props, state) { - const layoutNodes = state.nodes; - const layoutEdges = state.edges; - const selectedLayoutNode = layoutNodes[props.selectedNodeId]; + let stateNodes = state.nodes; + let stateEdges = state.edges; + let selectedLayoutNode = stateNodes.get(props.selectedNodeId); if (!selectedLayoutNode) { return {}; } const adjacency = AppStore.getAdjacentNodes(props.selectedNodeId); - const adjacentLayoutNodes = []; + let adjacentLayoutNodeIds = []; adjacency.forEach(function(adjacentId) { // filter loopback if (adjacentId !== props.selectedNodeId) { - adjacentLayoutNodes.push(layoutNodes[adjacentId]); + adjacentLayoutNodeIds.push(adjacentId); } }); // shift center node a bit const nodeScale = state.nodeScale; - selectedLayoutNode.x = selectedLayoutNode.px + nodeScale(1); - selectedLayoutNode.y = selectedLayoutNode.py + nodeScale(1); + const centerX = selectedLayoutNode.get('px') + nodeScale(1); + const centerY = selectedLayoutNode.get('py') + nodeScale(1); + stateNodes = stateNodes.mergeIn([props.selectedNodeId], { + x: centerX, + y: centerY + }); // circle layout for adjacent nodes - const centerX = selectedLayoutNode.x; - const centerY = selectedLayoutNode.y; - const adjacentCount = adjacentLayoutNodes.length; + const adjacentCount = adjacentLayoutNodeIds.length; const density = radiusDensity(adjacentCount); const radius = Math.min(props.width, props.height) / density; const offsetAngle = Math.PI / 4; - _.each(adjacentLayoutNodes, function(node, i) { - const angle = offsetAngle + Math.PI * 2 * i / adjacentCount; - node.x = centerX + radius * Math.sin(angle); - node.y = centerY + radius * Math.cos(angle); + stateNodes = stateNodes.map((node) => { + const index = adjacentLayoutNodeIds.indexOf(node.get('id')); + if (index > -1) { + const angle = offsetAngle + Math.PI * 2 * index / adjacentCount; + return node.merge({ + x: centerX + radius * Math.sin(angle), + y: centerY + radius * Math.cos(angle) + }); + } + return node; }); // fix all edges for circular nodes - - _.each(layoutEdges, function(edge) { - if (edge.source === selectedLayoutNode - || edge.target === selectedLayoutNode - || _.includes(adjacentLayoutNodes, edge.source) - || _.includes(adjacentLayoutNodes, edge.target)) { - edge.points = [ - {x: edge.source.x, y: edge.source.y}, - {x: edge.target.x, y: edge.target.y} - ]; + stateEdges = stateEdges.map(edge => { + if (edge.get('source') === selectedLayoutNode.get('id') + || edge.get('target') === selectedLayoutNode.get('id') + || _.includes(adjacentLayoutNodeIds, edge.get('source')) + || _.includes(adjacentLayoutNodeIds, edge.get('target'))) { + const source = stateNodes.get(edge.get('source')); + const target = stateNodes.get(edge.get('target')); + return edge.set('points', [ + {x: source.get('x'), y: source.get('y')}, + {x: target.get('x'), y: target.get('y')} + ]); } + return edge; }); // shift canvas selected node out of view if it has not been shifted already @@ -373,8 +394,8 @@ const NodesChart = React.createClass({ return { autoShifted: autoShifted, - edges: layoutEdges, - nodes: layoutNodes, + edges: stateEdges, + nodes: stateNodes, shiftTranslate: shiftTranslate }; }, @@ -394,21 +415,21 @@ const NodesChart = React.createClass({ }, restoreLayout: function(state) { - const edges = state.edges; - const nodes = state.nodes; - - _.each(nodes, function(node) { - node.x = node.px; - node.y = node.py; + const nodes = state.nodes.map(node => { + return node.merge({ + x: node.get('px'), + y: node.get('py') + }); }); - _.each(edges, function(edge) { - if (edge.ppoints) { - edge.points = edge.ppoints; + const edges = state.edges.map(edge => { + if (edge.has('ppoints')) { + return edge.set('points', edge.get('ppoints')); } + return edge; }); - return {edges: edges, nodes: nodes}; + return {edges, nodes}; }, updateGraphState: function(props, state) { @@ -416,29 +437,28 @@ const NodesChart = React.createClass({ if (n === 0) { return { - nodes: {}, - edges: {} + nodes: makeMap(), + edges: makeMap() }; } - const nodes = this.initNodes(props.nodes, state.nodes); - const edges = this.initEdges(props.nodes, nodes); + let stateNodes = this.initNodes(props.nodes, state.nodes); + let stateEdges = this.initEdges(props.nodes, stateNodes); const expanse = Math.min(props.height, props.width); const nodeSize = expanse / 3; // single node should fill a third of the screen const normalizedNodeSize = nodeSize / Math.sqrt(n); // assuming rectangular layout const nodeScale = this.state.nodeScale.range([0, normalizedNodeSize]); + const options = { + width: props.width, + height: props.height, + scale: nodeScale, + margins: MARGINS, + topologyId: this.props.topologyId + }; const timedLayouter = timely(NodesLayout.doLayout); - const graph = timedLayouter( - nodes, - edges, - props.width, - props.height, - nodeScale, - MARGINS, - this.props.topologyId - ); + const graph = timedLayouter(stateNodes, stateEdges, options); debug('graph layout took ' + timedLayouter.time + 'ms'); @@ -446,14 +466,18 @@ const NodesChart = React.createClass({ if (!graph) { return {maxNodesExceeded: true}; } + stateNodes = graph.nodes; + stateEdges = graph.edges; // save coordinates for restore - _.each(nodes, function(node) { - node.px = node.x; - node.py = node.y; + stateNodes = stateNodes.map(node => { + return node.merge({ + px: node.get('x'), + py: node.get('y') + }); }); - _.each(edges, function(edge) { - edge.ppoints = edge.points; + stateEdges = stateEdges.map(edge => { + return edge.set('ppoints', edge.get('points')); }); // adjust layout based on viewport @@ -469,8 +493,8 @@ const NodesChart = React.createClass({ } return { - nodes: nodes, - edges: edges, + nodes: stateNodes, + edges: stateEdges, nodeScale: nodeScale, scale: zoomScale, maxNodesExceeded: false diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index 5ee720f5b..310192f02 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -1,26 +1,38 @@ const dagre = require('dagre'); const debug = require('debug')('scope:nodes-layout'); +const makeMap = require('immutable').Map; +const ImmSet = require('immutable').Set; const Naming = require('../constants/naming'); -const _ = require('lodash'); const MAX_NODES = 100; -const topologyGraphs = {}; +const topologyCaches = {}; +let layoutRuns = 0; +let layoutRunsTrivial = 0; -const doLayout = function(nodes, edges, width, height, scale, margins, topologyId) { - let offsetX = 0 + margins.left; - let offsetY = 0 + margins.top; - let graph; +/** + * Layout engine runner + * After the layout engine run nodes and edges have x-y-coordinates. Engine is + * not run if the number of nodes is bigger than `MAX_NODES`. + * @param {Object} graph dagre graph instance + * @param {Map} imNodes new node set + * @param {Map} imEdges new edge set + * @param {Object} opts dimensions, scales, etc. + * @return {Object} Layout with nodes, edges, dimensions + */ +function runLayoutEngine(graph, imNodes, imEdges, opts) { + let nodes = imNodes; + let edges = imEdges; - if (_.size(nodes) > MAX_NODES) { + if (nodes.size > MAX_NODES) { debug('Too many nodes for graph layout engine. Limit: ' + MAX_NODES); return null; } - // one engine per topology, to keep renderings similar - if (!topologyGraphs[topologyId]) { - topologyGraphs[topologyId] = new dagre.graphlib.Graph({}); - } - graph = topologyGraphs[topologyId]; + const options = opts || {}; + const margins = options.margins || {top: 0, left: 0}; + const width = options.width || 800; + const height = options.height || width / 2; + const scale = options.scale || (val => val * 2); // configure node margins graph.setGraph({ @@ -29,10 +41,10 @@ const doLayout = function(nodes, edges, width, height, scale, margins, topologyI }); // add nodes to the graph if not already there - _.each(nodes, function(node) { - if (!graph.hasNode(node.id)) { - graph.setNode(node.id, { - id: node.id, + nodes.forEach(node => { + if (!graph.hasNode(node.get('id'))) { + graph.setNode(node.get('id'), { + id: node.get('id'), width: scale(1), height: scale(1) }); @@ -40,35 +52,41 @@ const doLayout = function(nodes, edges, width, height, scale, margins, topologyI }); // remove nodes that are no longer there - _.each(graph.nodes(), function(nodeid) { - if (!_.has(nodes, nodeid)) { + graph.nodes().forEach(nodeid => { + if (!nodes.has(nodeid)) { graph.removeNode(nodeid); } }); // add edges to the graph if not already there - _.each(edges, function(edge) { - if (!graph.hasEdge(edge.source.id, edge.target.id)) { - const virtualNodes = edge.source.id === edge.target.id ? 1 : 0; - graph.setEdge(edge.source.id, edge.target.id, {id: edge.id, minlen: virtualNodes}); + edges.forEach(edge => { + if (!graph.hasEdge(edge.get('source'), edge.get('target'))) { + const virtualNodes = edge.get('source') === edge.get('target') ? 1 : 0; + graph.setEdge( + edge.get('source'), + edge.get('target'), + {id: edge.get('id'), minlen: virtualNodes} + ); } }); - // remoed egdes that are no longer there - _.each(graph.edges(), function(edgeObj) { + // remove edges that are no longer there + graph.edges().forEach(edgeObj => { const edge = [edgeObj.v, edgeObj.w]; const edgeId = edge.join(Naming.EDGE_ID_SEPARATOR); - if (!_.has(edges, edgeId)) { + if (!edges.has(edgeId)) { graph.removeEdge(edgeObj.v, edgeObj.w); } }); dagre.layout(graph); - const layout = graph.graph(); // shifting graph coordinates to center + let offsetX = 0 + margins.left; + let offsetY = 0 + margins.top; + if (layout.width < width) { offsetX = (width - layout.width) / 2 + margins.left; } @@ -78,31 +96,161 @@ const doLayout = function(nodes, edges, width, height, scale, margins, topologyI // apply coordinates to nodes and edges - graph.nodes().forEach(function(id) { - const node = nodes[id]; + graph.nodes().forEach(id => { const graphNode = graph.node(id); - node.x = graphNode.x + offsetX; - node.y = graphNode.y + offsetY; + nodes = nodes.setIn([id, 'x'], graphNode.x + offsetX); + nodes = nodes.setIn([id, 'y'], graphNode.y + offsetY); }); - graph.edges().forEach(function(id) { + graph.edges().forEach(id => { const graphEdge = graph.edge(id); - const edge = edges[graphEdge.id]; - _.each(graphEdge.points, function(point) { - point.x += offsetX; - point.y += offsetY; - }); - edge.points = graphEdge.points; + const edge = edges.get(graphEdge.id); + const points = graphEdge.points.map(point => ({ + x: point.x + offsetX, + y: point.y + offsetY + })); + // set beginning and end points to node coordinates to ignore node bounding box - edge.points[0] = {x: edge.source.x, y: edge.source.y}; - edge.points[edge.points.length - 1] = {x: edge.target.x, y: edge.target.y}; + const source = nodes.get(edge.get('source')); + const target = nodes.get(edge.get('target')); + points[0] = {x: source.get('x'), y: source.get('y')}; + points[points.length - 1] = {x: target.get('x'), y: target.get('y')}; + + edges = edges.setIn([graphEdge.id, 'points'], points); }); // return object with the width and height of layout + layout.nodes = nodes; + layout.edges = edges; + return layout; +} + +/** + * Adds `points` array to edge based on location of source and target + * @param {Map} edge new edge + * @param {Map} nodeCache all nodes + * @returns {Map} modified edge + */ +function setSimpleEdgePoints(edge, nodeCache) { + const source = nodeCache.get(edge.get('source')); + const target = nodeCache.get(edge.get('target')); + return edge.set('points', [ + {x: source.get('x'), y: source.get('y')}, + {x: target.get('x'), y: target.get('y')} + ]); +} + +/** + * Determine if nodes were added between node sets + * @param {Map} nodes new Map of nodes + * @param {Map} cache old Map of nodes + * @return {Boolean} True if nodes had node ids that are not in cache + */ +export function hasUnseenNodes(nodes, cache) { + const hasUnseen = nodes.size > cache.size + || !ImmSet.fromKeys(nodes).isSubset(ImmSet.fromKeys(cache)); + if (hasUnseen) { + debug('unseen nodes:', ...ImmSet.fromKeys(nodes).subtract(ImmSet.fromKeys(cache)).toJS()); + } + return hasUnseen; +} + +/** + * Determine if edge has same endpoints in new nodes as well as in the nodeCache + * @param {Map} edge Edge with source and target + * @param {Map} nodes new node set + * @return {Boolean} True if old and new endpoints have same coordinates + */ +function hasSameEndpoints(cachedEdge, nodes) { + const oldPoints = cachedEdge.get('points'); + const oldSourcePoint = oldPoints[0]; + const oldTargetPoint = oldPoints[oldPoints.length - 1]; + const newSource = nodes.get(cachedEdge.get('source')); + const newTarget = nodes.get(cachedEdge.get('target')); + return (oldSourcePoint.x === newSource.get('x') + && oldSourcePoint.y === newSource.get('y') + && oldTargetPoint.x === newTarget.get('x') + && oldTargetPoint.y === newTarget.get('y')); +} + +/** + * Clones a previous layout + * @param {Object} layout Layout object + * @param {Map} nodes new nodes + * @param {Map} edges new edges + * @return {Object} layout clone + */ +function cloneLayout(layout, nodes, edges) { + const clone = {...layout, nodes, edges}; + return clone; +} + +/** + * Copies node properties from previous layout runs to new nodes. + * This assumes the cache has data for all new nodes. + * @param {Object} layout Layout + * @param {Object} nodeCache cache of all old nodes + * @param {Object} edgeCache cache of all old edges + * @return {Object} modified layout + */ +function copyLayoutProperties(layout, nodeCache, edgeCache) { + layout.nodes = layout.nodes.map(node => { + return node.merge(nodeCache.get(node.get('id'))); + }); + layout.edges = layout.edges.map(edge => { + if (edgeCache.has(edge.get('id')) && hasSameEndpoints(edgeCache.get(edge.get('id')), layout.nodes)) { + return edge.merge(edgeCache.get(edge.get('id'))); + } + return setSimpleEdgePoints(edge, nodeCache); + }); + return layout; +} + +/** + * Layout of nodes and edges + * If a previous layout was given and not too much changed, the previous layout + * is changed and returned. Otherwise does a new layout engine run. + * @param {Map} nodes All nodes + * @param {Map} edges All edges + * @param {object} opts width, height, margins, etc... + * @return {object} graph object with nodes, edges, dimensions + */ +export function doLayout(nodes, edges, opts) { + const options = opts || {}; + const topologyId = options.topologyId || 'noId'; + + // one engine and node and edge caches per topology, to keep renderings similar + if (!topologyCaches[topologyId]) { + topologyCaches[topologyId] = { + nodeCache: makeMap(), + edgeCache: makeMap(), + graph: new dagre.graphlib.Graph({}) + }; + } + + const cache = topologyCaches[topologyId]; + const cachedLayout = options.cachedLayout || cache.cachedLayout; + const nodeCache = options.nodeCache || cache.nodeCache; + const edgeCache = options.edgeCache || cache.edgeCache; + let layout; + + ++layoutRuns; + if (cachedLayout && nodeCache && edgeCache && !hasUnseenNodes(nodes, nodeCache)) { + debug('skip layout, trivial adjustment', ++layoutRunsTrivial, layoutRuns); + layout = cloneLayout(cachedLayout, nodes, edges); + // copy old properties, works also if nodes get re-added + layout = copyLayoutProperties(layout, nodeCache, edgeCache); + } else { + const graph = cache.graph; + layout = runLayoutEngine(graph, nodes, edges, opts); + } + + // cache results + if (layout) { + cache.cachedLayout = layout; + cache.nodeCache = cache.nodeCache.merge(layout.nodes); + cache.edgeCache = cache.edgeCache.merge(layout.edges); + } return layout; -}; - -module.exports = { - doLayout: doLayout -}; +} diff --git a/client/package.json b/client/package.json index 0fd48ea14..6d4fcbdeb 100644 --- a/client/package.json +++ b/client/package.json @@ -78,6 +78,7 @@ "react", "immutable", "d3", + "dagre", "keymirror", "object-assign", "lodash",