diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js
index 95ba8a467..fdce656aa 100644
--- a/client/app/scripts/charts/nodes-chart.js
+++ b/client/app/scripts/charts/nodes-chart.js
@@ -3,7 +3,7 @@ import d3 from 'd3';
import debug from 'debug';
import React from 'react';
import { connect } from 'react-redux';
-import { Map as makeMap, fromJS, is as isDeepEqual } from 'immutable';
+import { Map as makeMap, fromJS, is } from 'immutable';
import timely from 'timely';
import { clickBackground } from '../actions/app-actions';
@@ -23,6 +23,125 @@ const radiusDensity = d3.scale.threshold()
.domain([3, 6])
.range([2.5, 3.5, 3]);
+
+function toNodes(nodes) {
+ return nodes.map((node, id) => makeMap({
+ id,
+ label: node.get('label'),
+ pseudo: node.get('pseudo'),
+ subLabel: node.get('label_minor'),
+ nodeCount: node.get('node_count'),
+ metrics: node.get('metrics'),
+ rank: node.get('rank'),
+ shape: node.get('shape'),
+ stack: node.get('stack'),
+ networks: node.get('networks'),
+ }));
+}
+
+
+function identityPresevingMerge(a, b) {
+ //
+ // merge two maps, if the values are equal return the old value to preserve (a === a')
+ //
+ // Note: mergeDeep keeps identity but we can't always use that. E.g. if nodes have been removed in
+ // b but still exist in a. they will still exist in the result.
+ //
+ return a.mergeWith((v1, v2) => is(v1, v2) ? v1 : v2, a, b);
+}
+
+
+function mergeDeepIfExists(mapA, mapB) {
+ //
+ // Does a deep merge on any key that exists in the first map
+ //
+ return mapA.map((v, k) => v.mergeDeep(mapB.get(k)));
+}
+
+
+function getLayoutNodes(nodes) {
+ return nodes.map(n => makeMap({
+ id: n.get('id'),
+ adjacency: n.get('adjacency'),
+ }));
+}
+
+
+function initEdges(nodes) {
+ let edges = makeMap();
+
+ nodes.forEach((node, nodeId) => {
+ const adjacency = node.get('adjacency');
+ if (adjacency) {
+ adjacency.forEach(adjacent => {
+ const edge = [nodeId, adjacent];
+ const edgeId = edge.join(EDGE_ID_SEPARATOR);
+
+ if (!edges.has(edgeId)) {
+ const source = edge[0];
+ const target = edge[1];
+ if (nodes.has(source) && nodes.has(target)) {
+ edges = edges.set(edgeId, makeMap({
+ id: edgeId,
+ value: 1,
+ source,
+ target
+ }));
+ }
+ }
+ });
+ }
+ });
+
+ return edges;
+}
+
+
+function getNodeScale(nodes, width, height) {
+ const expanse = Math.min(height, width);
+ const nodeSize = expanse / 3; // single node should fill a third of the screen
+ const maxNodeSize = Math.min(MAX_NODE_SIZE, expanse / 10);
+ const normalizedNodeSize = Math.max(MIN_NODE_SIZE,
+ Math.min(nodeSize / Math.sqrt(nodes.size), maxNodeSize));
+
+ return d3.scale.linear().range([0, normalizedNodeSize]);
+}
+
+
+function updateLayout({width, height, margins, topologyId, topologyOptions, forceRelayout,
+ nodes }) {
+ const nodeScale = getNodeScale(nodes, width, height);
+ const edges = initEdges(nodes);
+
+ const options = {
+ width,
+ height,
+ margins: margins.toJS(),
+ forceRelayout,
+ topologyId,
+ topologyOptions: topologyOptions.toJS(),
+ scale: nodeScale,
+ };
+
+ const timedLayouter = timely(doLayout);
+ const graph = timedLayouter(nodes, edges, options);
+
+ log(`graph layout took ${timedLayouter.time}ms`);
+
+ // extract coords and save for restore
+ const layoutNodes = graph.nodes.map(node => makeMap({
+ x: node.get('x'),
+ px: node.get('x'),
+ y: node.get('y'),
+ py: node.get('y')
+ }));
+
+ const layoutEdges = graph.edges
+ .map(edge => edge.set('ppoints', edge.get('points')));
+
+ return { layoutNodes, layoutEdges, graph, nodeScale };
+}
+
class NodesChart extends React.Component {
constructor(props, context) {
@@ -42,7 +161,10 @@ class NodesChart extends React.Component {
hasZoomed: false,
height: props.height || 0,
width: props.width || 0,
- zoomCache: {}
+ zoomCache: {},
+
+ layoutInput: makeMap(),
+ layoutNodes: makeMap(),
};
}
@@ -81,9 +203,7 @@ class NodesChart extends React.Component {
state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height);
state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width);
- if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) {
- _.assign(state, this.updateGraphState(nextProps, state));
- }
+ _.assign(state, this.updateGraphState(nextProps, state));
if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
_.assign(state, this.restoreLayout(state));
@@ -132,8 +252,12 @@ class NodesChart extends React.Component {
-
@@ -149,64 +273,6 @@ class NodesChart extends React.Component {
}
}
- initNodes(topology, stateNodes) {
- let nextStateNodes = stateNodes;
-
- // remove nodes that have disappeared
- stateNodes.forEach((node, id) => {
- if (!topology.has(id)) {
- nextStateNodes = nextStateNodes.delete(id);
- }
- });
-
- // copy relevant fields to state nodes
- topology.forEach((node, id) => {
- nextStateNodes = nextStateNodes.mergeIn([id], makeMap({
- id,
- label: node.get('label'),
- pseudo: node.get('pseudo'),
- subLabel: node.get('label_minor'),
- nodeCount: node.get('node_count'),
- metrics: node.get('metrics'),
- rank: node.get('rank'),
- shape: node.get('shape'),
- stack: node.get('stack'),
- networks: node.get('networks'),
- }));
- });
-
- return nextStateNodes;
- }
-
- initEdges(topology, stateNodes) {
- let edges = makeMap();
-
- topology.forEach((node, nodeId) => {
- const adjacency = node.get('adjacency');
- if (adjacency) {
- adjacency.forEach(adjacent => {
- const edge = [nodeId, adjacent];
- const edgeId = edge.join(EDGE_ID_SEPARATOR);
-
- if (!edges.has(edgeId)) {
- const source = edge[0];
- const target = edge[1];
- if (stateNodes.has(source) && stateNodes.has(target)) {
- edges = edges.set(edgeId, makeMap({
- id: edgeId,
- value: 1,
- source,
- target
- }));
- }
- }
- });
- }
- });
-
- return edges;
- }
-
centerSelectedNode(props, state) {
let stateNodes = state.nodes;
let stateEdges = state.edges;
@@ -272,7 +338,7 @@ class NodesChart extends React.Component {
});
// auto-scale node size for selected nodes
- const selectedNodeScale = this.getNodeScale(adjacentNodes, state.width, state.height);
+ const selectedNodeScale = getNodeScale(adjacentNodes, state.width, state.height);
return {
selectedNodeScale,
@@ -309,71 +375,52 @@ class NodesChart extends React.Component {
};
}
- const stateNodes = this.initNodes(props.nodes, state.nodes);
- const stateEdges = this.initEdges(props.nodes, stateNodes);
- const nodeScale = this.getNodeScale(props.nodes, state.width, state.height);
- const nextState = { nodeScale };
+ const nextState = { nodes: toNodes(props.nodes) };
- const options = {
+ //
+ // pull this out into redux.
+ //
+ const layoutInput = identityPresevingMerge(state.layoutInput, {
width: state.width,
height: state.height,
- scale: nodeScale,
- margins: props.margins,
+ nodes: getLayoutNodes(props.nodes),
+ margins: fromJS(props.margins),
+ topologyId: props.topologyId,
+ topologyOptions: fromJS(props.topologyOptions),
forceRelayout: props.forceRelayout,
- topologyId: this.props.topologyId,
- topologyOptions: this.props.topologyOptions
- };
+ });
- const timedLayouter = timely(doLayout);
- const graph = timedLayouter(stateNodes, stateEdges, options);
+ // layout input hasn't changed.
+ // TODO: move this out into reselect
+ if (state.layoutInput !== layoutInput) {
+ nextState.layoutInput = layoutInput;
- log(`graph layout took ${timedLayouter.time}ms`);
+ const { layoutNodes, layoutEdges, graph, nodeScale } = updateLayout(layoutInput.toObject());
+ //
+ // adjust layout based on viewport
+ const xFactor = (state.width - props.margins.left - props.margins.right) / graph.width;
+ const yFactor = state.height / graph.height;
+ const zoomFactor = Math.min(xFactor, yFactor);
+ let zoomScale = state.scale;
- // extract coords and save for restore
- const graphNodes = graph.nodes.map(node => makeMap({
- x: node.get('x'),
- px: node.get('x'),
- y: node.get('y'),
- py: node.get('y')
- }));
+ if (this.zoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
+ zoomScale = zoomFactor;
+ // saving in d3's behavior cache
+ this.zoom.scale(zoomFactor);
+ }
- const layoutNodes = stateNodes.mergeDeep(graphNodes);
-
- const layoutEdges = graph.edges
- .map(edge => edge.set('ppoints', edge.get('points')));
-
- // adjust layout based on viewport
- const xFactor = (state.width - props.margins.left - props.margins.right) / graph.width;
- const yFactor = state.height / graph.height;
- const zoomFactor = Math.min(xFactor, yFactor);
- let zoomScale = this.state.scale;
-
- if (this.zoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
- zoomScale = zoomFactor;
- // saving in d3's behavior cache
- this.zoom.scale(zoomFactor);
- }
-
- nextState.scale = zoomScale;
- if (!isDeepEqual(layoutNodes, state.nodes)) {
- nextState.nodes = layoutNodes;
- }
- if (!isDeepEqual(layoutEdges, state.edges)) {
+ nextState.scale = zoomScale;
nextState.edges = layoutEdges;
+ nextState.nodeScale = nodeScale;
+ nextState.layoutNodes = layoutNodes;
}
+ nextState.nodes = mergeDeepIfExists(nextState.nodes,
+ (nextState.layoutNodes || state.layoutNodes));
+
return nextState;
}
- getNodeScale(nodes, width, height) {
- const expanse = Math.min(height, width);
- const nodeSize = expanse / 3; // single node should fill a third of the screen
- const maxNodeSize = Math.min(MAX_NODE_SIZE, expanse / 10);
- const normalizedNodeSize = Math.max(MIN_NODE_SIZE,
- Math.min(nodeSize / Math.sqrt(nodes.size), maxNodeSize));
- return this.state.nodeScale.copy().range([0, normalizedNodeSize]);
- }
-
zoomed() {
// debug('zoomed', d3.event.scale, d3.event.translate);
this.isZooming = true;