diff --git a/client/app/scripts/charts/__tests__/node-layout-test.js b/client/app/scripts/charts/__tests__/node-layout-test.js index f55b61203..9668705cd 100644 --- a/client/app/scripts/charts/__tests__/node-layout-test.js +++ b/client/app/scripts/charts/__tests__/node-layout-test.js @@ -35,19 +35,34 @@ describe('NodesLayout', () => { 'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'} }) }, - addNode15: { + rank4: { nodes: fromJS({ - n1: {id: 'n1'}, - n2: {id: 'n2'}, - n3: {id: 'n3'}, - n4: {id: 'n4'}, - n5: {id: 'n5'} + n1: {id: 'n1', rank: 'A'}, + n2: {id: 'n2', rank: 'A'}, + n3: {id: 'n3', rank: 'B'}, + n4: {id: 'n4', rank: 'B'} + }), + 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'} + }) + }, + rank6: { + nodes: fromJS({ + n1: {id: 'n1', rank: 'A'}, + n2: {id: 'n2', rank: 'A'}, + n3: {id: 'n3', rank: 'B'}, + n4: {id: 'n4', rank: 'B'}, + n5: {id: 'n5', rank: 'A'}, + n6: {id: 'n6', rank: 'B'}, }), edges: fromJS({ 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}, 'n1-n5': {id: 'n1-n5', source: 'n1', target: 'n5'}, - 'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'} + 'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'}, + 'n2-n6': {id: 'n2-n6', source: 'n2', target: 'n6'}, }) }, removeEdge24: { @@ -149,6 +164,54 @@ describe('NodesLayout', () => { expect(hasUnseen).toBeTruthy(); }); + it('shifts layouts to center', () => { + let xMin; + let xMax; + let yMin; + let yMax; + let xCenter; + let yCenter; + + // make sure initial layout is centered + const original = NodesLayout.doLayout( + nodeSets.initial4.nodes, + nodeSets.initial4.edges + ); + xMin = original.nodes.minBy(n => n.get('x')); + xMax = original.nodes.maxBy(n => n.get('x')); + yMin = original.nodes.minBy(n => n.get('y')); + yMax = original.nodes.maxBy(n => n.get('y')); + xCenter = (xMin.get('x') + xMax.get('x')) / 2; + yCenter = (yMin.get('y') + yMax.get('y')) / 2; + expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2); + expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2); + + // make sure re-running is idempotent + const rerun = NodesLayout.shiftLayoutToCenter(original); + xMin = rerun.nodes.minBy(n => n.get('x')); + xMax = rerun.nodes.maxBy(n => n.get('x')); + yMin = rerun.nodes.minBy(n => n.get('y')); + yMax = rerun.nodes.maxBy(n => n.get('y')); + xCenter = (xMin.get('x') + xMax.get('x')) / 2; + yCenter = (yMin.get('y') + yMax.get('y')) / 2; + expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2); + expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2); + + // shift after window was resized + const shifted = NodesLayout.shiftLayoutToCenter(original, { + width: 128, + height: 256 + }); + xMin = shifted.nodes.minBy(n => n.get('x')); + xMax = shifted.nodes.maxBy(n => n.get('x')); + yMin = shifted.nodes.minBy(n => n.get('y')); + yMax = shifted.nodes.maxBy(n => n.get('y')); + xCenter = (xMin.get('x') + xMax.get('x')) / 2; + yCenter = (yMin.get('y') + yMax.get('y')) / 2; + expect(xCenter).toEqual(128 / 2); + expect(yCenter).toEqual(256 / 2); + }); + it('lays out initial nodeset in a rectangle', () => { const result = NodesLayout.doLayout( nodeSets.initial4.nodes, @@ -351,28 +414,37 @@ describe('NodesLayout', () => { expect(nodes.n1.x).toBeLessThan(nodes.n5.x); expect(nodes.n1.x).toBeLessThan(nodes.n6.x); }); - // - // it('adds a new node to existing layout in a line', () => { - // 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.addNode15.nodes, - // nodeSets.addNode15.edges, - // options - // ); - // - // nodes = result.nodes.toJS(); - // - // expect(nodes.n1.x).toBeGreaterThan(nodes.n5.x); - // expect(nodes.n1.y).toEqual(nodes.n5.y); - // }); + + it('adds a new node to existing layout in a line', () => { + let result = NodesLayout.doLayout( + nodeSets.rank4.nodes, + nodeSets.rank4.edges, + { noCache: true } + ); + + 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); + + expect(NodesLayout.hasNewNodesOfExistingRank( + nodeSets.rank6.nodes, + nodeSets.rank6.edges, + result.nodes)).toBeTruthy(); + + result = NodesLayout.doLayout( + nodeSets.rank6.nodes, + nodeSets.rank6.edges, + options + ); + + nodes = result.nodes.toJS(); + + expect(nodes.n5.x).toBeGreaterThan(nodes.n1.x); + expect(nodes.n5.y).toEqual(nodes.n1.y); + expect(nodes.n6.x).toBeGreaterThan(nodes.n3.x); + expect(nodes.n6.y).toEqual(nodes.n3.y); + }); }); diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index bb2d803bd..785cda483 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -8,8 +8,9 @@ import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils const log = debug('scope:nodes-layout'); const topologyCaches = {}; -const DEFAULT_WIDTH = 800; -const DEFAULT_MARGINS = {top: 0, left: 0}; +export const DEFAULT_WIDTH = 800; +export const DEFAULT_HEIGHT = DEFAULT_WIDTH / 2; +export const DEFAULT_MARGINS = {top: 0, left: 0}; const DEFAULT_SCALE = val => val * 2; const NODE_SIZE_FACTOR = 1; const NODE_SEPARATION_FACTOR = 2.0; @@ -127,11 +128,63 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) { }; } -export function doLayoutNewNodesOfExistingRank(layout, immNodes, immEdges, opts) { - console.log(opts); +/** + * 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', fromJS([ + {x: source.get('x'), y: source.get('y')}, + {x: target.get('x'), y: target.get('y')} + ])); +} + +/** + * Layout nodes that have rank that already exists. + * Relies on only nodes being added that have a connection to an existing node + * while having a rank of an existing node. They will be laid out in the same + * line as the latter, with a direct connection between the existing and the new node. + * @param {object} layout Layout with nodes and edges + * @param {Map} nodeCache previous nodes + * @param {object} opts Options + * @return {object} new layout object + */ +export function doLayoutNewNodesOfExistingRank(layout, nodeCache, opts) { + const result = Object.assign({}, layout); + const options = opts || {}; + const scale = options.scale || DEFAULT_SCALE; + const nodesep = scale(NODE_SEPARATION_FACTOR); + const nodeWidth = scale(NODE_SIZE_FACTOR); + // determine new nodes - // layout new nodes - // return layout + const oldNodes = ImmSet.fromKeys(nodeCache); + const newNodes = ImmSet.fromKeys(layout.nodes.filter(n => n.get('degree') > 0)) + .subtract(oldNodes); + result.nodes = layout.nodes.map(n => { + if (newNodes.contains(n.get('id'))) { + const nodesSameRank = nodeCache.filter(nn => nn.get('rank') === n.get('rank')); + if (nodesSameRank.size > 0) { + const y = nodesSameRank.first().get('y'); + const x = nodesSameRank.maxBy(nn => nn.get('x')).get('x') + nodesep + nodeWidth; + return n.merge({ x, y }); + } + return n; + } + return n; + }); + + result.edges = layout.edges.map(edge => { + if (!edge.has('points')) { + return setSimpleEdgePoints(edge, layout.nodes); + } + return edge; + }); + + return result; } /** @@ -223,53 +276,44 @@ function layoutSingleNodes(layout, opts) { * @param {Object} opts Options with width and margins * @return {Object} modified layout */ -function shiftLayoutToCenter(layout, opts) { +export function shiftLayoutToCenter(layout, opts) { const result = Object.assign({}, layout); const options = opts || {}; const margins = options.margins || DEFAULT_MARGINS; const width = options.width || DEFAULT_WIDTH; - const height = options.height || width / 2; + const height = options.height || DEFAULT_HEIGHT; let offsetX = 0 + margins.left; let offsetY = 0 + margins.top; if (layout.width < width) { - offsetX = (width - layout.width) / 2 + margins.left; + const xMin = layout.nodes.minBy(n => n.get('x')); + const xMax = layout.nodes.maxBy(n => n.get('x')); + offsetX = (width - (xMin.get('x') + xMax.get('x'))) / 2 + margins.left; } if (layout.height < height) { - offsetY = (height - layout.height) / 2 + margins.top; + const yMin = layout.nodes.minBy(n => n.get('y')); + const yMax = layout.nodes.maxBy(n => n.get('y')); + offsetY = (height - (yMin.get('y') + yMax.get('y'))) / 2 + margins.top; } - result.nodes = layout.nodes.map(node => node.merge({ - x: node.get('x') + offsetX, - y: node.get('y') + offsetY - })); + if (offsetX || offsetY) { + result.nodes = layout.nodes.map(node => node.merge({ + x: node.get('x') + offsetX, + y: node.get('y') + offsetY + })); - result.edges = layout.edges.map(edge => edge.update('points', - points => points.map(point => point.merge({ - x: point.get('x') + offsetX, - y: point.get('y') + offsetY - })) - )); + result.edges = layout.edges.map(edge => edge.update('points', + points => points.map(point => point.merge({ + x: point.get('x') + offsetX, + y: point.get('y') + offsetY + })) + )); + } return result; } -/** - * 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', fromJS([ - {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 @@ -303,11 +347,25 @@ function hasNewSingleNode(nodes, cache) { * Determine if all new nodes are of existing ranks * Requires cached nodes (implies a previous layout run). * @param {Map} nodes new Map of nodes + * @param {Map} edges new Map of edges * @param {Map} cache old Map of nodes * @return {Boolean} True if all new nodes have a rank that already exists */ -function hasNewNodesOfExistingRank(nodes, cache) { - return false && nodes && cache; +export function hasNewNodesOfExistingRank(nodes, edges, cache) { + const oldNodes = ImmSet.fromKeys(cache); + const newNodes = ImmSet.fromKeys(nodes).subtract(oldNodes); + + // if new there are edges that connect 2 new nodes, need a full layout + const bothNodesNew = edges.find(edge => newNodes.contains(edge.get('source')) + && newNodes.contains(edge.get('target'))); + if (bothNodesNew) { + return false; + } + + const oldRanks = cache.filter(n => n.get('rank')).map(n => n.get('rank')).toSet(); + const hasNewNodesOfExistingRankOrSingle = newNodes.every(key => nodes.getIn([key, 'degree']) === 0 + || oldRanks.contains(nodes.getIn([key, 'rank']))); + return oldNodes.size > 0 && hasNewNodesOfExistingRankOrSingle; } /** @@ -357,8 +415,10 @@ function copyLayoutProperties(layout, nodeCache, edgeCache) { if (edgeCache.has(edge.get('id')) && hasSameEndpoints(edgeCache.get(edge.get('id')), result.nodes)) { return edge.merge(edgeCache.get(edge.get('id'))); + } else if (nodeCache.get(edge.get('source')) && nodeCache.get(edge.get('target'))) { + return setSimpleEdgePoints(edge, nodeCache); } - return setSimpleEdgePoints(edge, nodeCache); + return edge; }); return result; } @@ -406,13 +466,13 @@ export function doLayout(immNodes, immEdges, opts) { log('skip layout, only 0-degree node(s) added'); layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges); layout = copyLayoutProperties(layout, nodeCache, edgeCache); - } else if (useCache && hasNewNodesOfExistingRank(nodesWithDegrees, nodeCache)) { + } else if (useCache && hasNewNodesOfExistingRank(nodesWithDegrees, immEdges, nodeCache)) { // special case: few new nodes were added, no need for layout run, // they will inserted according to ranks log('skip layout, used rank-based insertion'); layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges); layout = copyLayoutProperties(layout, nodeCache, edgeCache); - layout = doLayoutNewNodesOfExistingRank(layout, nodesWithDegrees, immEdges); + layout = doLayoutNewNodesOfExistingRank(layout, nodeCache, opts); } else { const graph = cache.graph; layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts); @@ -420,6 +480,7 @@ export function doLayout(immNodes, immEdges, opts) { return layout; } } + layout = layoutSingleNodes(layout, opts); layout = shiftLayoutToCenter(layout, opts); }