mirror of
https://github.com/weaveworks/scope.git
synced 2026-07-28 17:51:21 +00:00
Merge pull request #593 from weaveworks/419-dancing
Mitigate dancing nodes
This commit is contained in:
234
client/app/scripts/charts/__tests__/node-layout-test.js
Normal file
234
client/app/scripts/charts/__tests__/node-layout-test.js
Normal file
@@ -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));
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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 (
|
||||
<Node
|
||||
blurred={node.blurred}
|
||||
focused={node.focused}
|
||||
highlighted={node.highlighted}
|
||||
// make sure blurred nodes are in the background
|
||||
const sortNodes = node => {
|
||||
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 (<Node
|
||||
blurred={node.get('blurred')}
|
||||
focused={node.get('focused')}
|
||||
highlighted={node.get('highlighted')}
|
||||
onClick={onNodeClick}
|
||||
key={node.id}
|
||||
id={node.id}
|
||||
label={node.label}
|
||||
pseudo={node.pseudo}
|
||||
subLabel={node.subLabel}
|
||||
rank={node.rank}
|
||||
key={node.get('id')}
|
||||
id={node.get('id')}
|
||||
label={node.get('label')}
|
||||
pseudo={node.get('pseudo')}
|
||||
subLabel={node.get('subLabel')}
|
||||
rank={node.get('rank')}
|
||||
scale={scale}
|
||||
dx={node.x}
|
||||
dy={node.y}
|
||||
dx={node.get('x')}
|
||||
dy={node.get('y')}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.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 (
|
||||
<Edge key={edge.id} id={edge.id} points={edge.points} blurred={blurred}
|
||||
highlighted={highlighted} />
|
||||
);
|
||||
}, 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 (
|
||||
<Edge key={edge.get('id')} id={edge.get('id')} points={edge.get('points')}
|
||||
blurred={edge.get('blurred')} highlighted={edge.get('highlighted')} />
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
renderMaxNodesError: function(show) {
|
||||
const errorHint = 'We\u0027re working on it, but for now, try a different view?';
|
||||
return (
|
||||
<NodesError faIconClass="fa-ban" hidden={!show}>
|
||||
<div className="centered">Too many nodes to show in the browser.<br />We're working on it, but for now, try a different view?</div>
|
||||
<div className="centered">Too many nodes to show in the browser.<br />{errorHint}</div>
|
||||
</NodesError>
|
||||
);
|
||||
},
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"react",
|
||||
"immutable",
|
||||
"d3",
|
||||
"dagre",
|
||||
"keymirror",
|
||||
"object-assign",
|
||||
"lodash",
|
||||
|
||||
Reference in New Issue
Block a user