diff --git a/client/app/scripts/charts/__tests__/node-layout-test.js b/client/app/scripts/charts/__tests__/node-layout-test.js index 7baa0d33e..8f8c7b969 100644 --- a/client/app/scripts/charts/__tests__/node-layout-test.js +++ b/client/app/scripts/charts/__tests__/node-layout-test.js @@ -35,6 +35,21 @@ describe('NodesLayout', () => { 'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'} }) }, + addNode15: { + nodes: fromJS({ + n1: {id: 'n1'}, + n2: {id: 'n2'}, + n3: {id: 'n3'}, + n4: {id: 'n4'}, + n5: {id: 'n5'} + }), + 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'} + }) + }, removeEdge24: { nodes: fromJS({ n1: {id: 'n1'}, @@ -86,6 +101,19 @@ describe('NodesLayout', () => { edges: fromJS({ 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} }) + }, + singlePortrait6: { + nodes: fromJS({ + n1: {id: 'n1'}, + n2: {id: 'n2'}, + n3: {id: 'n3'}, + n4: {id: 'n4'}, + n5: {id: 'n5'}, + n6: {id: 'n6'} + }), + edges: fromJS({ + 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} + }) } }; @@ -282,4 +310,65 @@ describe('NodesLayout', () => { expect(nodes.n1.x).toBeLessThan(nodes.n5.x); expect(nodes.n2.x).toEqual(nodes.n5.x); }); + + it('renders an additional single node in single nodes group', () => { + let result = NodesLayout.doLayout( + nodeSets.singlePortrait.nodes, + nodeSets.singlePortrait.edges); + + nodes = result.nodes.toJS(); + + // first square row on same level as top-most other node + expect(nodes.n1.y).toEqual(nodes.n2.y); + expect(nodes.n1.y).toEqual(nodes.n3.y); + expect(nodes.n4.y).toEqual(nodes.n5.y); + + // all singles right to other nodes + expect(nodes.n1.x).toEqual(nodes.n4.x); + expect(nodes.n1.x).toBeLessThan(nodes.n2.x); + expect(nodes.n1.x).toBeLessThan(nodes.n3.x); + expect(nodes.n1.x).toBeLessThan(nodes.n5.x); + expect(nodes.n2.x).toEqual(nodes.n5.x); + + options.cachedLayout = result; + options.nodeCache = options.nodeCache.merge(result.nodes); + options.edgeCache = options.edgeCache.merge(result.edge); + + result = NodesLayout.doLayout( + nodeSets.singlePortrait6.nodes, + nodeSets.singlePortrait6.edges, + options + ); + + nodes = result.nodes.toJS(); + + expect(nodes.n1.x).toBeLessThan(nodes.n2.x); + expect(nodes.n1.x).toBeLessThan(nodes.n3.x); + 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); + }); }); diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index 116e35f4d..e41b5a9b8 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -274,6 +274,13 @@ export function hasUnseenNodes(nodes, cache) { return hasUnseen; } +function hasNewSingleNode(nodes, cache) { + return (ImmSet + .fromKeys(nodes) + .subtract(ImmSet.fromKeys(cache)) + .every(key => nodes.getIn([key, 'degree']) === 0)); +} + /** * Determine if edge has same endpoints in new nodes as well as in the nodeCache * @param {Map} edge Edge with source and target @@ -364,9 +371,14 @@ export function doLayout(immNodes, immEdges, opts) { } else { const graph = cache.graph; const nodesWithDegrees = updateNodeDegrees(immNodes, immEdges); - layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts); - if (!layout) { - return layout; + if (hasNewSingleNode(nodesWithDegrees, nodeCache)) { + layout = cloneLayout(cachedLayout, immNodes, immEdges); + layout = copyLayoutProperties(layout, nodeCache, edgeCache); + } else { + layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts); + if (!layout) { + return layout; + } } layout = layoutSingleNodes(layout, opts); layout = shiftLayoutToCenter(layout, opts); diff --git a/client/app/scripts/components/debug-toolbar.js b/client/app/scripts/components/debug-toolbar.js index 1888d9fda..34e771cca 100644 --- a/client/app/scripts/components/debug-toolbar.js +++ b/client/app/scripts/components/debug-toolbar.js @@ -4,7 +4,7 @@ import d3 from 'd3'; import _ from 'lodash'; import Perf from 'react-addons-perf'; import { connect } from 'react-redux'; -import { fromJS } from 'immutable'; +import { fromJS, Set as makeSet } from 'immutable'; import debug from 'debug'; const log = debug('scope:debug-panel'); @@ -160,6 +160,10 @@ class DebugToolbar extends React.Component { this.onChange = this.onChange.bind(this); this.toggleColors = this.toggleColors.bind(this); this.addNodes = this.addNodes.bind(this); + this.intermittendTimer = null; + this.intermittendNodes = makeSet(); + this.shortLivedTimer = null; + this.shortLivedNodes = makeSet(); this.state = { nodesToAdd: 30, showColors: false @@ -197,6 +201,66 @@ class DebugToolbar extends React.Component { })); } + setIntermittend() { + // simulate epheremal nodes + if (this.intermittendTimer) { + clearInterval(this.intermittendTimer); + this.intermittendTimer = null; + } else { + this.intermittendTimer = setInterval(() => { + // add new node + this.addNodes(1); + + // remove random node + const ns = this.props.nodes; + const nodeNames = ns.keySeq().toJS(); + const randomNode = _.sample(nodeNames); + this.asyncDispatch(receiveNodesDelta({ + remove: [randomNode] + })); + }, 1000); + } + } + + setShortLived() { + // simulate nodes with same ID popping in and out + if (this.shortLivedTimer) { + clearInterval(this.shortLivedTimer); + this.shortLivedTimer = null; + } else { + this.shortLivedTimer = setInterval(() => { + // filter random node + const ns = this.props.nodes; + const nodeNames = ns.keySeq().toJS(); + const randomNode = _.sample(nodeNames); + if (randomNode) { + let nextNodes = ns.setIn([randomNode, 'filtered'], true); + this.shortLivedNodes = this.shortLivedNodes.add(randomNode); + // bring nodes back after a bit + if (this.shortLivedNodes.size > 5) { + const returningNode = this.shortLivedNodes.first(); + this.shortLivedNodes = this.shortLivedNodes.rest(); + nextNodes = nextNodes.setIn([returningNode, 'filtered'], false); + } + this.asyncDispatch(setAppState(state => state.set('nodes', nextNodes))); + } + }, 1000); + } + } + + updateAdjacencies() { + const ns = this.props.nodes; + const nodeNames = ns.keySeq().toJS(); + this.asyncDispatch(receiveNodesDelta({ + add: this._addNodes(7), + update: sample(nodeNames).map(n => ({ + id: n, + adjacency: sample(nodeNames), + }), nodeNames.length), + remove: this._removeNode(), + })); + } + _addNodes(n, prefix = 'zing') { const ns = this.props.nodes; const nodeNames = ns.keySeq().toJS(); @@ -303,6 +367,12 @@ class DebugToolbar extends React.Component { +
+ + + +
+