@@ -36,6 +36,7 @@ class NodesChartEdges extends React.Component {
source={edge.get('source')}
target={edge.get('target')}
waypoints={edge.get('points')}
+ scale={focused ? selectedScale : 1}
isAnimated={isAnimated}
blurred={blurred}
focused={focused}
diff --git a/client/app/scripts/charts/nodes-chart-elements.js b/client/app/scripts/charts/nodes-chart-elements.js
index df193d9fec..dc16e0b73f 100644
--- a/client/app/scripts/charts/nodes-chart-elements.js
+++ b/client/app/scripts/charts/nodes-chart-elements.js
@@ -12,10 +12,10 @@ class NodesChartElements extends React.Component {
diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js
index 0477d0ae47..eecfce22ce 100644
--- a/client/app/scripts/charts/nodes-chart-nodes.js
+++ b/client/app/scripts/charts/nodes-chart-nodes.js
@@ -7,9 +7,9 @@ import NodeContainer from './node-container';
class NodesChartNodes extends React.Component {
render() {
- const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId, scale,
- selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId, topCardNode,
- searchNodeMatches = makeMap() } = this.props;
+ const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId,
+ selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId,
+ topCardNode, searchNodeMatches = makeMap() } = this.props;
// highlighter functions
const setHighlighted = node => node.set('highlighted',
@@ -71,7 +71,7 @@ class NodesChartNodes extends React.Component {
metric={metric(node)}
rank={node.get('rank')}
isAnimated={isAnimated}
- magnified={node.get('focused') ? selectedScale / scale : 1}
+ magnified={node.get('focused') ? selectedScale : 1}
dx={node.get('x')}
dy={node.get('y')}
/>)}
diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js
index 1d8f48a749..2b42bfa1dc 100644
--- a/client/app/scripts/charts/nodes-chart.js
+++ b/client/app/scripts/charts/nodes-chart.js
@@ -1,329 +1,66 @@
-import debug from 'debug';
import React from 'react';
import { connect } from 'react-redux';
-import { assign, pick, includes } from 'lodash';
-import { Map as makeMap, fromJS } from 'immutable';
-import timely from 'timely';
+import { assign, pick } from 'lodash';
+import { Map as makeMap } from 'immutable';
-import { scaleThreshold } from 'd3-scale';
import { event as d3Event, select } from 'd3-selection';
import { zoom, zoomIdentity } from 'd3-zoom';
import { nodeAdjacenciesSelector, adjacentNodesSelector } from '../selectors/chartSelectors';
import { clickBackground } from '../actions/app-actions';
-import { EDGE_ID_SEPARATOR } from '../constants/naming';
-import { DETAILS_PANEL_WIDTH, NODE_BASE_SIZE } from '../constants/styles';
import Logo from '../components/logo';
-import { doLayout } from './nodes-layout';
import NodesChartElements from './nodes-chart-elements';
import { getActiveTopologyOptions } from '../utils/topology-utils';
-const log = debug('scope:nodes-chart');
+import { topologyZoomState } from '../selectors/nodes-chart-zoom';
+import { layoutWithSelectedNode } from '../selectors/nodes-chart-focus';
+import { graphLayout } from '../selectors/nodes-chart-layout';
-const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY', 'minScale', 'maxScale'];
-// make sure circular layouts a bit denser with 3-6 nodes
-const radiusDensity = scaleThreshold()
- .domain([3, 6])
- .range([2.5, 3.5, 3]);
-
-const emptyLayoutState = {
- nodes: makeMap(),
- edges: makeMap(),
-};
-
-
-// EDGES
-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;
-}
-
-
-// ZOOM STATE
-function getLayoutDefaultZoom(layoutNodes, width, height) {
- const xMin = layoutNodes.minBy(n => n.get('x')).get('x');
- const xMax = layoutNodes.maxBy(n => n.get('x')).get('x');
- const yMin = layoutNodes.minBy(n => n.get('y')).get('y');
- const yMax = layoutNodes.maxBy(n => n.get('y')).get('y');
-
- const xFactor = width / (xMax - xMin);
- const yFactor = height / (yMax - yMin);
- const scale = Math.min(xFactor, yFactor);
-
- return {
- translateX: (width - ((xMax + xMin) * scale)) / 2,
- translateY: (height - ((yMax + yMin) * scale)) / 2,
- scale,
- };
-}
-
-function defaultZoomState(props, state) {
- // adjust layout based on viewport
- const width = state.width - props.margins.left - props.margins.right;
- const height = state.height - props.margins.top - props.margins.bottom;
-
- const { translateX, translateY, scale } = getLayoutDefaultZoom(state.nodes, width, height);
-
- return {
- scale,
- minScale: scale / 5,
- maxScale: Math.min(width, height) / NODE_BASE_SIZE / 3,
- panTranslateX: translateX + props.margins.left,
- panTranslateY: translateY + props.margins.top,
- };
-}
-
-
-// LAYOUT STATE
-function updateLayout(width, height, nodes, baseOptions) {
- const edges = initEdges(nodes);
- const options = Object.assign({}, baseOptions);
-
- const timedLayouter = timely(doLayout);
- const graph = timedLayouter(nodes, edges, options);
-
- log(`graph layout took ${timedLayouter.time}ms`);
-
- const layoutNodes = graph.nodes.map(node => makeMap({
- x: node.get('x'),
- y: node.get('y'),
- // extract coords and save for restore
- px: node.get('x'),
- py: node.get('y')
- }));
-
- const layoutEdges = graph.edges.map(edge => edge.set('ppoints', edge.get('points')));
-
- return { layoutNodes, layoutEdges };
-}
-
-function updatedGraphState(props, state) {
- if (props.nodes.size === 0) {
- return emptyLayoutState;
- }
-
- const options = {
- width: state.width,
- height: state.height,
- margins: props.margins,
- forceRelayout: props.forceRelayout,
- topologyId: props.topologyId,
- topologyOptions: props.topologyOptions,
- };
-
- const { layoutNodes, layoutEdges } =
- updateLayout(state.width, state.height, props.nodes, options);
-
- return {
- nodes: layoutNodes,
- edges: layoutEdges,
- };
-}
-
-function restoredLayout(state) {
- const restoredNodes = state.nodes.map(node => node.merge({
- x: node.get('px'),
- y: node.get('py')
- }));
-
- const restoredEdges = state.edges.map(edge => (
- edge.has('ppoints') ? edge.set('points', edge.get('ppoints')) : edge
- ));
-
- return {
- nodes: restoredNodes,
- edges: restoredEdges,
- };
-}
-
-
-// SELECTED NODE
-function centerSelectedNode(props, state) {
- let stateNodes = state.nodes;
- let stateEdges = state.edges;
- if (!stateNodes.has(props.selectedNodeId)) {
- return {};
- }
-
- const adjacentNodes = props.adjacentNodes;
- const adjacentLayoutNodeIds = [];
-
- adjacentNodes.forEach((adjacentId) => {
- // filter loopback
- if (adjacentId !== props.selectedNodeId) {
- adjacentLayoutNodeIds.push(adjacentId);
- }
- });
-
- // move origin node to center of viewport
- const zoomScale = state.scale;
- const translate = [state.panTranslateX, state.panTranslateY];
- const viewportHalfWidth = ((state.width + props.margins.left) - DETAILS_PANEL_WIDTH) / 2;
- const viewportHalfHeight = (state.height + props.margins.top) / 2;
- const centerX = (-translate[0] + viewportHalfWidth) / zoomScale;
- const centerY = (-translate[1] + viewportHalfHeight) / zoomScale;
- stateNodes = stateNodes.mergeIn([props.selectedNodeId], {
- x: centerX,
- y: centerY
- });
-
- // circle layout for adjacent nodes
- const adjacentCount = adjacentLayoutNodeIds.length;
- const density = radiusDensity(adjacentCount);
- const radius = Math.min(state.width, state.height) / density / zoomScale;
- const offsetAngle = Math.PI / 4;
-
- stateNodes = stateNodes.map((node, nodeId) => {
- const index = adjacentLayoutNodeIds.indexOf(nodeId);
- 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
- stateEdges = stateEdges.map((edge) => {
- if (edge.get('source') === props.selectedNodeId
- || edge.get('target') === props.selectedNodeId
- || 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', fromJS([
- {x: source.get('x'), y: source.get('y')},
- {x: target.get('x'), y: target.get('y')}
- ]));
- }
- return edge;
- });
-
- // auto-scale node size for selected nodes
- // const selectedNodeScale = getNodeScale(adjacentNodes.size, state.width, state.height);
-
- return {
- selectedScale: 1,
- edges: stateEdges,
- nodes: stateNodes
- };
-}
+const GRAPH_COMPLEXITY_NODES_TRESHOLD = 100;
+const ZOOM_CACHE_FIELDS = [
+ 'panTranslateX', 'panTranslateY',
+ 'zoomScale', 'minZoomScale', 'maxZoomScale'
+];
class NodesChart extends React.Component {
-
constructor(props, context) {
super(props, context);
- this.state = Object.assign({
- scale: 1,
- minScale: 1,
- maxScale: 1,
+
+ this.state = {
+ layoutNodes: makeMap(),
+ layoutEdges: makeMap(),
+ zoomScale: 0,
+ minZoomScale: 0,
+ maxZoomScale: 0,
panTranslateX: 0,
panTranslateY: 0,
selectedScale: 1,
height: props.height || 0,
width: props.width || 0,
+ // TODO: Move zoomCache to global Redux state. Now that we store
+ // it here, it gets reset every time the component gets destroyed.
+ // That happens e.g. when we switch to a grid mode in one topology,
+ // which resets the zoom cache across all topologies, which is bad.
zoomCache: {},
- }, emptyLayoutState);
+ };
this.handleMouseClick = this.handleMouseClick.bind(this);
this.zoomed = this.zoomed.bind(this);
}
componentWillMount() {
- const state = updatedGraphState(this.props, this.state);
- // debugger;
- // assign(state, this.restoreZoomState(this.props, Object.assign(this.state, state)));
- this.setState(state);
- }
-
- componentWillReceiveProps(nextProps) {
- // gather state, setState should be called only once here
- const state = assign({}, this.state);
-
- const topologyChanged = nextProps.topologyId !== this.props.topologyId;
-
- // wipe node states when showing different topology
- if (topologyChanged) {
- assign(state, emptyLayoutState);
- }
-
- // reset layout dimensions only when forced
- 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, updatedGraphState(nextProps, state));
- }
-
- console.log(`Prepare ${nextProps.nodes.size}`);
- if (nextProps.nodes.size > 0) {
- console.log(state.zoomCache);
- assign(state, this.restoreZoomState(nextProps, state));
- }
-
- // if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
- // // undo any pan/zooming that might have happened
- // this.setZoom(state);
- // assign(state, restoredLayout(state));
- // }
- //
- // if (nextProps.selectedNodeId) {
- // assign(state, centerSelectedNode(nextProps, state));
- // }
-
- if (topologyChanged) {
- // saving previous zoom state
- const prevZoom = pick(this.state, ZOOM_CACHE_FIELDS);
- const zoomCache = assign({}, this.state.zoomCache);
- zoomCache[this.props.topologyId] = prevZoom;
- assign(state, { zoomCache });
- }
-
- // console.log(topologyChanged);
- // console.log(state);
- this.setState(state);
+ this.setState(graphLayout(this.state, this.props));
}
componentDidMount() {
// distinguish pan/zoom from click
this.isZooming = false;
- // debugger;
-
- this.zoom = zoom()
- .scaleExtent([this.state.minScale, this.state.maxScale])
- .on('zoom', this.zoomed);
+ this.zoom = zoom().on('zoom', this.zoomed);
this.svg = select('.nodes-chart svg');
this.svg.call(this.zoom);
- // this.setZoom(this.state);
}
componentWillUnmount() {
@@ -336,18 +73,39 @@ class NodesChart extends React.Component {
.on('touchstart.zoom', null);
}
- isSmallTopology() {
- return this.state.nodes.size < 100;
+ componentWillReceiveProps(nextProps) {
+ // Don't modify the original state, as we only want to call setState once at the end.
+ const state = assign({}, this.state);
+
+ // Reset layout dimensions only when forced (to prevent excessive rendering on resizing).
+ state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height);
+ state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width);
+
+ // Update the state with memoized graph layout information based on props nodes and edges.
+ assign(state, graphLayout(state, nextProps));
+
+ // Now that we have the graph layout information, we use it to create a default zoom
+ // settings for the current topology if we are rendering its layout for the first time, or
+ // otherwise we use the cached zoom information from local state for this topology layout.
+ assign(state, topologyZoomState(state, nextProps));
+
+ // Finally we update the layout state with the circular
+ // subgraph centered around the selected node (if there is one).
+ if (nextProps.selectedNodeId) {
+ assign(state, layoutWithSelectedNode(state, nextProps));
+ }
+
+ this.applyZoomState(state);
+ this.setState(state);
}
render() {
- const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state;
- console.log(`Render ${nodes.size}`);
+ // Not passing transform into child components for perf reasons.
+ const { panTranslateX, panTranslateY, zoomScale } = this.state;
+ const transform = `translate(${panTranslateX}, ${panTranslateY}) scale(${zoomScale})`;
- // not passing translates into child components for perf reasons, use getTranslate instead
- const translate = [panTranslateX, panTranslateY];
- const transform = `translate(${translate}) scale(${scale})`;
const svgClassNames = this.props.isEmpty ? 'hide' : '';
+ const isAnimated = !this.isTopologyGraphComplex();
return (
@@ -358,12 +116,11 @@ class NodesChart extends React.Component {
+ isAnimated={isAnimated} />
);
@@ -377,34 +134,38 @@ class NodesChart extends React.Component {
}
}
- restoreZoomState(props, state) {
- // re-apply cached canvas zoom/pan to d3 behavior (or set the default values)
- const nextZoom = state.zoomCache[props.topologyId] || defaultZoomState(props, state);
- if (this.zoom) {
- this.zoom = this.zoom.scaleExtent([nextZoom.minScale, nextZoom.maxScale]);
- this.setZoom(nextZoom);
- }
+ isTopologyGraphComplex() {
+ return this.state.layoutNodes.size > GRAPH_COMPLEXITY_NODES_TRESHOLD;
+ }
- return nextZoom;
+ cacheZoomState(state) {
+ const zoomState = pick(state, ZOOM_CACHE_FIELDS);
+ const zoomCache = assign({}, state.zoomCache);
+ zoomCache[this.props.topologyId] = zoomState;
+ return { zoomCache };
+ }
+
+ applyZoomState({ zoomScale, minZoomScale, maxZoomScale, panTranslateX, panTranslateY }) {
+ this.zoom = this.zoom.scaleExtent([minZoomScale, maxZoomScale]);
+ this.svg.call(this.zoom.transform, zoomIdentity
+ .translate(panTranslateX, panTranslateY)
+ .scale(zoomScale));
}
zoomed() {
this.isZooming = true;
// don't pan while node is selected
if (!this.props.selectedNodeId) {
- this.setState({
+ let state = assign({}, this.state, {
panTranslateX: d3Event.transform.x,
panTranslateY: d3Event.transform.y,
- scale: d3Event.transform.k
+ zoomScale: d3Event.transform.k
});
+ // Cache the zoom state as soon as it changes as it is cheap, and makes us
+ // be able to skip difficult conditions on when this caching should happen.
+ state = assign(state, this.cacheZoomState(state));
+ this.setState(state);
}
- // console.log(d3Event.transform);
- }
-
- setZoom(newZoom) {
- this.svg.call(this.zoom.transform, zoomIdentity
- .translate(newZoom.panTranslateX, newZoom.panTranslateY)
- .scale(newZoom.scale));
}
}
diff --git a/client/app/scripts/constants/styles.js b/client/app/scripts/constants/styles.js
index c81285f491..e7d6a16cfd 100644
--- a/client/app/scripts/constants/styles.js
+++ b/client/app/scripts/constants/styles.js
@@ -24,7 +24,12 @@ export const NODE_SHAPE_BORDER_RADIUS = 0.5;
export const NODE_SHAPE_SHADOW_RADIUS = 0.45;
export const NODE_SHAPE_DOT_RADIUS = 0.125;
export const NODE_BLUR_OPACITY = 0.2;
-export const NODE_BASE_SIZE = 50;
+// NOTE: Modifying this value shouldn't actually change much in the way
+// nodes are rendered, as long as its kept >> 1. The idea was to draw all
+// the nodes in a unit scale and control their size just through scaling
+// transform, but the problem is that dagre only works with integer coordinates,
+// so this constant basically serves as a precision factor for dagre.
+export const NODE_BASE_SIZE = 100;
// Node details table constants
export const NODE_DETAILS_TABLE_CW = {
diff --git a/client/app/scripts/selectors/nodes-chart-focus.js b/client/app/scripts/selectors/nodes-chart-focus.js
new file mode 100644
index 0000000000..15ad07f0f8
--- /dev/null
+++ b/client/app/scripts/selectors/nodes-chart-focus.js
@@ -0,0 +1,149 @@
+import { includes, without } from 'lodash';
+import { fromJS } from 'immutable';
+import { createSelector } from 'reselect';
+import { scaleThreshold } from 'd3-scale';
+
+import { NODE_BASE_SIZE, DETAILS_PANEL_WIDTH } from '../constants/styles';
+
+
+const circularOffsetAngle = Math.PI / 4;
+
+// make sure circular layouts a bit denser with 3-6 nodes
+const radiusDensity = scaleThreshold()
+ .domain([3, 6])
+ .range([2.5, 3.5, 3]);
+
+
+const layoutNodesSelector = state => state.layoutNodes;
+const layoutEdgesSelector = state => state.layoutEdges;
+const stateWidthSelector = state => state.width;
+const stateHeightSelector = state => state.height;
+const stateScaleSelector = state => state.zoomScale;
+const stateTranslateXSelector = state => state.panTranslateX;
+const stateTranslateYSelector = state => state.panTranslateY;
+const propsSelectedNodeIdSelector = (_, props) => props.selectedNodeId;
+const propsAdjacentNodesSelector = (_, props) => props.adjacentNodes;
+const propsMarginsSelector = (_, props) => props.margins;
+
+// The narrower dimension of the viewport, used for scaling.
+const viewportExpanseSelector = createSelector(
+ [
+ stateWidthSelector,
+ stateHeightSelector,
+ ],
+ (width, height) => Math.min(width, height)
+);
+
+// Coordinates of the viewport center (when the details
+// panel is open), used for focusing the selected node.
+const viewportCenterSelector = createSelector(
+ [
+ stateWidthSelector,
+ stateHeightSelector,
+ stateTranslateXSelector,
+ stateTranslateYSelector,
+ stateScaleSelector,
+ propsMarginsSelector,
+ ],
+ (width, height, translateX, translateY, scale, margins) => {
+ const viewportHalfWidth = ((width + margins.left) - DETAILS_PANEL_WIDTH) / 2;
+ const viewportHalfHeight = (height + margins.top) / 2;
+ return {
+ x: (-translateX + viewportHalfWidth) / scale,
+ y: (-translateY + viewportHalfHeight) / scale,
+ };
+ }
+);
+
+// List of all the adjacent nodes to the selected
+// one, excluding itself (in case of loops).
+const selectedNodeNeighborsIdsSelector = createSelector(
+ [
+ propsSelectedNodeIdSelector,
+ propsAdjacentNodesSelector,
+ ],
+ (selectedNodeId, adjacentNodes) => without(adjacentNodes.toArray(), selectedNodeId)
+);
+
+const selectedNodesLayoutSettingsSelector = createSelector(
+ [
+ selectedNodeNeighborsIdsSelector,
+ viewportExpanseSelector,
+ stateScaleSelector,
+ ],
+ (circularNodesIds, viewportExpanse, scale) => {
+ const circularNodesCount = circularNodesIds.length;
+
+ // Here we calculate the zoom factor of the nodes that get selected into focus.
+ // The factor is a somewhat arbitrary function (based on what looks good) of the
+ // viewport dimensions and the number of nodes in the circular layout. The idea
+ // is that the node should never be zoomed more than to cover 1/3 of the viewport
+ // (`maxScale`) and then the factor gets decresed asymptotically to the inverse
+ // square of the number of circular nodes, with a little constant push to make
+ // the layout more stable for a small number of nodes. Finally, the zoom factor is
+ // divided by the zoom factor applied to the whole topology layout to cancel it out.
+ const maxScale = viewportExpanse / NODE_BASE_SIZE / 3;
+ const shrinkFactor = Math.sqrt(circularNodesCount + 10);
+ const selectedScale = maxScale / shrinkFactor / scale;
+
+ // Following a similar logic as above, we set the radius of the circular
+ // layout based on the viewport dimensions and the number of circular nodes.
+ const circularRadius = viewportExpanse / radiusDensity(circularNodesCount) / scale;
+ const circularInnerAngle = (2 * Math.PI) / circularNodesCount;
+
+ return { selectedScale, circularRadius, circularInnerAngle };
+ }
+);
+
+export const layoutWithSelectedNode = createSelector(
+ [
+ layoutNodesSelector,
+ layoutEdgesSelector,
+ viewportCenterSelector,
+ propsSelectedNodeIdSelector,
+ selectedNodeNeighborsIdsSelector,
+ selectedNodesLayoutSettingsSelector,
+ ],
+ (layoutNodes, layoutEdges, viewportCenter, selectedNodeId, neighborsIds, layoutSettings) => {
+ // Do nothing if the layout doesn't contain the selected node anymore.
+ if (!layoutNodes.has(selectedNodeId)) {
+ return {};
+ }
+
+ const { selectedScale, circularRadius, circularInnerAngle } = layoutSettings;
+
+ // Fix the selected node in the viewport center.
+ layoutNodes = layoutNodes.mergeIn([selectedNodeId], viewportCenter);
+
+ // Put the nodes that are adjacent to the selected one in a circular layout around it.
+ layoutNodes = layoutNodes.map((node, nodeId) => {
+ const index = neighborsIds.indexOf(nodeId);
+ if (index > -1) {
+ const angle = circularOffsetAngle + (index * circularInnerAngle);
+ return node.merge({
+ x: viewportCenter.x + (circularRadius * Math.sin(angle)),
+ y: viewportCenter.y + (circularRadius * Math.cos(angle))
+ });
+ }
+ return node;
+ });
+
+ // Update the edges in the circular layout to link the nodes in a straight line.
+ layoutEdges = layoutEdges.map((edge) => {
+ if (edge.get('source') === selectedNodeId
+ || edge.get('target') === selectedNodeId
+ || includes(neighborsIds, edge.get('source'))
+ || includes(neighborsIds, edge.get('target'))) {
+ const source = layoutNodes.get(edge.get('source'));
+ const target = layoutNodes.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')}
+ ]));
+ }
+ return edge;
+ });
+
+ return { layoutNodes, layoutEdges, selectedScale };
+ }
+);
diff --git a/client/app/scripts/selectors/nodes-chart-layout.js b/client/app/scripts/selectors/nodes-chart-layout.js
new file mode 100644
index 0000000000..e5c8a8e73b
--- /dev/null
+++ b/client/app/scripts/selectors/nodes-chart-layout.js
@@ -0,0 +1,94 @@
+import debug from 'debug';
+import { createSelector } from 'reselect';
+import { Map as makeMap } from 'immutable';
+import timely from 'timely';
+
+import { EDGE_ID_SEPARATOR } from '../constants/naming';
+import { doLayout } from '../charts/nodes-layout';
+
+const log = debug('scope:nodes-chart');
+
+
+const stateWidthSelector = state => state.width;
+const stateHeightSelector = state => state.height;
+const inputNodesSelector = (_, props) => props.nodes;
+const propsMarginsSelector = (_, props) => props.margins;
+const forceRelayoutSelector = (_, props) => props.forceRelayout;
+const topologyIdSelector = (_, props) => props.topologyId;
+const topologyOptionsSelector = (_, props) => props.topologyOptions;
+
+
+function initEdgesFromNodes(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;
+}
+
+const layoutOptionsSelector = createSelector(
+ [
+ stateWidthSelector,
+ stateHeightSelector,
+ propsMarginsSelector,
+ forceRelayoutSelector,
+ topologyIdSelector,
+ topologyOptionsSelector,
+ ],
+ (width, height, margins, forceRelayout, topologyId, topologyOptions) => (
+ { width, height, margins, forceRelayout, topologyId, topologyOptions }
+ )
+);
+
+export const graphLayout = createSelector(
+ [
+ inputNodesSelector,
+ layoutOptionsSelector,
+ ],
+ (nodes, options) => {
+ // If the graph is empty, skip computing the layout.
+ if (nodes.size === 0) {
+ return {
+ layoutNodes: makeMap(),
+ layoutEdges: makeMap(),
+ };
+ }
+
+ const edges = initEdgesFromNodes(nodes);
+ const timedLayouter = timely(doLayout);
+ const graph = timedLayouter(nodes, edges, options);
+
+ // NOTE: We probably shouldn't log anything in a
+ // computed property, but this is still useful.
+ log(`graph layout calculation took ${timedLayouter.time}ms`);
+
+ const layoutEdges = graph.edges;
+ const layoutNodes = graph.nodes.map(node => makeMap({
+ x: node.get('x'),
+ y: node.get('y'),
+ }));
+
+ return { layoutNodes, layoutEdges };
+ }
+);
diff --git a/client/app/scripts/selectors/nodes-chart-zoom.js b/client/app/scripts/selectors/nodes-chart-zoom.js
new file mode 100644
index 0000000000..c9e8abe3a5
--- /dev/null
+++ b/client/app/scripts/selectors/nodes-chart-zoom.js
@@ -0,0 +1,73 @@
+import { createSelector } from 'reselect';
+import { NODE_BASE_SIZE } from '../constants/styles';
+
+
+const layoutNodesSelector = state => state.layoutNodes;
+const stateWidthSelector = state => state.width;
+const stateHeightSelector = state => state.height;
+const propsMarginsSelector = (_, props) => props.margins;
+const cachedZoomStateSelector = (state, props) => state.zoomCache[props.topologyId];
+
+const viewportWidthSelector = createSelector(
+ [
+ stateWidthSelector,
+ propsMarginsSelector,
+ ],
+ (width, margins) => width - margins.left - margins.right
+);
+const viewportHeightSelector = createSelector(
+ [
+ stateHeightSelector,
+ propsMarginsSelector,
+ ],
+ (height, margins) => height - margins.top
+);
+
+// Compute the default zoom settings for the given graph layout.
+const defaultZoomSelector = createSelector(
+ [
+ layoutNodesSelector,
+ viewportWidthSelector,
+ viewportHeightSelector,
+ propsMarginsSelector,
+ ],
+ (layoutNodes, width, height, margins) => {
+ if (layoutNodes.size === 0) {
+ return {};
+ }
+
+ const xMin = layoutNodes.minBy(n => n.get('x')).get('x');
+ const xMax = layoutNodes.maxBy(n => n.get('x')).get('x');
+ const yMin = layoutNodes.minBy(n => n.get('y')).get('y');
+ const yMax = layoutNodes.maxBy(n => n.get('y')).get('y');
+
+ const xFactor = width / (xMax - xMin);
+ const yFactor = height / (yMax - yMin);
+
+ // Maximal allowed zoom will always be such that a node covers 1/5 of the viewport.
+ const maxZoomScale = Math.min(width, height) / NODE_BASE_SIZE / 5;
+
+ // Initial zoom is such that the graph covers 90% of either
+ // the viewport, respecting the maximal zoom constraint.
+ const zoomScale = Math.min(xFactor, yFactor, maxZoomScale) * 0.9;
+
+ // Finally, we always allow zooming out exactly 5x compared to the initial zoom.
+ const minZoomScale = zoomScale / 5;
+
+ // This translation puts the graph in the center of the viewport, respecting the margins.
+ const panTranslateX = ((width - ((xMax + xMin) * zoomScale)) / 2) + margins.left;
+ const panTranslateY = ((height - ((yMax + yMin) * zoomScale)) / 2) + margins.top;
+
+ return { zoomScale, minZoomScale, maxZoomScale, panTranslateX, panTranslateY };
+ }
+);
+
+// Use the cache to get the last zoom state for the selected topology,
+// otherwise use the default zoom options computed from the graph layout.
+export const topologyZoomState = createSelector(
+ [
+ cachedZoomStateSelector,
+ defaultZoomSelector,
+ ],
+ (cachedZoomState, defaultZoomState) => cachedZoomState || defaultZoomState
+);
diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss
index 8456f8974c..c2c64769a3 100644
--- a/client/app/styles/_base.scss
+++ b/client/app/styles/_base.scss
@@ -422,7 +422,7 @@
.link {
fill: none;
- stroke-width: $edge-link-stroke-width;
+ stroke: $text-secondary-color;
stroke-opacity: $edge-opacity;
}
.shadow {