+
-
+
diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js
index 1be2a0f3b..9cd392749 100644
--- a/client/app/scripts/charts/node.js
+++ b/client/app/scripts/charts/node.js
@@ -7,6 +7,7 @@ import { clickNode, enterNode, leaveNode } from '../actions/app-actions';
import { getNodeColor } from '../utils/color-utils';
import MatchedText from '../components/matched-text';
import MatchedResults from '../components/matched-results';
+import { NODE_BASE_SIZE } from '../constants/styles';
import NodeShapeCircle from './node-shape-circle';
import NodeShapeStack from './node-shape-stack';
@@ -15,18 +16,9 @@ import NodeShapeHexagon from './node-shape-hexagon';
import NodeShapeHeptagon from './node-shape-heptagon';
import NodeShapeCloud from './node-shape-cloud';
import NodeNetworksOverlay from './node-networks-overlay';
-import { MIN_NODE_LABEL_SIZE, BASE_NODE_LABEL_SIZE, BASE_NODE_SIZE } from '../constants/styles';
-function labelFontSize(nodeSize) {
- return Math.max(MIN_NODE_LABEL_SIZE, (BASE_NODE_LABEL_SIZE / BASE_NODE_SIZE) * nodeSize);
-}
-
-function stackedShape(Shape) {
- const factory = React.createFactory(NodeShapeStack);
- return props => factory(Object.assign({}, props, {shape: Shape}));
-}
-
+const labelWidth = 1.4 * NODE_BASE_SIZE;
const nodeShapes = {
circle: NodeShapeCircle,
hexagon: NodeShapeHexagon,
@@ -35,6 +27,11 @@ const nodeShapes = {
cloud: NodeShapeCloud
};
+function stackedShape(Shape) {
+ const factory = React.createFactory(NodeShapeStack);
+ return props => factory(Object.assign({}, props, {shape: Shape}));
+}
+
function getNodeShape({ shape, stack }) {
const nodeShape = nodeShapes[shape];
if (!nodeShape) {
@@ -43,58 +40,76 @@ function getNodeShape({ shape, stack }) {
return stack ? stackedShape(nodeShape) : nodeShape;
}
-function svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) {
- return (
-
- {label}
-
- {subLabel}
-
-
- );
-}
class Node extends React.Component {
-
constructor(props, context) {
super(props, context);
- this.handleMouseClick = this.handleMouseClick.bind(this);
- this.handleMouseEnter = this.handleMouseEnter.bind(this);
- this.handleMouseLeave = this.handleMouseLeave.bind(this);
- this.saveShapeRef = this.saveShapeRef.bind(this);
this.state = {
hovered: false,
matched: false
};
+
+ this.handleMouseClick = this.handleMouseClick.bind(this);
+ this.handleMouseEnter = this.handleMouseEnter.bind(this);
+ this.handleMouseLeave = this.handleMouseLeave.bind(this);
+ this.saveShapeRef = this.saveShapeRef.bind(this);
}
componentWillReceiveProps(nextProps) {
// marks as matched only when search query changes
if (nextProps.searchQuery !== this.props.searchQuery) {
- this.setState({
- matched: nextProps.matched
- });
+ this.setState({ matched: nextProps.matched });
} else {
- this.setState({
- matched: false
- });
+ this.setState({ matched: false });
}
}
+ renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) {
+ const { label, subLabel } = this.props;
+ return (
+
+ {label}
+
+ {subLabel}
+
+
+ );
+ }
+
+ renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents) {
+ const { label, subLabel, blurred, matches = makeMap() } = this.props;
+ const matchedMetadata = matches.get('metadata', makeList());
+ const matchedParents = matches.get('parents', makeList());
+ const matchedNodeDetails = matchedMetadata.concat(matchedParents);
+
+ return (
+
+
+
+
+
+
+
+
+ {!blurred &&
}
+
+
+ );
+ }
+
render() {
- const { blurred, focused, highlighted, label, matches = makeMap(), networks,
- pseudo, rank, subLabel, scaleFactor, transform, exportingGraph,
- showingNetworks, stack } = this.props;
+ const { blurred, focused, highlighted, networks, pseudo, rank, label,
+ transform, exportingGraph, showingNetworks, stack } = this.props;
const { hovered, matched } = this.state;
- const nodeScale = focused ? this.props.selectedNodeScale : this.props.nodeScale;
const color = getNodeColor(rank, label, pseudo);
const truncate = !focused && !hovered;
- const labelWidth = nodeScale(scaleFactor * 3);
- const labelOffsetX = -labelWidth / 2;
- const labelDy = (showingNetworks && networks) ? 0.70 : 0.55;
- const labelOffsetY = nodeScale(labelDy * scaleFactor);
- const networkOffset = nodeScale(scaleFactor * 0.67);
+ const labelOffsetY = (showingNetworks && networks) ? 40 : 28;
const nodeClassName = classnames('node', {
highlighted,
@@ -109,53 +124,23 @@ class Node extends React.Component {
const NodeShapeType = getNodeShape(this.props);
const useSvgLabels = exportingGraph;
- const size = nodeScale(scaleFactor);
- const fontSize = labelFontSize(size);
const mouseEvents = {
onClick: this.handleMouseClick,
onMouseEnter: this.handleMouseEnter,
onMouseLeave: this.handleMouseLeave,
};
- const matchedNodeDetails = matches.get('metadata', makeList())
- .concat(matches.get('parents', makeList()));
return (
-
{useSvgLabels ?
-
- svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) :
-
-
-
-
-
-
-
-
-
- {!blurred &&
}
-
- }
+ this.renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) :
+ this.renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents)}
-
+
- {showingNetworks && }
+ {showingNetworks && }
);
}
diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js
index 3e0d3ce90..402cf0b1b 100644
--- a/client/app/scripts/charts/nodes-chart-edges.js
+++ b/client/app/scripts/charts/nodes-chart-edges.js
@@ -7,9 +7,9 @@ import EdgeContainer from './edge-container';
class NodesChartEdges extends React.Component {
render() {
- const { hasSelectedNode, highlightedEdgeIds, layoutEdges,
- layoutPrecision, searchNodeMatches = makeMap(), searchQuery,
- selectedNodeId, selectedNetwork, selectedNetworkNodes } = this.props;
+ const { hasSelectedNode, highlightedEdgeIds, layoutEdges, searchQuery,
+ isAnimated, selectedScale, selectedNodeId, selectedNetwork, selectedNetworkNodes,
+ searchNodeMatches = makeMap() } = this.props;
return (
@@ -35,10 +35,11 @@ class NodesChartEdges extends React.Component {
id={edge.get('id')}
source={edge.get('source')}
target={edge.get('target')}
- points={edge.get('points')}
+ waypoints={edge.get('points')}
+ scale={focused ? selectedScale : 1}
+ isAnimated={isAnimated}
blurred={blurred}
focused={focused}
- layoutPrecision={layoutPrecision}
highlighted={highlighted}
/>
);
diff --git a/client/app/scripts/charts/nodes-chart-elements.js b/client/app/scripts/charts/nodes-chart-elements.js
index 19b1aa996..dc16e0b73 100644
--- a/client/app/scripts/charts/nodes-chart-elements.js
+++ b/client/app/scripts/charts/nodes-chart-elements.js
@@ -12,13 +12,12 @@ class NodesChartElements extends React.Component {
+ selectedScale={props.selectedScale}
+ isAnimated={props.isAnimated} />
+ selectedScale={props.selectedScale}
+ isAnimated={props.isAnimated} />
);
}
diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js
index 4bacb2d23..9ce3c1181 100644
--- a/client/app/scripts/charts/nodes-chart-nodes.js
+++ b/client/app/scripts/charts/nodes-chart-nodes.js
@@ -7,12 +7,9 @@ import NodeContainer from './node-container';
class NodesChartNodes extends React.Component {
render() {
- const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision,
- mouseOverNodeId, nodeScale, scale, searchNodeMatches = makeMap(),
- searchQuery, selectedMetric, selectedNetwork, selectedNodeScale, selectedNodeId,
- topCardNode } = this.props;
-
- const zoomScale = scale;
+ const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId,
+ selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId,
+ topCardNode, searchNodeMatches = makeMap() } = this.props;
// highlighter functions
const setHighlighted = node => node.set('highlighted',
@@ -73,12 +70,11 @@ class NodesChartNodes extends React.Component {
subLabel={node.get('subLabel')}
metric={metric(node)}
rank={node.get('rank')}
- layoutPrecision={layoutPrecision}
- selectedNodeScale={selectedNodeScale}
- nodeScale={nodeScale}
- zoomScale={zoomScale}
+ isAnimated={isAnimated}
+ scale={node.get('focused') ? selectedScale : 1}
dx={node.get('x')}
- dy={node.get('y')} />)}
+ dy={node.get('y')}
+ />)}
);
}
diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js
index 72869150f..33a40fd73 100644
--- a/client/app/scripts/charts/nodes-chart.js
+++ b/client/app/scripts/charts/nodes-chart.js
@@ -1,272 +1,63 @@
-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, scaleLinear } 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 { MIN_NODE_SIZE, DETAILS_PANEL_WIDTH, MAX_NODE_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';
+import { getActiveTopologyOptions, zoomCacheKey } from '../utils/topology-utils';
-const log = debug('scope:nodes-chart');
-
-const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY'];
-
-// make sure circular layouts a bit denser with 3-6 nodes
-const radiusDensity = scaleThreshold()
- .domain([3, 6])
- .range([2.5, 3.5, 3]);
-
-/**
- * dynamic coords precision based on topology size
- */
-function getLayoutPrecision(nodesCount) {
- let precision;
- if (nodesCount >= 50) {
- precision = 0;
- } else if (nodesCount > 20) {
- precision = 1;
- } else if (nodesCount > 10) {
- precision = 2;
- } else {
- precision = 3;
- }
-
- return precision;
-}
+import { topologyZoomState } from '../selectors/nodes-chart-zoom';
+import { layoutWithSelectedNode } from '../selectors/nodes-chart-focus';
+import { graphLayout } from '../selectors/nodes-chart-layout';
-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(nodesCount, 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(nodesCount), maxNodeSize));
-
- return scaleLinear().range([0, normalizedNodeSize]);
-}
-
-
-function updateLayout(width, height, nodes, baseOptions) {
- const nodeScale = getNodeScale(nodes.size, width, height);
- const edges = initEdges(nodes);
-
- const options = Object.assign({}, baseOptions, {
- scale: nodeScale,
- });
-
- 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, layoutWidth: graph.width, layoutHeight: graph.height };
-}
-
-
-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 {
- selectedNodeScale,
- 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.handleMouseClick = this.handleMouseClick.bind(this);
- this.zoomed = this.zoomed.bind(this);
-
this.state = {
- edges: makeMap(),
- nodes: makeMap(),
- nodeScale: scaleLinear(),
+ layoutNodes: makeMap(),
+ layoutEdges: makeMap(),
+ zoomScale: 0,
+ minZoomScale: 0,
+ maxZoomScale: 0,
panTranslateX: 0,
panTranslateY: 0,
- scale: 1,
- selectedNodeScale: scaleLinear(),
- hasZoomed: false,
+ 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: {},
};
+
+ this.handleMouseClick = this.handleMouseClick.bind(this);
+ this.zoomed = this.zoomed.bind(this);
}
componentWillMount() {
- const state = this.updateGraphState(this.props, this.state);
- this.setState(state);
- }
-
- componentWillReceiveProps(nextProps) {
- // gather state, setState should be called only once here
- const state = assign({}, this.state);
-
- // wipe node states when showing different topology
- if (nextProps.topologyId !== this.props.topologyId) {
- // re-apply cached canvas zoom/pan to d3 behavior (or set the default values)
- const defaultZoom = { scale: 1, panTranslateX: 0, panTranslateY: 0, hasZoomed: false };
- const nextZoom = this.state.zoomCache[nextProps.topologyId] || defaultZoom;
- if (nextZoom) {
- this.setZoom(nextZoom);
- }
-
- // saving previous zoom state
- const prevZoom = pick(this.state, ZOOM_CACHE_FIELDS);
- const zoomCache = assign({}, this.state.zoomCache);
- zoomCache[this.props.topologyId] = prevZoom;
-
- // clear canvas and apply zoom state
- assign(state, nextZoom, { zoomCache }, {
- nodes: makeMap(),
- edges: makeMap()
- });
- }
-
- // 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, this.updateGraphState(nextProps, state));
- }
-
- if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
- assign(state, this.restoreLayout(state));
- }
- if (nextProps.selectedNodeId) {
- assign(state, centerSelectedNode(nextProps, state));
- }
-
- this.setState(state);
+ this.setState(graphLayout(this.state, this.props));
}
componentDidMount() {
// distinguish pan/zoom from click
this.isZooming = false;
-
- this.zoom = zoom()
- .scaleExtent([0.1, 2])
- .on('zoom', this.zoomed);
+ this.zoom = zoom().on('zoom', this.zoomed);
this.svg = select('.nodes-chart svg');
this.svg.call(this.zoom);
@@ -282,15 +73,40 @@ class NodesChart extends React.Component {
.on('touchstart.zoom', null);
}
+ 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;
+ // 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();
- const layoutPrecision = getLayoutPrecision(nodes.size);
return (
+ isAnimated={isAnimated} />
);
@@ -320,81 +134,39 @@ class NodesChart extends React.Component {
}
}
- restoreLayout(state) {
- // undo any pan/zooming that might have happened
- this.setZoom(state);
-
- const nodes = state.nodes.map(node => node.merge({
- x: node.get('px'),
- y: node.get('py')
- }));
-
- const edges = state.edges.map((edge) => {
- if (edge.has('ppoints')) {
- return edge.set('points', edge.get('ppoints'));
- }
- return edge;
- });
-
- return { edges, nodes };
+ isTopologyGraphComplex() {
+ return this.state.layoutNodes.size > GRAPH_COMPLEXITY_NODES_TRESHOLD;
}
- updateGraphState(props, state) {
- if (props.nodes.size === 0) {
- return {
- nodes: makeMap(),
- edges: makeMap()
- };
- }
+ cacheZoomState(state) {
+ const zoomState = pick(state, ZOOM_CACHE_FIELDS);
+ const zoomCache = assign({}, state.zoomCache);
+ zoomCache[zoomCacheKey(this.props)] = zoomState;
+ return { zoomCache };
+ }
- const options = {
- width: state.width,
- height: state.height,
- margins: props.margins,
- forceRelayout: props.forceRelayout,
- topologyId: props.topologyId,
- topologyOptions: props.topologyOptions,
- };
-
- const { layoutNodes, layoutEdges, layoutWidth, layoutHeight } = updateLayout(
- state.width, state.height, props.nodes, options);
- //
- // adjust layout based on viewport
- const xFactor = (state.width - props.margins.left - props.margins.right) / layoutWidth;
- const yFactor = state.height / layoutHeight;
- const zoomFactor = Math.min(xFactor, yFactor);
- let zoomScale = state.scale;
-
- if (this.svg && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
- zoomScale = zoomFactor;
- }
-
- return {
- scale: zoomScale,
- nodes: layoutNodes,
- edges: layoutEdges,
- nodeScale: getNodeScale(props.nodes.size, state.width, state.height),
- };
+ 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;
- // dont pan while node is selected
+ // don't pan while node is selected
if (!this.props.selectedNodeId) {
- this.setState({
- hasZoomed: true,
+ 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);
}
}
-
- setZoom(newZoom) {
- this.svg.call(this.zoom.transform, zoomIdentity
- .translate(newZoom.panTranslateX, newZoom.panTranslateY)
- .scale(newZoom.scale));
- }
}
@@ -405,7 +177,7 @@ function mapStateToProps(state) {
forceRelayout: state.get('forceRelayout'),
selectedNodeId: state.get('selectedNodeId'),
topologyId: state.get('currentTopologyId'),
- topologyOptions: getActiveTopologyOptions(state)
+ topologyOptions: getActiveTopologyOptions(state),
};
}
diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js
index efd356541..635fb8092 100644
--- a/client/app/scripts/charts/nodes-layout.js
+++ b/client/app/scripts/charts/nodes-layout.js
@@ -2,6 +2,7 @@ import dagre from 'dagre';
import debug from 'debug';
import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable';
+import { NODE_BASE_SIZE } from '../constants/styles';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { featureIsEnabledAny } from '../utils/feature-utils';
import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils';
@@ -12,10 +13,9 @@ const topologyCaches = {};
export const DEFAULT_WIDTH = 800;
export const DEFAULT_HEIGHT = DEFAULT_WIDTH / 2;
export const DEFAULT_MARGINS = {top: 0, left: 0};
-const DEFAULT_SCALE = val => val * 2;
-const NODE_SIZE_FACTOR = 1;
-const NODE_SEPARATION_FACTOR = 2.0;
-const RANK_SEPARATION_FACTOR = 3.0;
+const NODE_SIZE_FACTOR = NODE_BASE_SIZE;
+const NODE_SEPARATION_FACTOR = 2 * NODE_BASE_SIZE;
+const RANK_SEPARATION_FACTOR = 3 * NODE_BASE_SIZE;
let layoutRuns = 0;
let layoutRunsTrivial = 0;
@@ -34,19 +34,16 @@ function fromGraphNodeId(encodedId) {
* @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) {
+function runLayoutEngine(graph, imNodes, imEdges) {
let nodes = imNodes;
let edges = imEdges;
- const options = opts || {};
- const scale = options.scale || DEFAULT_SCALE;
- const ranksep = scale(RANK_SEPARATION_FACTOR);
- const nodesep = scale(NODE_SEPARATION_FACTOR);
- const nodeWidth = scale(NODE_SIZE_FACTOR);
- const nodeHeight = scale(NODE_SIZE_FACTOR);
+ const ranksep = RANK_SEPARATION_FACTOR;
+ const nodesep = NODE_SEPARATION_FACTOR;
+ const nodeWidth = NODE_SIZE_FACTOR;
+ const nodeHeight = NODE_SIZE_FACTOR;
// configure node margins
graph.setGraph({
@@ -154,12 +151,10 @@ function setSimpleEdgePoints(edge, nodeCache) {
* @param {object} opts Options
* @return {object} new layout object
*/
-export function doLayoutNewNodesOfExistingRank(layout, nodeCache, opts) {
+export function doLayoutNewNodesOfExistingRank(layout, nodeCache) {
const result = Object.assign({}, layout);
- const options = opts || {};
- const scale = options.scale || DEFAULT_SCALE;
- const nodesep = scale(NODE_SEPARATION_FACTOR);
- const nodeWidth = scale(NODE_SIZE_FACTOR);
+ const nodesep = NODE_SEPARATION_FACTOR;
+ const nodeWidth = NODE_SIZE_FACTOR;
// determine new nodes
const oldNodes = ImmSet.fromKeys(nodeCache);
@@ -200,11 +195,10 @@ function layoutSingleNodes(layout, opts) {
const result = Object.assign({}, layout);
const options = opts || {};
const margins = options.margins || DEFAULT_MARGINS;
- const scale = options.scale || DEFAULT_SCALE;
- const ranksep = scale(RANK_SEPARATION_FACTOR) / 2; // dagre splits it in half
- const nodesep = scale(NODE_SEPARATION_FACTOR);
- const nodeWidth = scale(NODE_SIZE_FACTOR);
- const nodeHeight = scale(NODE_SIZE_FACTOR);
+ const ranksep = RANK_SEPARATION_FACTOR / 2; // dagre splits it in half
+ const nodesep = NODE_SEPARATION_FACTOR;
+ const nodeWidth = NODE_SIZE_FACTOR;
+ const nodeHeight = NODE_SIZE_FACTOR;
const graphHeight = layout.graphHeight || layout.height;
const graphWidth = layout.graphWidth || layout.width;
const aspectRatio = graphHeight ? graphWidth / graphHeight : 1;
@@ -271,50 +265,6 @@ function layoutSingleNodes(layout, opts) {
return result;
}
-/**
- * Shifts all coordinates of node and edge points to make the layout more centered
- * @param {Object} layout Layout
- * @param {Object} opts Options with width and margins
- * @return {Object} modified layout
- */
-export function shiftLayoutToCenter(layout, opts) {
- const result = Object.assign({}, layout);
- const options = opts || {};
- const margins = options.margins || DEFAULT_MARGINS;
- const width = options.width || DEFAULT_WIDTH;
- const height = options.height || DEFAULT_HEIGHT;
-
- let offsetX = 0 + margins.left;
- let offsetY = 0 + margins.top;
-
- if (layout.width < width) {
- const xMin = layout.nodes.minBy(n => n.get('x'));
- const xMax = layout.nodes.maxBy(n => n.get('x'));
- offsetX = ((width - (xMin.get('x') + xMax.get('x'))) / 2) + margins.left;
- }
- if (layout.height < height) {
- const yMin = layout.nodes.minBy(n => n.get('y'));
- const yMax = layout.nodes.maxBy(n => n.get('y'));
- offsetY = ((height - (yMin.get('y') + yMax.get('y'))) / 2) + margins.top;
- }
-
- if (offsetX || offsetY) {
- result.nodes = layout.nodes.map(node => node.merge({
- x: node.get('x') + offsetX,
- y: node.get('y') + offsetY
- }));
-
- result.edges = layout.edges.map(edge => edge.update('points',
- points => points.map(point => point.merge({
- x: point.get('x') + offsetX,
- y: point.get('y') + offsetY
- }))
- ));
- }
-
- return result;
-}
-
/**
* Determine if nodes were added between node sets
* @param {Map} nodes new Map of nodes
@@ -478,17 +428,16 @@ export function doLayout(immNodes, immEdges, opts) {
log('skip layout, used rank-based insertion');
layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges);
layout = copyLayoutProperties(layout, nodeCache, edgeCache);
- layout = doLayoutNewNodesOfExistingRank(layout, nodeCache, opts);
+ layout = doLayoutNewNodesOfExistingRank(layout, nodeCache);
} else {
const graph = cache.graph;
- layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts);
+ layout = runLayoutEngine(graph, nodesWithDegrees, immEdges);
if (!layout) {
return layout;
}
}
layout = layoutSingleNodes(layout, opts);
- layout = shiftLayoutToCenter(layout, opts);
}
// cache results
diff --git a/client/app/scripts/components/sparkline.js b/client/app/scripts/components/sparkline.js
index 49b22688d..9a47dc7df 100644
--- a/client/app/scripts/components/sparkline.js
+++ b/client/app/scripts/components/sparkline.js
@@ -6,7 +6,6 @@ import { line, curveLinear } from 'd3-shape';
import { scaleLinear } from 'd3-scale';
import { formatMetricSvg } from '../utils/string-utils';
-import { round } from '../utils/math-utils';
export default class Sparkline extends React.Component {
@@ -64,7 +63,7 @@ export default class Sparkline extends React.Component {
const min = formatMetricSvg(d3Min(data, d => d.value), this.props);
const max = formatMetricSvg(d3Max(data, d => d.value), this.props);
const mean = formatMetricSvg(d3Mean(data, d => d.value), this.props);
- const title = `Last ${round((lastDate - firstDate) / 1000)} seconds, ` +
+ const title = `Last ${Math.round((lastDate - firstDate) / 1000)} seconds, ` +
`${data.length} samples, min: ${min}, max: ${max}, mean: ${mean}`;
return {title, lastX, lastY, data};
diff --git a/client/app/scripts/constants/animation.js b/client/app/scripts/constants/animation.js
new file mode 100644
index 000000000..e24d70770
--- /dev/null
+++ b/client/app/scripts/constants/animation.js
@@ -0,0 +1,2 @@
+
+export const NODES_SPRING_ANIMATION_CONFIG = { stiffness: 80, damping: 20, precision: 0.1 };
diff --git a/client/app/scripts/constants/styles.js b/client/app/scripts/constants/styles.js
index 909b9a311..3a68e0564 100644
--- a/client/app/scripts/constants/styles.js
+++ b/client/app/scripts/constants/styles.js
@@ -9,8 +9,6 @@ export const DETAILS_PANEL_MARGINS = {
export const DETAILS_PANEL_OFFSET = 8;
-export const CANVAS_METRIC_FONT_SIZE = 0.19;
-
export const CANVAS_MARGINS = {
top: 160,
left: 40,
@@ -18,14 +16,20 @@ export const CANVAS_MARGINS = {
bottom: 100,
};
-//
-// The base size the shapes were defined at matches nicely w/ a 14px font.
-//
-export const BASE_NODE_SIZE = 64;
-export const MIN_NODE_SIZE = 24;
-export const MAX_NODE_SIZE = 96;
-export const BASE_NODE_LABEL_SIZE = 14;
-export const MIN_NODE_LABEL_SIZE = 12;
+// Node shapes
+export const NODE_SHAPE_HIGHLIGHT_RADIUS = 70;
+export const NODE_SHAPE_BORDER_RADIUS = 50;
+export const NODE_SHAPE_SHADOW_RADIUS = 45;
+export const NODE_SHAPE_DOT_RADIUS = 10;
+export const NODE_BLUR_OPACITY = 0.2;
+// NOTE: This value represents the node unit radius (in pixels). Since zooming is
+// controlled at the top level now, this renormalization would be obsolete (i.e.
+// value 1 could be used instead), if it wasn't for the following factors:
+// 1. `dagre` library only works with integer coordinates,
+// so >> 1 value is used to increase layout precision.
+// 2. Fonts don't behave nicely (especially on Firefox) if they
+// are given on a small unit scale as foreign objects in SVG.
+export const NODE_BASE_SIZE = 100;
// Node details table constants
export const NODE_DETAILS_TABLE_CW = {
diff --git a/client/app/scripts/hoc/metric-feeder.js b/client/app/scripts/hoc/metric-feeder.js
index 1180cf9d4..c2167fd66 100644
--- a/client/app/scripts/hoc/metric-feeder.js
+++ b/client/app/scripts/hoc/metric-feeder.js
@@ -2,8 +2,6 @@ import React from 'react';
import { isoParse as parseDate } from 'd3-time-format';
import { OrderedMap } from 'immutable';
-import { round } from '../utils/math-utils';
-
const makeOrderedMap = OrderedMap;
const sortDate = (v, d) => d;
const DEFAULT_TICK_INTERVAL = 1000; // DEFAULT_TICK_INTERVAL + renderTime < 1000ms
@@ -104,7 +102,7 @@ export default ComposedComponent => class extends React.Component {
let lastIndex = bufferKeys.indexOf(movingLast);
// speed up the window if it falls behind
- const step = lastIndex > 0 ? round(buffer.size / lastIndex) : 1;
+ const step = lastIndex > 0 ? Math.round(buffer.size / lastIndex) : 1;
// only move first if we have enough values in window
const windowLength = lastIndex - firstIndex;
diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js
index 31a1532a9..3248c2eb6 100644
--- a/client/app/scripts/reducers/root.js
+++ b/client/app/scripts/reducers/root.js
@@ -7,8 +7,15 @@ import ActionTypes from '../constants/action-types';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { applyPinnedSearches, updateNodeMatches } from '../utils/search-utils';
import { getNetworkNodes, getAvailableNetworks } from '../utils/network-view-utils';
-import { findTopologyById, getAdjacentNodes, setTopologyUrlsById, updateTopologyIds,
- filterHiddenTopologies, addTopologyFullname, getDefaultTopology, graphExceedsComplexityThresh
+import {
+ findTopologyById,
+ getAdjacentNodes,
+ setTopologyUrlsById,
+ updateTopologyIds,
+ filterHiddenTopologies,
+ addTopologyFullname,
+ getDefaultTopology,
+ graphExceedsComplexityThresh
} from '../utils/topology-utils';
const log = debug('scope:app-store');
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 000000000..15ad07f0f
--- /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 000000000..e5c8a8e73
--- /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 000000000..ede1e9c45
--- /dev/null
+++ b/client/app/scripts/selectors/nodes-chart-zoom.js
@@ -0,0 +1,74 @@
+import { createSelector } from 'reselect';
+
+import { NODE_BASE_SIZE } from '../constants/styles';
+import { zoomCacheKey } from '../utils/topology-utils';
+
+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[zoomCacheKey(props)];
+
+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,
+ // or one half of maximal zoom constraint, whichever is smaller.
+ const zoomScale = Math.min(xFactor, yFactor, maxZoomScale / 2) * 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/scripts/utils/__tests__/math-utils-test.js b/client/app/scripts/utils/__tests__/math-utils-test.js
index 6d5f95398..ab46b1a96 100644
--- a/client/app/scripts/utils/__tests__/math-utils-test.js
+++ b/client/app/scripts/utils/__tests__/math-utils-test.js
@@ -19,21 +19,4 @@ describe('MathUtils', () => {
expect(f(-5, 5)).toBe(0);
});
});
-
- describe('round', () => {
- const f = MathUtils.round;
-
- it('it should round the decimal number to given precision', () => {
- expect(f(-173.6499023, -2)).toBe(-200);
- expect(f(-173.6499023, -1)).toBe(-170);
- expect(f(-173.6499023, 0)).toBe(-174);
- expect(f(-173.6499023)).toBe(-174);
- expect(f(-173.6499023, 1)).toBe(-173.6);
- expect(f(-173.6499023, 2)).toBe(-173.65);
- expect(f(0.0013, 2)).toBe(0);
- expect(f(0.0013, 3)).toBe(0.001);
- expect(f(0.0013, 4)).toBe(0.0013);
- expect(f(0.0013, 5)).toBe(0.0013);
- });
- });
});
diff --git a/client/app/scripts/utils/math-utils.js b/client/app/scripts/utils/math-utils.js
index 401bf76b8..578d83760 100644
--- a/client/app/scripts/utils/math-utils.js
+++ b/client/app/scripts/utils/math-utils.js
@@ -18,10 +18,3 @@
export function modulo(i, n) {
return ((i % n) + n) % n;
}
-
-// Does the same that the deprecated d3.round was doing.
-// Possibly imprecise: This https://github.com/d3/d3/issues/210
-export function round(value, decimals = 0) {
- const p = Math.pow(10, decimals);
- return Math.round(value * p) / p;
-}
diff --git a/client/app/scripts/utils/metric-utils.js b/client/app/scripts/utils/metric-utils.js
index 5acf724ae..e67852a3f 100644
--- a/client/app/scripts/utils/metric-utils.js
+++ b/client/app/scripts/utils/metric-utils.js
@@ -2,32 +2,30 @@ import { includes } from 'lodash';
import { scaleLog } from 'd3-scale';
import React from 'react';
+import { NODE_BASE_SIZE, NODE_SHAPE_DOT_RADIUS } from '../constants/styles';
import { formatMetricSvg } from './string-utils';
import { colors } from './color-utils';
-export function getClipPathDefinition(clipId, size, height,
- x = -size * 0.5, y = (size * 0.5) - height) {
+export function getClipPathDefinition(clipId, height) {
return (
-
-
+
+
);
}
+export function renderMetricValue(value, condition) {
+ return condition ? {value} : ;
+}
//
// loadScale(1) == 0.5; E.g. a nicely balanced system :).
const loadScale = scaleLog().domain([0.01, 100]).range([0, 1]);
-export function getMetricValue(metric, size) {
+export function getMetricValue(metric) {
if (!metric) {
return {height: 0, value: null, formattedValue: 'n/a'};
}
@@ -48,10 +46,9 @@ export function getMetricValue(metric, size) {
} else if (displayedValue >= m.max && displayedValue > 0) {
displayedValue = 1;
}
- const height = size * displayedValue;
return {
- height,
+ height: displayedValue,
hasMetric: value !== null,
formattedValue: formatMetricSvg(value, m)
};
diff --git a/client/app/scripts/utils/node-shape-utils.js b/client/app/scripts/utils/node-shape-utils.js
new file mode 100644
index 000000000..45dbf6b2b
--- /dev/null
+++ b/client/app/scripts/utils/node-shape-utils.js
@@ -0,0 +1,12 @@
+import { line, curveCardinalClosed } from 'd3-shape';
+import range from 'lodash/range';
+
+const shapeSpline = line().curve(curveCardinalClosed.tension(0.65));
+
+export function nodeShapePolygon(radius, n) {
+ const innerAngle = (2 * Math.PI) / n;
+ return shapeSpline(range(0, n).map(k => [
+ radius * Math.sin(k * innerAngle),
+ -radius * Math.cos(k * innerAngle)
+ ]));
+}
diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js
index 5588c7e69..9a7485641 100644
--- a/client/app/scripts/utils/topology-utils.js
+++ b/client/app/scripts/utils/topology-utils.js
@@ -182,3 +182,7 @@ export function graphExceedsComplexityThresh(stats) {
// Check to see if complexity is high. Used to trigger table view on page load.
return (stats.get('node_count') + (2 * stats.get('edge_count'))) > 500;
}
+
+export function zoomCacheKey(props) {
+ return `${props.topologyId}-${JSON.stringify(props.topologyOptions)}`;
+}
diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss
index 22601a7d8..01e7df8df 100644
--- a/client/app/styles/_base.scss
+++ b/client/app/styles/_base.scss
@@ -299,7 +299,7 @@
fill: $text-secondary-color;
}
- .nodes-chart-nodes > .node {
+ .nodes-chart-nodes .node {
transition: opacity .5s $base-ease;
text-align: center;
@@ -316,6 +316,11 @@
color: $text-color;
}
+ .node-labels-container {
+ transform: scale($node-text-scale);
+ pointer-events: none;
+ }
+
.node-label-wrapper {
//
// Base line height doesn't hop across foreignObject =/
@@ -336,6 +341,9 @@
vertical-align: top;
cursor: pointer;
+ pointer-events: all;
+ font-size: 12px;
+ width: 100%;
}
.node-sublabel {
@@ -344,7 +352,6 @@
}
.node-label, .node-sublabel {
-
span {
border-radius: 2px;
}
@@ -411,15 +418,13 @@
}
.link {
- stroke: $text-secondary-color;
- stroke-width: $edge-link-stroke-width;
fill: none;
+ stroke: $text-secondary-color;
stroke-opacity: $edge-opacity;
}
.shadow {
- stroke: $weave-blue;
- stroke-width: 10px;
fill: none;
+ stroke: $weave-blue;
stroke-opacity: 0;
}
&.highlighted {
@@ -433,7 +438,7 @@
display: none;
}
- .stack .onlyHighlight .shape {
+ .stack .highlight .shape {
.border { display: none; }
.shadow { display: none; }
.node { display: none; }
@@ -448,8 +453,7 @@
transform: scale(1);
cursor: pointer;
- /* cloud paths have stroke-width set dynamically */
- &:not(.shape-cloud) .border {
+ .border {
stroke-width: $node-border-stroke-width;
fill: $background-color;
transition: stroke-opacity 0.333s $base-ease, fill 0.333s $base-ease;
@@ -475,11 +479,12 @@
.node {
fill: $text-color;
stroke: $background-lighter-color;
- stroke-width: 2px;
+ stroke-width: 0.05;
}
text {
- font-size: 12px;
+ transform: scale($node-text-scale);
+ font-size: 10px;
dominant-baseline: middle;
text-anchor: middle;
}
@@ -494,7 +499,7 @@
}
.stack .shape .border {
- stroke-width: $node-border-stroke-width - 0.5;
+ stroke-width: $node-border-stroke-width * 0.8;
}
}
diff --git a/client/app/styles/_contrast-overrides.scss b/client/app/styles/_contrast-overrides.scss
index adcc59256..1ae13c32b 100644
--- a/client/app/styles/_contrast-overrides.scss
+++ b/client/app/styles/_contrast-overrides.scss
@@ -14,13 +14,12 @@ $white: white;
$node-opacity-blurred: 0.6;
$node-highlight-fill-opacity: 0.3;
$node-highlight-stroke-opacity: 0.5;
-$node-highlight-stroke-width: 3px;
-$node-border-stroke-width: 5px;
+$node-highlight-stroke-width: 8;
+$node-border-stroke-width: 10;
$node-pseudo-opacity: 1;
$edge-highlight-opacity: 0.3;
$edge-opacity-blurred: 0;
$edge-opacity: 0.5;
-$edge-link-stroke-width: 3px;
$btn-opacity-default: 1;
$btn-opacity-hover: 1;
diff --git a/client/app/styles/_variables.scss b/client/app/styles/_variables.scss
index 8e28a72e2..778322088 100644
--- a/client/app/styles/_variables.scss
+++ b/client/app/styles/_variables.scss
@@ -33,13 +33,13 @@ $terminal-header-height: 44px;
$node-opacity-blurred: 0.25;
$node-highlight-fill-opacity: 0.1;
$node-highlight-stroke-opacity: 0.4;
-$node-highlight-stroke-width: 1px;
-$node-border-stroke-width: 2.5px;
+$node-highlight-stroke-width: 2;
+$node-border-stroke-width: 6;
$node-pseudo-opacity: 0.8;
+$node-text-scale: 2;
$edge-highlight-opacity: 0.1;
$edge-opacity-blurred: 0.2;
$edge-opacity: 0.5;
-$edge-link-stroke-width: 1px;
$btn-opacity-default: 0.7;
$btn-opacity-hover: 1;