Rewrote nodes-layout to use immutablejs

This commit is contained in:
David Kaltschmidt
2015-10-27 20:24:28 +00:00
parent e4da515fa1
commit 7306e8fb5f
3 changed files with 253 additions and 173 deletions

View File

@@ -1,6 +1,8 @@
jest.dontMock('../nodes-layout');
jest.dontMock('../../constants/naming'); // edge naming: 'source-target'
import { fromJS } from 'immutable';
describe('NodesLayout', () => {
const NodesLayout = require('../nodes-layout');
@@ -14,6 +16,8 @@ describe('NodesLayout', () => {
left: 0,
top: 0
};
let history;
let nodes;
const nodeSets = {
initial4: {
@@ -24,27 +28,57 @@ describe('NodesLayout', () => {
n4: {id: 'n4'}
},
edges: {
'n1-n3': {id: 'n1-n3', source: {id: 'n1'}, target: {id: 'n3'}},
'n1-n4': {id: 'n1-n4', source: {id: 'n1'}, target: {id: 'n4'}},
'n2-n4': {id: 'n2-n4', source: {id: 'n2'}, target: {id: 'n4'}}
'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: {
n1: {id: 'n1'},
n2: {id: 'n2'},
n3: {id: 'n3'},
n4: {id: 'n4'}
},
edges: {
'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'},
'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}
}
}
};
it('lays out initial nodeset', () => {
const nodes = nodeSets.initial4.nodes;
const edges = nodeSets.initial4.edges;
NodesLayout.doLayout(nodes, edges);
it('lays out initial nodeset in a rectangle', () => {
const result = NodesLayout.doLayout(
fromJS(nodeSets.initial4.nodes),
fromJS(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);
console.log(nodes, nodeSets.initial4.nodes);
});
// it('keeps nodes in rectangle after removing one edge', () => {
// history = [{
// nodes: nodeSets.initial4.nodes,
// edges: nodeSets.initial4.edges
// }];
// nodes = nodeSets.removeEdge24.nodes;
// edges = nodeSets.removeEdge24.edges;
// NodesLayout.doLayout(nodes, edges, {history});
// console.log('remove 1 edge', nodes);
//
// 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);
//
// });
});

View File

@@ -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,60 +97,81 @@ 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) {
@@ -187,7 +209,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 +241,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 +265,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 +288,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 +393,8 @@ const NodesChart = React.createClass({
return {
autoShifted: autoShifted,
edges: layoutEdges,
nodes: layoutNodes,
edges: stateEdges,
nodes: stateNodes,
shiftTranslate: shiftTranslate
};
},
@@ -394,21 +414,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,13 +436,13 @@ 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
@@ -437,7 +457,7 @@ const NodesChart = React.createClass({
};
const timedLayouter = timely(NodesLayout.doLayout);
const graph = timedLayouter(nodes, edges, options);
const graph = timedLayouter(stateNodes, stateEdges, options);
debug('graph layout took ' + timedLayouter.time + 'ms');
@@ -445,14 +465,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
@@ -468,8 +492,8 @@ const NodesChart = React.createClass({
}
return {
nodes: nodes,
edges: edges,
nodes: stateNodes,
edges: stateEdges,
nodeScale: nodeScale,
scale: zoomScale,
maxNodesExceeded: false

View File

@@ -1,12 +1,19 @@
const dagre = require('dagre');
const debug = require('debug')('scope:nodes-layout');
const Naming = require('../constants/naming');
const _ = require('lodash');
const MAX_NODES = 100;
const topologyGraphs = {};
export function doLayout(nodes, edges, opts) {
function runLayoutEngine(imNodes, imEdges, opts) {
let nodes = imNodes;
let edges = imEdges;
if (nodes.size > MAX_NODES) {
debug('Too many nodes for graph layout engine. Limit: ' + MAX_NODES);
return null;
}
const options = opts || {};
const margins = options.margins || {top: 0, left: 0};
const width = options.width || 800;
@@ -14,20 +21,11 @@ export function doLayout(nodes, edges, opts) {
const scale = options.scale || (val => val * 2);
const topologyId = options.topologyId || 'noId';
let offsetX = 0 + margins.left;
let offsetY = 0 + margins.top;
let graph;
if (_.size(nodes) > 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 graph = topologyGraphs[topologyId];
// configure node margins
graph.setGraph({
@@ -36,10 +34,10 @@ export function doLayout(nodes, edges, opts) {
});
// 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)
});
@@ -47,35 +45,41 @@ export function doLayout(nodes, edges, opts) {
});
// 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;
}
@@ -85,27 +89,45 @@ export function doLayout(nodes, edges, opts) {
// 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;
}
/**
* Layout of nodes and edges
* @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 history = options.history || [];
return runLayoutEngine(nodes, edges, opts);
}