+
-
+
diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js
index dabed89e6..1be2a0f3b 100644
--- a/client/app/scripts/charts/node.js
+++ b/client/app/scripts/charts/node.js
@@ -15,8 +15,13 @@ 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}));
@@ -38,72 +43,58 @@ 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.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);
+ this.state = {
+ hovered: false,
+ matched: false
+ };
}
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, networks, pseudo, rank, label,
- transform, exportingGraph, showingNetworks, stack } = this.props;
+ const { blurred, focused, highlighted, label, matches = makeMap(), networks,
+ pseudo, rank, subLabel, scaleFactor, 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 labelOffsetY = (showingNetworks && networks) ? 40 : 28;
- const networkOffset = 0.67;
+ 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 nodeClassName = classnames('node', {
highlighted,
@@ -118,25 +109,51 @@ 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 || false ?
- this.renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) :
- this.renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents)}
+
+ {useSvgLabels ?
+
+ svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) :
+
+
+
+
+
+
+
+
+
+ {!blurred &&
}
+
+ }
-
+
{showingNetworks && }
diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js
index 402cf0b1b..3e0d3ce90 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, searchQuery,
- isAnimated, selectedScale, selectedNodeId, selectedNetwork, selectedNetworkNodes,
- searchNodeMatches = makeMap() } = this.props;
+ const { hasSelectedNode, highlightedEdgeIds, layoutEdges,
+ layoutPrecision, searchNodeMatches = makeMap(), searchQuery,
+ selectedNodeId, selectedNetwork, selectedNetworkNodes } = this.props;
return (
@@ -35,11 +35,10 @@ class NodesChartEdges extends React.Component {
id={edge.get('id')}
source={edge.get('source')}
target={edge.get('target')}
- waypoints={edge.get('points')}
- scale={focused ? selectedScale : 1}
- isAnimated={isAnimated}
+ points={edge.get('points')}
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 dc16e0b73..19b1aa996 100644
--- a/client/app/scripts/charts/nodes-chart-elements.js
+++ b/client/app/scripts/charts/nodes-chart-elements.js
@@ -12,12 +12,13 @@ class NodesChartElements extends React.Component {
+ layoutPrecision={props.layoutPrecision} />
+ nodeScale={props.nodeScale}
+ scale={props.scale}
+ selectedNodeScale={props.selectedNodeScale}
+ layoutPrecision={props.layoutPrecision} />
);
}
diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js
index eecfce22c..4bacb2d23 100644
--- a/client/app/scripts/charts/nodes-chart-nodes.js
+++ b/client/app/scripts/charts/nodes-chart-nodes.js
@@ -7,9 +7,12 @@ import NodeContainer from './node-container';
class NodesChartNodes extends React.Component {
render() {
- const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId,
- selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId,
- topCardNode, searchNodeMatches = makeMap() } = this.props;
+ const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision,
+ mouseOverNodeId, nodeScale, scale, searchNodeMatches = makeMap(),
+ searchQuery, selectedMetric, selectedNetwork, selectedNodeScale, selectedNodeId,
+ topCardNode } = this.props;
+
+ const zoomScale = scale;
// highlighter functions
const setHighlighted = node => node.set('highlighted',
@@ -70,11 +73,12 @@ class NodesChartNodes extends React.Component {
subLabel={node.get('subLabel')}
metric={metric(node)}
rank={node.get('rank')}
- isAnimated={isAnimated}
- magnified={node.get('focused') ? selectedScale : 1}
+ layoutPrecision={layoutPrecision}
+ selectedNodeScale={selectedNodeScale}
+ nodeScale={nodeScale}
+ zoomScale={zoomScale}
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 33a40fd73..72869150f 100644
--- a/client/app/scripts/charts/nodes-chart.js
+++ b/client/app/scripts/charts/nodes-chart.js
@@ -1,63 +1,272 @@
+import debug from 'debug';
import React from 'react';
import { connect } from 'react-redux';
-import { assign, pick } from 'lodash';
-import { Map as makeMap } from 'immutable';
+import { assign, pick, includes } from 'lodash';
+import { Map as makeMap, fromJS } from 'immutable';
+import timely from 'timely';
+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, zoomCacheKey } from '../utils/topology-utils';
+import { getActiveTopologyOptions } from '../utils/topology-utils';
-import { topologyZoomState } from '../selectors/nodes-chart-zoom';
-import { layoutWithSelectedNode } from '../selectors/nodes-chart-focus';
-import { graphLayout } from '../selectors/nodes-chart-layout';
+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;
+}
-const GRAPH_COMPLEXITY_NODES_TRESHOLD = 100;
-const ZOOM_CACHE_FIELDS = [
- 'panTranslateX', 'panTranslateY',
- 'zoomScale', 'minZoomScale', 'maxZoomScale'
-];
+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
+ };
+}
class NodesChart extends React.Component {
+
constructor(props, context) {
super(props, context);
- 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: {},
- };
-
this.handleMouseClick = this.handleMouseClick.bind(this);
this.zoomed = this.zoomed.bind(this);
+
+ this.state = {
+ edges: makeMap(),
+ nodes: makeMap(),
+ nodeScale: scaleLinear(),
+ panTranslateX: 0,
+ panTranslateY: 0,
+ scale: 1,
+ selectedNodeScale: scaleLinear(),
+ hasZoomed: false,
+ height: props.height || 0,
+ width: props.width || 0,
+ zoomCache: {},
+ };
}
componentWillMount() {
- this.setState(graphLayout(this.state, this.props));
+ 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);
}
componentDidMount() {
// distinguish pan/zoom from click
this.isZooming = false;
- this.zoom = zoom().on('zoom', this.zoomed);
+
+ this.zoom = zoom()
+ .scaleExtent([0.1, 2])
+ .on('zoom', this.zoomed);
this.svg = select('.nodes-chart svg');
this.svg.call(this.zoom);
@@ -73,40 +282,15 @@ 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() {
- // Not passing transform into child components for perf reasons.
- const { panTranslateX, panTranslateY, zoomScale } = this.state;
- const transform = `translate(${panTranslateX}, ${panTranslateY}) scale(${zoomScale})`;
+ const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state;
+ // 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 (
+ selectedNodeScale={this.state.selectedNodeScale}
+ layoutPrecision={layoutPrecision} />
);
@@ -134,39 +320,81 @@ class NodesChart extends React.Component {
}
}
- isTopologyGraphComplex() {
- return this.state.layoutNodes.size > GRAPH_COMPLEXITY_NODES_TRESHOLD;
+ 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 };
}
- cacheZoomState(state) {
- const zoomState = pick(state, ZOOM_CACHE_FIELDS);
- const zoomCache = assign({}, state.zoomCache);
- zoomCache[zoomCacheKey(this.props)] = zoomState;
- return { zoomCache };
- }
+ updateGraphState(props, state) {
+ if (props.nodes.size === 0) {
+ return {
+ nodes: makeMap(),
+ edges: makeMap()
+ };
+ }
- 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));
+ 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),
+ };
}
zoomed() {
this.isZooming = true;
- // don't pan while node is selected
+ // dont pan while node is selected
if (!this.props.selectedNodeId) {
- let state = assign({}, this.state, {
+ this.setState({
+ hasZoomed: true,
panTranslateX: d3Event.transform.x,
panTranslateY: d3Event.transform.y,
- zoomScale: d3Event.transform.k
+ scale: 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));
+ }
}
@@ -177,7 +405,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 635fb8092..efd356541 100644
--- a/client/app/scripts/charts/nodes-layout.js
+++ b/client/app/scripts/charts/nodes-layout.js
@@ -2,7 +2,6 @@ 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';
@@ -13,9 +12,10 @@ const topologyCaches = {};
export const DEFAULT_WIDTH = 800;
export const DEFAULT_HEIGHT = DEFAULT_WIDTH / 2;
export const DEFAULT_MARGINS = {top: 0, left: 0};
-const NODE_SIZE_FACTOR = NODE_BASE_SIZE;
-const NODE_SEPARATION_FACTOR = 2 * NODE_BASE_SIZE;
-const RANK_SEPARATION_FACTOR = 3 * NODE_BASE_SIZE;
+const DEFAULT_SCALE = val => val * 2;
+const NODE_SIZE_FACTOR = 1;
+const NODE_SEPARATION_FACTOR = 2.0;
+const RANK_SEPARATION_FACTOR = 3.0;
let layoutRuns = 0;
let layoutRunsTrivial = 0;
@@ -34,16 +34,19 @@ 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) {
+function runLayoutEngine(graph, imNodes, imEdges, opts) {
let nodes = imNodes;
let edges = imEdges;
- const ranksep = RANK_SEPARATION_FACTOR;
- const nodesep = NODE_SEPARATION_FACTOR;
- const nodeWidth = NODE_SIZE_FACTOR;
- const nodeHeight = NODE_SIZE_FACTOR;
+ 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);
// configure node margins
graph.setGraph({
@@ -151,10 +154,12 @@ function setSimpleEdgePoints(edge, nodeCache) {
* @param {object} opts Options
* @return {object} new layout object
*/
-export function doLayoutNewNodesOfExistingRank(layout, nodeCache) {
+export function doLayoutNewNodesOfExistingRank(layout, nodeCache, opts) {
const result = Object.assign({}, layout);
- const nodesep = NODE_SEPARATION_FACTOR;
- const nodeWidth = NODE_SIZE_FACTOR;
+ const options = opts || {};
+ const scale = options.scale || DEFAULT_SCALE;
+ const nodesep = scale(NODE_SEPARATION_FACTOR);
+ const nodeWidth = scale(NODE_SIZE_FACTOR);
// determine new nodes
const oldNodes = ImmSet.fromKeys(nodeCache);
@@ -195,10 +200,11 @@ function layoutSingleNodes(layout, opts) {
const result = Object.assign({}, layout);
const options = opts || {};
const margins = options.margins || DEFAULT_MARGINS;
- 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 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 graphHeight = layout.graphHeight || layout.height;
const graphWidth = layout.graphWidth || layout.width;
const aspectRatio = graphHeight ? graphWidth / graphHeight : 1;
@@ -265,6 +271,50 @@ 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
@@ -428,16 +478,17 @@ 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);
+ layout = doLayoutNewNodesOfExistingRank(layout, nodeCache, opts);
} else {
const graph = cache.graph;
- layout = runLayoutEngine(graph, nodesWithDegrees, immEdges);
+ layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts);
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 9a47dc7df..49b22688d 100644
--- a/client/app/scripts/components/sparkline.js
+++ b/client/app/scripts/components/sparkline.js
@@ -6,6 +6,7 @@ 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 {
@@ -63,7 +64,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 ${Math.round((lastDate - firstDate) / 1000)} seconds, ` +
+ const title = `Last ${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
deleted file mode 100644
index e24d70770..000000000
--- a/client/app/scripts/constants/animation.js
+++ /dev/null
@@ -1,2 +0,0 @@
-
-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 e7d6a16cf..909b9a311 100644
--- a/client/app/scripts/constants/styles.js
+++ b/client/app/scripts/constants/styles.js
@@ -18,18 +18,14 @@ export const CANVAS_MARGINS = {
bottom: 100,
};
-// Node shapes
-export const NODE_SHAPE_HIGHLIGHT_RADIUS = 0.7;
-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;
-// 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;
+//
+// 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 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 c2167fd66..1180cf9d4 100644
--- a/client/app/scripts/hoc/metric-feeder.js
+++ b/client/app/scripts/hoc/metric-feeder.js
@@ -2,6 +2,8 @@ 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
@@ -102,7 +104,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 ? Math.round(buffer.size / lastIndex) : 1;
+ const step = lastIndex > 0 ? 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 3248c2eb6..31a1532a9 100644
--- a/client/app/scripts/reducers/root.js
+++ b/client/app/scripts/reducers/root.js
@@ -7,15 +7,8 @@ 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
deleted file mode 100644
index 15ad07f0f..000000000
--- a/client/app/scripts/selectors/nodes-chart-focus.js
+++ /dev/null
@@ -1,149 +0,0 @@
-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
deleted file mode 100644
index e5c8a8e73..000000000
--- a/client/app/scripts/selectors/nodes-chart-layout.js
+++ /dev/null
@@ -1,94 +0,0 @@
-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
deleted file mode 100644
index ca0db11dd..000000000
--- a/client/app/scripts/selectors/nodes-chart-zoom.js
+++ /dev/null
@@ -1,74 +0,0 @@
-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, 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/scripts/utils/__tests__/math-utils-test.js b/client/app/scripts/utils/__tests__/math-utils-test.js
index ab46b1a96..6d5f95398 100644
--- a/client/app/scripts/utils/__tests__/math-utils-test.js
+++ b/client/app/scripts/utils/__tests__/math-utils-test.js
@@ -19,4 +19,21 @@ 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 578d83760..401bf76b8 100644
--- a/client/app/scripts/utils/math-utils.js
+++ b/client/app/scripts/utils/math-utils.js
@@ -18,3 +18,10 @@
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 f27a56760..5acf724ae 100644
--- a/client/app/scripts/utils/metric-utils.js
+++ b/client/app/scripts/utils/metric-utils.js
@@ -2,30 +2,32 @@ import { includes } from 'lodash';
import { scaleLog } from 'd3-scale';
import React from 'react';
-import { NODE_SHAPE_DOT_RADIUS } from '../constants/styles';
import { formatMetricSvg } from './string-utils';
import { colors } from './color-utils';
-export function getClipPathDefinition(clipId, height) {
+export function getClipPathDefinition(clipId, size, height,
+ x = -size * 0.5, y = (size * 0.5) - 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) {
+export function getMetricValue(metric, size) {
if (!metric) {
return {height: 0, value: null, formattedValue: 'n/a'};
}
@@ -46,9 +48,10 @@ export function getMetricValue(metric) {
} else if (displayedValue >= m.max && displayedValue > 0) {
displayedValue = 1;
}
+ const height = size * displayedValue;
return {
- height: displayedValue,
+ height,
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
deleted file mode 100644
index 45dbf6b2b..000000000
--- a/client/app/scripts/utils/node-shape-utils.js
+++ /dev/null
@@ -1,12 +0,0 @@
-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 9a7485641..5588c7e69 100644
--- a/client/app/scripts/utils/topology-utils.js
+++ b/client/app/scripts/utils/topology-utils.js
@@ -182,7 +182,3 @@ 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 aaaea5f16..22601a7d8 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,14 +316,6 @@
color: $text-color;
}
- .node-labels-container {
- transform: scale($node-text-scale);
- pointer-events: none;
- height: 5em;
- x: -0.5 * $node-labels-max-width;
- width: $node-labels-max-width;
- }
-
.node-label-wrapper {
//
// Base line height doesn't hop across foreignObject =/
@@ -344,9 +336,6 @@
vertical-align: top;
cursor: pointer;
- pointer-events: all;
- font-size: 12px;
- width: 100%;
}
.node-sublabel {
@@ -355,6 +344,7 @@
}
.node-label, .node-sublabel {
+
span {
border-radius: 2px;
}
@@ -421,13 +411,15 @@
}
.link {
- fill: none;
stroke: $text-secondary-color;
+ stroke-width: $edge-link-stroke-width;
+ fill: none;
stroke-opacity: $edge-opacity;
}
.shadow {
- fill: none;
stroke: $weave-blue;
+ stroke-width: 10px;
+ fill: none;
stroke-opacity: 0;
}
&.highlighted {
@@ -441,7 +433,7 @@
display: none;
}
- .stack .highlight .shape {
+ .stack .onlyHighlight .shape {
.border { display: none; }
.shadow { display: none; }
.node { display: none; }
@@ -456,7 +448,8 @@
transform: scale(1);
cursor: pointer;
- .border {
+ /* cloud paths have stroke-width set dynamically */
+ &:not(.shape-cloud) .border {
stroke-width: $node-border-stroke-width;
fill: $background-color;
transition: stroke-opacity 0.333s $base-ease, fill 0.333s $base-ease;
@@ -482,12 +475,11 @@
.node {
fill: $text-color;
stroke: $background-lighter-color;
- stroke-width: 0.05;
+ stroke-width: 2px;
}
text {
- transform: scale($node-text-scale);
- font-size: 10px;
+ font-size: 12px;
dominant-baseline: middle;
text-anchor: middle;
}
@@ -502,7 +494,7 @@
}
.stack .shape .border {
- stroke-width: $node-border-stroke-width * 0.8;
+ stroke-width: $node-border-stroke-width - 0.5;
}
}
diff --git a/client/app/styles/_contrast-overrides.scss b/client/app/styles/_contrast-overrides.scss
index a62c5de5f..adcc59256 100644
--- a/client/app/styles/_contrast-overrides.scss
+++ b/client/app/styles/_contrast-overrides.scss
@@ -14,12 +14,13 @@ $white: white;
$node-opacity-blurred: 0.6;
$node-highlight-fill-opacity: 0.3;
$node-highlight-stroke-opacity: 0.5;
-$node-highlight-stroke-width: 0.06;
-$node-border-stroke-width: 0.1;
+$node-highlight-stroke-width: 3px;
+$node-border-stroke-width: 5px;
$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 14c434d06..8e28a72e2 100644
--- a/client/app/styles/_variables.scss
+++ b/client/app/styles/_variables.scss
@@ -33,14 +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: 0.02;
-$node-border-stroke-width: 0.06;
+$node-highlight-stroke-width: 1px;
+$node-border-stroke-width: 2.5px;
$node-pseudo-opacity: 0.8;
-$node-text-scale: 0.02;
-$node-labels-max-width: 120px;
$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;