diff --git a/client/app/scripts/charts/__tests__/node-layout-test.js b/client/app/scripts/charts/__tests__/node-layout-test.js index 7baa0d33e..72f0467c5 100644 --- a/client/app/scripts/charts/__tests__/node-layout-test.js +++ b/client/app/scripts/charts/__tests__/node-layout-test.js @@ -35,6 +35,36 @@ describe('NodesLayout', () => { 'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'} }) }, + rank4: { + nodes: fromJS({ + 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-n6': {id: 'n2-n6', source: 'n2', target: 'n6'}, + }) + }, removeEdge24: { nodes: fromJS({ n1: {id: 'n1'}, @@ -86,10 +116,26 @@ 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'} + }) } }; beforeEach(() => { + // clear feature flags + window.localStorage.clear(); + options = { nodeCache: makeMap(), edgeCache: makeMap() @@ -121,6 +167,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, @@ -266,7 +360,9 @@ describe('NodesLayout', () => { it('renders single nodes next to portrait graph', () => { const result = NodesLayout.doLayout( nodeSets.singlePortrait.nodes, - nodeSets.singlePortrait.edges); + nodeSets.singlePortrait.edges, + { noCache: true } + ); nodes = result.nodes.toJS(); @@ -282,4 +378,79 @@ 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, + { noCache: true } + ); + + 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', () => { + // feature flag + window.localStorage.setItem('scope-experimental:layout-dance', true); + + 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 116e35f4d..670671777 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -3,13 +3,15 @@ import debug from 'debug'; import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; +import { featureIsEnabledAny } from '../utils/feature-utils'; 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; @@ -118,6 +120,8 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) { // return object with the width and height of layout return { + graphWidth: layout.width, + graphHeight: layout.height, width: layout.width, height: layout.height, nodes, @@ -125,6 +129,65 @@ function runLayoutEngine(graph, imNodes, imEdges, 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 + 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; +} + /** * Add coordinates to 0-degree nodes using a square layout * Depending on the previous layout run's graph aspect ratio, the square will be @@ -142,7 +205,9 @@ function layoutSingleNodes(layout, opts) { const nodesep = scale(NODE_SEPARATION_FACTOR); const nodeWidth = scale(NODE_SIZE_FACTOR); const nodeHeight = scale(NODE_SIZE_FACTOR); - const aspectRatio = layout.height ? layout.width / layout.height : 1; + const graphHeight = layout.graphHeight || layout.height; + const graphWidth = layout.graphWidth || layout.width; + const aspectRatio = graphHeight ? graphWidth / graphHeight : 1; let nodes = layout.nodes; @@ -212,53 +277,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 @@ -274,6 +330,45 @@ export function hasUnseenNodes(nodes, cache) { return hasUnseen; } +/** + * Determine if all new nodes are 0-degree nodes + * Requires cached nodes (implies a previous layout run). + * @param {Map} nodes new Map of nodes + * @param {Map} cache old Map of nodes + * @return {Boolean} True if all new nodes are 0-nodes + */ +function hasNewSingleNode(nodes, cache) { + const oldNodes = ImmSet.fromKeys(cache); + const newNodes = ImmSet.fromKeys(nodes).subtract(oldNodes); + const hasNewSingleNodes = newNodes.every(key => nodes.getIn([key, 'degree']) === 0); + return oldNodes.size > 0 && hasNewSingleNodes; +} + +/** + * 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 + */ +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; +} + /** * Determine if edge has same endpoints in new nodes as well as in the nodeCache * @param {Map} edge Edge with source and target @@ -315,13 +410,16 @@ function cloneLayout(layout, nodes, edges) { */ function copyLayoutProperties(layout, nodeCache, edgeCache) { const result = Object.assign({}, layout); - result.nodes = layout.nodes.map(node => node.merge(nodeCache.get(node.get('id')))); + result.nodes = layout.nodes.map(node => (nodeCache.has(node.get('id')) + ? node.merge(nodeCache.get(node.get('id'))) : node)); result.edges = layout.edges.map(edge => { 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; } @@ -340,7 +438,7 @@ export function doLayout(immNodes, immEdges, opts) { const cacheId = buildTopologyCacheId(options.topologyId, options.topologyOptions); // one engine and node and edge caches per topology, to keep renderings similar - if (!topologyCaches[cacheId]) { + if (options.noCache || !topologyCaches[cacheId]) { topologyCaches[cacheId] = { nodeCache: makeMap(), edgeCache: makeMap(), @@ -352,22 +450,42 @@ export function doLayout(immNodes, immEdges, opts) { const cachedLayout = options.cachedLayout || cache.cachedLayout; const nodeCache = options.nodeCache || cache.nodeCache; const edgeCache = options.edgeCache || cache.edgeCache; + const useCache = !options.forceRelayout && cachedLayout && nodeCache && edgeCache; let layout; ++layoutRuns; - if (!options.forceRelayout && cachedLayout && nodeCache && edgeCache - && !hasUnseenNodes(immNodes, nodeCache)) { + if (useCache && !hasUnseenNodes(immNodes, nodeCache)) { log('skip layout, trivial adjustment', ++layoutRunsTrivial, layoutRuns); layout = cloneLayout(cachedLayout, immNodes, immEdges); // copy old properties, works also if nodes get re-added layout = copyLayoutProperties(layout, nodeCache, edgeCache); } else { - const graph = cache.graph; const nodesWithDegrees = updateNodeDegrees(immNodes, immEdges); - layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts); - if (!layout) { - return layout; + if (useCache + && featureIsEnabledAny('layout-dance', 'layout-dance-single') + && hasNewSingleNode(nodesWithDegrees, nodeCache)) { + // special case: new nodes are 0-degree nodes, no need for layout run, + // they will be laid out further below + log('skip layout, only 0-degree node(s) added'); + layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges); + layout = copyLayoutProperties(layout, nodeCache, edgeCache); + } else if (useCache + && featureIsEnabledAny('layout-dance', 'layout-dance-rank') + && 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, nodeCache, opts); + } else { + const graph = cache.graph; + 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..14d5eaa74 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.intermittentTimer = null; + this.intermittentNodes = makeSet(); + this.shortLivedTimer = null; + this.shortLivedNodes = makeSet(); this.state = { nodesToAdd: 30, showColors: false @@ -184,6 +188,53 @@ class DebugToolbar extends React.Component { this.asyncDispatch(setAppState(state => state.set('topologiesLoaded', !loading))); } + setIntermittent() { + // simulate epheremal nodes + if (this.intermittentTimer) { + clearInterval(this.intermittentTimer); + this.intermittentTimer = null; + } else { + this.intermittentTimer = 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(); @@ -303,6 +354,12 @@ class DebugToolbar extends React.Component { +