From 8eaa12e68010efa2648aa077ece9a0d42e550c6c Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 2 Feb 2017 11:42:12 +0100 Subject: [PATCH] Revert "Graph layout optimizations" --- .../charts/__tests__/node-layout-test.js | 48 +++ client/app/scripts/charts/edge-container.js | 90 ++-- client/app/scripts/charts/edge.js | 18 +- client/app/scripts/charts/node-container.js | 45 +- .../scripts/charts/node-networks-overlay.js | 48 ++- .../app/scripts/charts/node-shape-circle.js | 40 +- client/app/scripts/charts/node-shape-cloud.js | 55 ++- .../app/scripts/charts/node-shape-heptagon.js | 61 +-- .../app/scripts/charts/node-shape-hexagon.js | 85 ++-- .../app/scripts/charts/node-shape-square.js | 60 ++- client/app/scripts/charts/node-shape-stack.js | 19 +- client/app/scripts/charts/node.js | 115 +++--- .../app/scripts/charts/nodes-chart-edges.js | 11 +- .../scripts/charts/nodes-chart-elements.js | 9 +- .../app/scripts/charts/nodes-chart-nodes.js | 18 +- client/app/scripts/charts/nodes-chart.js | 390 ++++++++++++++---- client/app/scripts/charts/nodes-layout.js | 87 +++- client/app/scripts/components/sparkline.js | 3 +- client/app/scripts/constants/animation.js | 2 - client/app/scripts/constants/styles.js | 20 +- client/app/scripts/hoc/metric-feeder.js | 4 +- client/app/scripts/reducers/root.js | 11 +- .../scripts/selectors/nodes-chart-focus.js | 149 ------- .../scripts/selectors/nodes-chart-layout.js | 94 ----- .../app/scripts/selectors/nodes-chart-zoom.js | 74 ---- .../utils/__tests__/math-utils-test.js | 17 + client/app/scripts/utils/math-utils.js | 7 + client/app/scripts/utils/metric-utils.js | 19 +- client/app/scripts/utils/node-shape-utils.js | 12 - client/app/scripts/utils/topology-utils.js | 4 - client/app/styles/_base.scss | 32 +- client/app/styles/_contrast-overrides.scss | 5 +- client/app/styles/_variables.scss | 7 +- 33 files changed, 867 insertions(+), 792 deletions(-) delete mode 100644 client/app/scripts/constants/animation.js delete mode 100644 client/app/scripts/selectors/nodes-chart-focus.js delete mode 100644 client/app/scripts/selectors/nodes-chart-layout.js delete mode 100644 client/app/scripts/selectors/nodes-chart-zoom.js delete mode 100644 client/app/scripts/utils/node-shape-utils.js diff --git a/client/app/scripts/charts/__tests__/node-layout-test.js b/client/app/scripts/charts/__tests__/node-layout-test.js index a9fcb99e09..ec06b61066 100644 --- a/client/app/scripts/charts/__tests__/node-layout-test.js +++ b/client/app/scripts/charts/__tests__/node-layout-test.js @@ -167,6 +167,54 @@ describe('NodesLayout', () => { expect(hasUnseen).toBeTruthy(); }); + it('shifts layouts to center', () => { + let xMin; + let xMax; + let yMin; + let yMax; + let xCenter; + let yCenter; + + // make sure initial layout is centered + const original = NodesLayout.doLayout( + nodeSets.initial4.nodes, + nodeSets.initial4.edges + ); + xMin = original.nodes.minBy(n => n.get('x')); + xMax = original.nodes.maxBy(n => n.get('x')); + yMin = original.nodes.minBy(n => n.get('y')); + yMax = original.nodes.maxBy(n => n.get('y')); + xCenter = (xMin.get('x') + xMax.get('x')) / 2; + yCenter = (yMin.get('y') + yMax.get('y')) / 2; + expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2); + expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2); + + // make sure re-running is idempotent + const rerun = NodesLayout.shiftLayoutToCenter(original); + xMin = rerun.nodes.minBy(n => n.get('x')); + xMax = rerun.nodes.maxBy(n => n.get('x')); + yMin = rerun.nodes.minBy(n => n.get('y')); + yMax = rerun.nodes.maxBy(n => n.get('y')); + xCenter = (xMin.get('x') + xMax.get('x')) / 2; + yCenter = (yMin.get('y') + yMax.get('y')) / 2; + expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2); + expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2); + + // shift after window was resized + const shifted = NodesLayout.shiftLayoutToCenter(original, { + width: 128, + height: 256 + }); + xMin = shifted.nodes.minBy(n => n.get('x')); + xMax = shifted.nodes.maxBy(n => n.get('x')); + yMin = shifted.nodes.minBy(n => n.get('y')); + yMax = shifted.nodes.maxBy(n => n.get('y')); + xCenter = (xMin.get('x') + xMax.get('x')) / 2; + yCenter = (yMin.get('y') + yMax.get('y')) / 2; + expect(xCenter).toEqual(128 / 2); + expect(yCenter).toEqual(256 / 2); + }); + it('lays out initial nodeset in a rectangle', () => { const result = NodesLayout.doLayout( nodeSets.initial4.nodes, diff --git a/client/app/scripts/charts/edge-container.js b/client/app/scripts/charts/edge-container.js index d33241b0c2..81c1d2bb0d 100644 --- a/client/app/scripts/charts/edge-container.js +++ b/client/app/scripts/charts/edge-container.js @@ -5,102 +5,106 @@ import { Map as makeMap } from 'immutable'; import { line, curveBasis } from 'd3-shape'; import { each, omit, times, constant } from 'lodash'; -import { NODES_SPRING_ANIMATION_CONFIG } from '../constants/animation'; import { uniformSelect } from '../utils/array-utils'; +import { round } from '../utils/math-utils'; import Edge from './edge'; +// Spring stiffness & damping respectively +const ANIMATION_CONFIG = [80, 20]; // Tweak this value for the number of control // points along the edge curve, e.g. values: // * 2 -> edges are simply straight lines // * 4 -> minimal value for loops to look ok -const WAYPOINTS_COUNT = 8; +const WAYPOINTS_CAP = 8; const spline = line() .curve(curveBasis) .x(d => d.x) .y(d => d.y); -const transformedEdge = (props, path) => ( - -); - -// Converts a waypoints map of the format {x0: 11, y0: 22, x1: 33, y1: 44} -// that is used by Motion to an array of waypoints in the format -// [{x: 11, y: 22}, {x: 33, y: 44}] that can be used by D3. -const waypointsMapToArray = (waypointsMap) => { - const waypointsArray = times(WAYPOINTS_COUNT, () => ({})); - each(waypointsMap, (value, key) => { - const [axis, index] = [key[0], key.slice(1)]; - waypointsArray[index][axis] = value; +const buildPath = (points, layoutPrecision) => { + const extracted = []; + each(points, (value, key) => { + const axis = key[0]; + const index = key.slice(1); + if (!extracted[index]) { + extracted[index] = {}; + } + extracted[index][axis] = round(value, layoutPrecision); }); - return waypointsArray; + return extracted; }; - class EdgeContainer extends React.Component { + constructor(props, context) { super(props, context); - this.state = { waypointsMap: makeMap() }; + this.state = { + pointsMap: makeMap() + }; } componentWillMount() { - if (this.props.isAnimated) { - this.prepareWaypointsForMotion(this.props.waypoints); - } + this.preparePoints(this.props.points); } componentWillReceiveProps(nextProps) { // immutablejs allows us to `===`! \o/ - if (this.props.isAnimated && nextProps.waypoints !== this.props.waypoints) { - this.prepareWaypointsForMotion(nextProps.waypoints); + if (nextProps.points !== this.props.points) { + this.preparePoints(nextProps.points); } } render() { - const { isAnimated, waypoints } = this.props; - const forwardedProps = omit(this.props, 'isAnimated', 'waypoints'); + const { layoutPrecision, points } = this.props; + const other = omit(this.props, 'points'); - if (!isAnimated) { - return transformedEdge(forwardedProps, waypoints.toJS()); + if (layoutPrecision === 0) { + const path = spline(points.toJS()); + return ; } return ( - // For the Motion interpolation to work, the waypoints need to be in a map format like - // {x0: 11, y0: 22, x1: 33, y1: 44} that we convert to the array format when rendering. - - {interpolated => transformedEdge(forwardedProps, waypointsMapToArray(interpolated))} + + {(interpolated) => { + // convert points to path string, because that lends itself to + // JS-equality checks in the child component + const path = spline(buildPath(interpolated, layoutPrecision)); + return ; + }} ); } - prepareWaypointsForMotion(nextWaypoints) { - nextWaypoints = nextWaypoints.toJS(); + preparePoints(nextPoints) { + nextPoints = nextPoints.toJS(); // Motion requires a constant number of waypoints along the path of each edge // for the animation to work correctly, but dagre might be changing their number // depending on the dynamic topology reconfiguration. Here we are transforming - // the waypoints array given by dagre to the fixed size of `WAYPOINTS_COUNT` that + // the waypoints array given by dagre to the fixed size of `WAYPOINTS_CAP` that // Motion could take over. - const waypointsMissing = WAYPOINTS_COUNT - nextWaypoints.length; - if (waypointsMissing > 0) { + const pointsMissing = WAYPOINTS_CAP - nextPoints.length; + if (pointsMissing > 0) { // Whenever there are some waypoints missing, we simply populate the beginning of the // array with the first element, as this leaves the curve interpolation unchanged. - nextWaypoints = times(waypointsMissing, constant(nextWaypoints[0])).concat(nextWaypoints); - } else if (waypointsMissing < 0) { + nextPoints = times(pointsMissing, constant(nextPoints[0])).concat(nextPoints); + } else if (pointsMissing < 0) { // If there are 'too many' waypoints given by dagre, we select a sub-array of // uniformly distributed indices. Note that it is very important to keep the first // and the last endpoints in the array as they are the ones connecting the nodes. - nextWaypoints = uniformSelect(nextWaypoints, WAYPOINTS_COUNT); + nextPoints = uniformSelect(nextPoints, WAYPOINTS_CAP); } - let { waypointsMap } = this.state; - nextWaypoints.forEach((point, index) => { - waypointsMap = waypointsMap.set(`x${index}`, spring(point.x, NODES_SPRING_ANIMATION_CONFIG)); - waypointsMap = waypointsMap.set(`y${index}`, spring(point.y, NODES_SPRING_ANIMATION_CONFIG)); + let { pointsMap } = this.state; + nextPoints.forEach((point, index) => { + pointsMap = pointsMap.set(`x${index}`, spring(point.x, ANIMATION_CONFIG)); + pointsMap = pointsMap.set(`y${index}`, spring(point.y, ANIMATION_CONFIG)); }); - this.setState({ waypointsMap }); + this.setState({ pointsMap }); } + } export default connect()(EdgeContainer); diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index 1deab2f041..ef33a5f1a2 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -3,8 +3,6 @@ import { connect } from 'react-redux'; import classNames from 'classnames'; import { enterEdge, leaveEdge } from '../actions/app-actions'; -import { isContrastMode } from '../utils/contrast-utils'; -import { NODE_BASE_SIZE } from '../constants/styles'; class Edge extends React.Component { @@ -15,19 +13,15 @@ class Edge extends React.Component { } render() { - const { id, path, highlighted, blurred, focused, scale } = this.props; - const className = classNames('edge', { highlighted, blurred, focused }); - const thickness = scale * (isContrastMode() ? 0.02 : 0.01) * NODE_BASE_SIZE; + const { id, path, highlighted, blurred, focused } = this.props; + const className = classNames('edge', {highlighted, blurred, focused}); - // Draws the edge so that its thickness reflects the zoom scale. - // Edge shadow is always made 10x thicker than the edge itself. return ( - - + className={className} onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} id={id}> + + ); } diff --git a/client/app/scripts/charts/node-container.js b/client/app/scripts/charts/node-container.js index dc92b46bc5..4faa3ae1dc 100644 --- a/client/app/scripts/charts/node-container.js +++ b/client/app/scripts/charts/node-container.js @@ -3,40 +3,29 @@ import { omit } from 'lodash'; import { connect } from 'react-redux'; import { Motion, spring } from 'react-motion'; -import { NODES_SPRING_ANIMATION_CONFIG } from '../constants/animation'; -import { NODE_BASE_SIZE, NODE_BLUR_OPACITY } from '../constants/styles'; +import { round } from '../utils/math-utils'; import Node from './node'; -const transformedNode = (otherProps, { x, y, k }) => ( - -); - class NodeContainer extends React.Component { render() { - const { dx, dy, isAnimated, magnified, blurred } = this.props; - const forwardedProps = omit(this.props, 'dx', 'dy', 'isAnimated', 'magnified', 'blurred'); - const opacity = blurred ? NODE_BLUR_OPACITY : 1; - const scale = magnified * NODE_BASE_SIZE; + const { dx, dy, focused, layoutPrecision, zoomScale } = this.props; + const animConfig = [80, 20]; // stiffness, damping + const scaleFactor = focused ? (1 / zoomScale) : 1; + const other = omit(this.props, 'dx', 'dy'); - // NOTE: Controlling blurring from here seems to re-render faster - // than adding a CSS class and controlling it from there. return ( - - {!isAnimated ? - - // Show static node for optimized rendering - transformedNode(forwardedProps, { x: dx, y: dy, k: scale }) : - - // Animate the node if the layout is sufficiently small - - {interpolated => transformedNode(forwardedProps, interpolated)} - } - + + {(interpolated) => { + const transform = `translate(${round(interpolated.x, layoutPrecision)},` + + `${round(interpolated.y, layoutPrecision)})`; + return ; + }} + ); } } diff --git a/client/app/scripts/charts/node-networks-overlay.js b/client/app/scripts/charts/node-networks-overlay.js index b8af48b36d..520f195658 100644 --- a/client/app/scripts/charts/node-networks-overlay.js +++ b/client/app/scripts/charts/node-networks-overlay.js @@ -4,40 +4,50 @@ import { List as makeList } from 'immutable'; import { getNetworkColor } from '../utils/color-utils'; import { isContrastMode } from '../utils/contrast-utils'; -// Min size is about a quarter of the width, feels about right. -const minBarWidth = 0.25; -const barHeight = 0.08; -const innerPadding = 0.04; -const borderRadius = 0.01; + +// Gap size between bar segments. +const minBarHeight = 3; +const padding = 0.05; +const rx = 1; +const ry = rx; const x = scaleBand(); -function NodeNetworksOverlay({offset, stack, networks = makeList()}) { - const barWidth = Math.max(1, minBarWidth * networks.size); - const yPosition = offset - (barHeight * 0.5); +function NodeNetworksOverlay({offset, size, stack, networks = makeList()}) { + // Min size is about a quarter of the width, feels about right. + const minBarWidth = (size / 4); + const barWidth = Math.max(size, minBarWidth * networks.size); + const barHeight = Math.max(size * 0.085, minBarHeight); // Update singleton scale. x.domain(networks.map((n, i) => i).toJS()); x.range([barWidth * -0.5, barWidth * 0.5]); - x.paddingInner(innerPadding); + x.paddingInner(padding); - const bandwidth = x.bandwidth(); const bars = networks.map((n, i) => ( )); - const translateY = stack && isContrastMode() ? 0.15 : 0; + let transform = ''; + if (stack) { + const contrastMode = isContrastMode(); + const [dx, dy] = contrastMode ? [0, 8] : [0, 0]; + transform = `translate(${dx}, ${dy * -1.5})`; + } + return ( - + {bars.toJS()} ); diff --git a/client/app/scripts/charts/node-shape-circle.js b/client/app/scripts/charts/node-shape-circle.js index cc8e52f503..9505ed0863 100644 --- a/client/app/scripts/charts/node-shape-circle.js +++ b/client/app/scripts/charts/node-shape-circle.js @@ -1,39 +1,31 @@ import React from 'react'; import classNames from 'classnames'; +import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils'; +import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles'; -import { - getMetricValue, - getMetricColor, - getClipPathDefinition, - renderMetricValue, -} from '../utils/metric-utils'; -import { - NODE_SHAPE_HIGHLIGHT_RADIUS, - NODE_SHAPE_BORDER_RADIUS, - NODE_SHAPE_SHADOW_RADIUS, -} from '../constants/styles'; - -export default function NodeShapeCircle({id, highlighted, color, metric}) { - const { height, hasMetric, formattedValue } = getMetricValue(metric); - const metricStyle = { fill: getMetricColor(metric) }; - - const className = classNames('shape', 'shape-circle', { metrics: hasMetric }); +export default function NodeShapeCircle({id, highlighted, size, color, metric}) { const clipId = `mask-${id}`; + const {height, hasMetric, formattedValue} = getMetricValue(metric, size); + const metricStyle = { fill: getMetricColor(metric) }; + const className = classNames('shape', { metrics: hasMetric }); + const fontSize = size * CANVAS_METRIC_FONT_SIZE; return ( - {hasMetric && getClipPathDefinition(clipId, height)} - {highlighted && } - - + {hasMetric && getClipPathDefinition(clipId, size, height)} + {highlighted && } + + {hasMetric && } - {renderMetricValue(formattedValue, highlighted && hasMetric)} + {highlighted && hasMetric ? + {formattedValue} : + } ); } diff --git a/client/app/scripts/charts/node-shape-cloud.js b/client/app/scripts/charts/node-shape-cloud.js index 61af2f3922..ebf5dd2b77 100644 --- a/client/app/scripts/charts/node-shape-cloud.js +++ b/client/app/scripts/charts/node-shape-cloud.js @@ -1,27 +1,46 @@ import React from 'react'; -import { - NODE_SHAPE_HIGHLIGHT_RADIUS, - NODE_SHAPE_BORDER_RADIUS, - NODE_SHAPE_SHADOW_RADIUS, - NODE_SHAPE_DOT_RADIUS, -} from '../constants/styles'; +import { extent } from 'd3-array'; -// This path is already normalized so no rescaling is needed. -const CLOUD_PATH = 'M-1.25 0.233Q-1.25 0.44-1.104 0.587-0.957 0.733-0.75 0.733H0.667Q0.908 ' - + '0.733 1.079 0.562 1.25 0.391 1.25 0.15 1.25-0.022 1.158-0.164 1.065-0.307 0.914-0.377q' - + '0.003-0.036 0.003-0.056 0-0.276-0.196-0.472-0.195-0.195-0.471-0.195-0.206 0-0.373 0.115' - + '-0.167 0.115-0.244 0.299-0.091-0.081-0.216-0.081-0.138 0-0.236 0.098-0.098 0.098-0.098 ' - + '0.236 0 0.098 0.054 0.179-0.168 0.039-0.278 0.175-0.109 0.136-0.109 0.312z'; +import { isContrastMode } from '../utils/contrast-utils'; -export default function NodeShapeCloud({highlighted, color}) { - const pathProps = r => ({ d: CLOUD_PATH, transform: `scale(${r})` }); +const CLOUD_PATH = 'M 1920,384 Q 1920,225 1807.5,112.5 1695,0 1536,0 H 448 ' + + 'Q 263,0 131.5,131.5 0,263 0,448 0,580 71,689.5 142,799 258,853 ' + + 'q -2,28 -2,43 0,212 150,362 150,150 362,150 158,0 286.5,-88 128.5,-88 ' + + '187.5,-230 70,62 166,62 106,0 181,-75 75,-75 75,-181 0,-75 -41,-138 ' + + '129,-30 213,-134.5 84,-104.5 84,-239.5 z'; + +function toPoint(stringPair) { + return stringPair.split(',').map(p => parseFloat(p, 10)); +} + +function getExtents(svgPath) { + const points = svgPath.split(' ').filter(s => s.length > 1).map(toPoint); + return [extent(points, p => p[0]), extent(points, p => p[1])]; +} + +export default function NodeShapeCloud({highlighted, size, color}) { + const [[minx, maxx], [miny, maxy]] = getExtents(CLOUD_PATH); + const width = (maxx - minx); + const height = (maxy - miny); + const cx = width / 2; + const cy = height / 2; + const pathSize = (width + height) / 2; + const baseScale = (size * 2) / pathSize; + const strokeWidth = isContrastMode() ? 6 / baseScale : 4 / baseScale; + + const pathProps = v => ({ + d: CLOUD_PATH, + fill: 'none', + transform: `scale(-${v * baseScale}) translate(-${cx},-${cy})`, + strokeWidth + }); return ( - {highlighted && } - - - + {highlighted && } + + + ); } diff --git a/client/app/scripts/charts/node-shape-heptagon.js b/client/app/scripts/charts/node-shape-heptagon.js index 074fbc3e3f..c3488ba7f2 100644 --- a/client/app/scripts/charts/node-shape-heptagon.js +++ b/client/app/scripts/charts/node-shape-heptagon.js @@ -1,41 +1,52 @@ import React from 'react'; import classNames from 'classnames'; +import { line, curveCardinalClosed } from 'd3-shape'; +import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils'; +import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles'; + + +const spline = line() + .curve(curveCardinalClosed.tension(0.65)); + + +function polygon(r, sides) { + const a = (Math.PI * 2) / sides; + const points = []; + for (let i = 0; i < sides; i += 1) { + points.push([r * Math.sin(a * i), -r * Math.cos(a * i)]); + } + return points; +} -import { nodeShapePolygon } from '../utils/node-shape-utils'; -import { - getMetricValue, - getMetricColor, - getClipPathDefinition, - renderMetricValue, -} from '../utils/metric-utils'; -import { - NODE_SHAPE_HIGHLIGHT_RADIUS, - NODE_SHAPE_BORDER_RADIUS, - NODE_SHAPE_SHADOW_RADIUS, -} from '../constants/styles'; - - -export default function NodeShapeHeptagon({ id, highlighted, color, metric }) { - const { height, hasMetric, formattedValue } = getMetricValue(metric); - const metricStyle = { fill: getMetricColor(metric) }; - const className = classNames('shape', 'shape-heptagon', { metrics: hasMetric }); - const pathProps = r => ({ d: nodeShapePolygon(r, 7) }); +export default function NodeShapeHeptagon({id, highlighted, size, color, metric}) { + const scaledSize = size * 1.0; + const pathProps = v => ({ + d: spline(polygon(scaledSize * v, 7)) + }); + const clipId = `mask-${id}`; + const {height, hasMetric, formattedValue} = getMetricValue(metric, size); + const metricStyle = { fill: getMetricColor(metric) }; + const className = classNames('shape', { metrics: hasMetric }); + const fontSize = size * CANVAS_METRIC_FONT_SIZE; + const halfSize = size * 0.5; return ( - {hasMetric && getClipPathDefinition(clipId, height)} - {highlighted && } - - + {hasMetric && getClipPathDefinition(clipId, size, height, -halfSize, halfSize - height)} + {highlighted && } + + {hasMetric && } - {renderMetricValue(formattedValue, highlighted && hasMetric)} + {highlighted && hasMetric ? + {formattedValue} : + } ); } diff --git a/client/app/scripts/charts/node-shape-hexagon.js b/client/app/scripts/charts/node-shape-hexagon.js index 511f103aee..7dba51ee2a 100644 --- a/client/app/scripts/charts/node-shape-hexagon.js +++ b/client/app/scripts/charts/node-shape-hexagon.js @@ -1,41 +1,74 @@ import React from 'react'; import classNames from 'classnames'; +import { line, curveCardinalClosed } from 'd3-shape'; +import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils'; +import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles'; + + +const spline = line() + .curve(curveCardinalClosed.tension(0.65)); + + +function getWidth(h) { + return (Math.sqrt(3) / 2) * h; +} -import { nodeShapePolygon } from '../utils/node-shape-utils'; -import { - getMetricValue, - getMetricColor, - getClipPathDefinition, - renderMetricValue, -} from '../utils/metric-utils'; -import { - NODE_SHAPE_HIGHLIGHT_RADIUS, - NODE_SHAPE_BORDER_RADIUS, - NODE_SHAPE_SHADOW_RADIUS, -} from '../constants/styles'; - - -export default function NodeShapeHexagon({ id, highlighted, color, metric }) { - const { height, hasMetric, formattedValue } = getMetricValue(metric); - const metricStyle = { fill: getMetricColor(metric) }; - const className = classNames('shape', 'shape-hexagon', { metrics: hasMetric }); - const pathProps = r => ({ d: nodeShapePolygon(r, 6) }); +function getPoints(h) { + const w = getWidth(h); + const points = [ + [w * 0.5, 0], + [w, 0.25 * h], + [w, 0.75 * h], + [w * 0.5, h], + [0, 0.75 * h], + [0, 0.25 * h] + ]; + + return spline(points); +} + + +export default function NodeShapeHexagon({id, highlighted, size, color, metric}) { + const pathProps = v => ({ + d: getPoints(size * v * 2), + transform: `translate(-${size * getWidth(v)}, -${size * v})` + }); + + const shadowSize = 0.45; + const clipId = `mask-${id}`; + const {height, hasMetric, formattedValue} = getMetricValue(metric, size); + const metricStyle = { fill: getMetricColor(metric) }; + const className = classNames('shape', { metrics: hasMetric }); + const fontSize = size * CANVAS_METRIC_FONT_SIZE; + // how much the hex curve line interpolator curves outside the original shape definition in + // percent (very roughly) + const hexCurve = 0.05; return ( - {hasMetric && getClipPathDefinition(clipId, height)} - {highlighted && } - - + {hasMetric && getClipPathDefinition( + clipId, + size * (1 + (hexCurve * 2)), + height, + -(size * hexCurve), + (size - height) * (shadowSize * 2) + )} + {highlighted && } + + {hasMetric && } - {renderMetricValue(formattedValue, highlighted && hasMetric)} + {highlighted && hasMetric ? + + {formattedValue} + : + } ); } diff --git a/client/app/scripts/charts/node-shape-square.js b/client/app/scripts/charts/node-shape-square.js index 921653bbf1..f99cfdcebb 100644 --- a/client/app/scripts/charts/node-shape-square.js +++ b/client/app/scripts/charts/node-shape-square.js @@ -1,47 +1,43 @@ import React from 'react'; import classNames from 'classnames'; +import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils'; +import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles'; -import { - getMetricValue, - getMetricColor, - getClipPathDefinition, - renderMetricValue, -} from '../utils/metric-utils'; -import { - NODE_SHAPE_HIGHLIGHT_RADIUS, - NODE_SHAPE_BORDER_RADIUS, - NODE_SHAPE_SHADOW_RADIUS, -} from '../constants/styles'; - -export default function NodeShapeSquare({ id, highlighted, color, rx = 0, ry = 0, metric }) { - const { height, hasMetric, formattedValue } = getMetricValue(metric); - const metricStyle = { fill: getMetricColor(metric) }; - - const className = classNames('shape', 'shape-square', { metrics: hasMetric }); - const rectProps = (scale, borderRadiusAdjustmentFactor = 1) => ({ - width: scale * 2, - height: scale * 2, - rx: scale * rx * borderRadiusAdjustmentFactor, - ry: scale * ry * borderRadiusAdjustmentFactor, - x: -scale, - y: -scale +export default function NodeShapeSquare({ + id, highlighted, size, color, rx = 0, ry = 0, metric +}) { + const rectProps = (scale, radiusScale) => ({ + width: scale * size * 2, + height: scale * size * 2, + rx: (radiusScale || scale) * size * rx, + ry: (radiusScale || scale) * size * ry, + x: -size * scale, + y: -size * scale }); + const clipId = `mask-${id}`; + const {height, hasMetric, formattedValue} = getMetricValue(metric, size); + const metricStyle = { fill: getMetricColor(metric) }; + const className = classNames('shape', { metrics: hasMetric }); + const fontSize = size * CANVAS_METRIC_FONT_SIZE; return ( - {hasMetric && getClipPathDefinition(clipId, height)} - {highlighted && } - - + {hasMetric && getClipPathDefinition(clipId, size, height)} + {highlighted && } + + {hasMetric && } - {renderMetricValue(formattedValue, highlighted && hasMetric)} + {highlighted && hasMetric ? + + {formattedValue} + : + } ); } diff --git a/client/app/scripts/charts/node-shape-stack.js b/client/app/scripts/charts/node-shape-stack.js index 638db478a0..1c5cd5f65f 100644 --- a/client/app/scripts/charts/node-shape-stack.js +++ b/client/app/scripts/charts/node-shape-stack.js @@ -2,22 +2,25 @@ import React from 'react'; import { isContrastMode } from '../utils/contrast-utils'; export default function NodeShapeStack(props) { - const dy = isContrastMode() ? 0.15 : 0.1; - const highlightScale = [1, 1 + dy]; - + const contrastMode = isContrastMode(); const Shape = props.shape; + const [dx, dy] = contrastMode ? [0, 8] : [0, 5]; + const dsx = (props.size + dx) / props.size; + const dsy = (props.size + dy) / props.size; + const hls = [dsx, dsy]; + return ( - - + + - + - + - + diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index dabed89e6f..1be2a0f3b3 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 402cf0b1b5..3e0d3ce900 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 dc16e0b73f..19b1aa996f 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 eecfce22ce..4bacb2d23d 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 33a40fd732..72869150f2 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']; -const GRAPH_COMPLEXITY_NODES_TRESHOLD = 100; -const ZOOM_CACHE_FIELDS = [ - 'panTranslateX', 'panTranslateY', - 'zoomScale', 'minZoomScale', 'maxZoomScale' -]; +// 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; +} + + +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.handleMouseClick = this.handleMouseClick.bind(this); + this.zoomed = this.zoomed.bind(this); + this.state = { - layoutNodes: makeMap(), - layoutEdges: makeMap(), - zoomScale: 0, - minZoomScale: 0, - maxZoomScale: 0, + edges: makeMap(), + nodes: makeMap(), + nodeScale: scaleLinear(), panTranslateX: 0, panTranslateY: 0, - selectedScale: 1, + scale: 1, + selectedNodeScale: scaleLinear(), + hasZoomed: false, 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() { - 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; + }); - cacheZoomState(state) { - const zoomState = pick(state, ZOOM_CACHE_FIELDS); - const zoomCache = assign({}, state.zoomCache); - zoomCache[zoomCacheKey(this.props)] = zoomState; - return { zoomCache }; + return { edges, nodes }; } - 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)); + updateGraphState(props, state) { + if (props.nodes.size === 0) { + return { + nodes: makeMap(), + edges: makeMap() + }; + } + + 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 635fb80920..efd3565414 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 9a47dc7dfb..49b22688db 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 e24d707705..0000000000 --- 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 e7d6a16cfd..909b9a3118 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 c2167fd660..1180cf9d44 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 3248c2eb68..31a1532a97 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 15ad07f0f8..0000000000 --- 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 e5c8a8e73b..0000000000 --- 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 ca0db11dd4..0000000000 --- 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 ab46b1a961..6d5f953987 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 578d837609..401bf76b81 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 f27a567604..5acf724ae9 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 45dbf6b2b2..0000000000 --- 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 9a74856413..5588c7e696 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 aaaea5f165..22601a7d83 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 a62c5de5f3..adcc592566 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 14c434d069..8e28a72e2a 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;