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;