diff --git a/client/app/scripts/charts/__tests__/node-layout-test.js b/client/app/scripts/charts/__tests__/node-layout-test.js index ec06b6106..a9fcb99e0 100644 --- a/client/app/scripts/charts/__tests__/node-layout-test.js +++ b/client/app/scripts/charts/__tests__/node-layout-test.js @@ -167,54 +167,6 @@ 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 81c1d2bb0..d33241b0c 100644 --- a/client/app/scripts/charts/edge-container.js +++ b/client/app/scripts/charts/edge-container.js @@ -5,106 +5,102 @@ 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_CAP = 8; +const WAYPOINTS_COUNT = 8; const spline = line() .curve(curveBasis) .x(d => d.x) .y(d => d.y); -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); +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; }); - return extracted; + return waypointsArray; }; -class EdgeContainer extends React.Component { +class EdgeContainer extends React.Component { constructor(props, context) { super(props, context); - this.state = { - pointsMap: makeMap() - }; + this.state = { waypointsMap: makeMap() }; } componentWillMount() { - this.preparePoints(this.props.points); + if (this.props.isAnimated) { + this.prepareWaypointsForMotion(this.props.waypoints); + } } componentWillReceiveProps(nextProps) { // immutablejs allows us to `===`! \o/ - if (nextProps.points !== this.props.points) { - this.preparePoints(nextProps.points); + if (this.props.isAnimated && nextProps.waypoints !== this.props.waypoints) { + this.prepareWaypointsForMotion(nextProps.waypoints); } } render() { - const { layoutPrecision, points } = this.props; - const other = omit(this.props, 'points'); + const { isAnimated, waypoints } = this.props; + const forwardedProps = omit(this.props, 'isAnimated', 'waypoints'); - if (layoutPrecision === 0) { - const path = spline(points.toJS()); - return ; + if (!isAnimated) { + return transformedEdge(forwardedProps, waypoints.toJS()); } return ( - - {(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 ; - }} + // 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))} ); } - preparePoints(nextPoints) { - nextPoints = nextPoints.toJS(); + prepareWaypointsForMotion(nextWaypoints) { + nextWaypoints = nextWaypoints.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_CAP` that + // the waypoints array given by dagre to the fixed size of `WAYPOINTS_COUNT` that // Motion could take over. - const pointsMissing = WAYPOINTS_CAP - nextPoints.length; - if (pointsMissing > 0) { + const waypointsMissing = WAYPOINTS_COUNT - nextWaypoints.length; + if (waypointsMissing > 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. - nextPoints = times(pointsMissing, constant(nextPoints[0])).concat(nextPoints); - } else if (pointsMissing < 0) { + nextWaypoints = times(waypointsMissing, constant(nextWaypoints[0])).concat(nextWaypoints); + } else if (waypointsMissing < 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. - nextPoints = uniformSelect(nextPoints, WAYPOINTS_CAP); + nextWaypoints = uniformSelect(nextWaypoints, WAYPOINTS_COUNT); } - 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)); + 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)); }); - this.setState({ pointsMap }); + this.setState({ waypointsMap }); } - } export default connect()(EdgeContainer); diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index ef33a5f1a..1deab2f04 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -3,6 +3,8 @@ 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 { @@ -13,15 +15,19 @@ class Edge extends React.Component { } render() { - const { id, path, highlighted, blurred, focused } = this.props; - const className = classNames('edge', {highlighted, blurred, focused}); + 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; + // Draws the edge so that its thickness reflects the zoom scale. + // Edge shadow is always made 10x thicker than the edge itself. return ( - - + id={id} className={className} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave}> + + ); } diff --git a/client/app/scripts/charts/node-container.js b/client/app/scripts/charts/node-container.js index 4faa3ae1d..8f5817026 100644 --- a/client/app/scripts/charts/node-container.js +++ b/client/app/scripts/charts/node-container.js @@ -3,29 +3,39 @@ import { omit } from 'lodash'; import { connect } from 'react-redux'; import { Motion, spring } from 'react-motion'; -import { round } from '../utils/math-utils'; +import { NODES_SPRING_ANIMATION_CONFIG } from '../constants/animation'; +import { NODE_BLUR_OPACITY } from '../constants/styles'; import Node from './node'; +const transformedNode = (otherProps, { x, y, k }) => ( + +); + class NodeContainer extends React.Component { render() { - 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'); + const { dx, dy, isAnimated, scale, blurred } = this.props; + const forwardedProps = omit(this.props, 'dx', 'dy', 'isAnimated', 'scale', 'blurred'); + const opacity = blurred ? NODE_BLUR_OPACITY : 1; + // NOTE: Controlling blurring from here seems to re-render faster + // than adding a CSS class and controlling it from there. return ( - - {(interpolated) => { - const transform = `translate(${round(interpolated.x, layoutPrecision)},` - + `${round(interpolated.y, layoutPrecision)})`; - 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)} + } + ); } } diff --git a/client/app/scripts/charts/node-networks-overlay.js b/client/app/scripts/charts/node-networks-overlay.js index 520f19565..ae1b45710 100644 --- a/client/app/scripts/charts/node-networks-overlay.js +++ b/client/app/scripts/charts/node-networks-overlay.js @@ -3,51 +3,43 @@ import { scaleBand } from 'd3-scale'; import { List as makeList } from 'immutable'; import { getNetworkColor } from '../utils/color-utils'; import { isContrastMode } from '../utils/contrast-utils'; +import { NODE_BASE_SIZE } from '../constants/styles'; - -// Gap size between bar segments. -const minBarHeight = 3; -const padding = 0.05; -const rx = 1; -const ry = rx; +// 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; +const offset = 0.67; const x = scaleBand(); -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); +function NodeNetworksOverlay({ stack, networks = makeList() }) { + const barWidth = Math.max(1, minBarWidth * networks.size); + const yPosition = offset - (barHeight * 0.5); // Update singleton scale. x.domain(networks.map((n, i) => i).toJS()); x.range([barWidth * -0.5, barWidth * 0.5]); - x.paddingInner(padding); + x.paddingInner(innerPadding); + const bandwidth = x.bandwidth(); const bars = networks.map((n, i) => ( )); - let transform = ''; - if (stack) { - const contrastMode = isContrastMode(); - const [dx, dy] = contrastMode ? [0, 8] : [0, 0]; - transform = `translate(${dx}, ${dy * -1.5})`; - } - + const translateY = stack && isContrastMode() ? 0.15 : 0; return ( - + {bars.toJS()} ); diff --git a/client/app/scripts/charts/node-shape-circle.js b/client/app/scripts/charts/node-shape-circle.js index 9505ed086..cc8e52f50 100644 --- a/client/app/scripts/charts/node-shape-circle.js +++ b/client/app/scripts/charts/node-shape-circle.js @@ -1,31 +1,39 @@ 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, size, color, metric}) { - const clipId = `mask-${id}`; - const {height, hasMetric, formattedValue} = getMetricValue(metric, size); +export default function NodeShapeCircle({id, highlighted, color, metric}) { + const { height, hasMetric, formattedValue } = getMetricValue(metric); const metricStyle = { fill: getMetricColor(metric) }; - const className = classNames('shape', { metrics: hasMetric }); - const fontSize = size * CANVAS_METRIC_FONT_SIZE; + + const className = classNames('shape', 'shape-circle', { metrics: hasMetric }); + const clipId = `mask-${id}`; return ( - {hasMetric && getClipPathDefinition(clipId, size, height)} - {highlighted && } - - + {hasMetric && getClipPathDefinition(clipId, height)} + {highlighted && } + + {hasMetric && } - {highlighted && hasMetric ? - {formattedValue} : - } + {renderMetricValue(formattedValue, highlighted && hasMetric)} ); } diff --git a/client/app/scripts/charts/node-shape-cloud.js b/client/app/scripts/charts/node-shape-cloud.js index ebf5dd2b7..3278ea651 100644 --- a/client/app/scripts/charts/node-shape-cloud.js +++ b/client/app/scripts/charts/node-shape-cloud.js @@ -1,46 +1,28 @@ import React from 'react'; -import { extent } from 'd3-array'; +import { + NODE_SHAPE_HIGHLIGHT_RADIUS, + NODE_SHAPE_BORDER_RADIUS, + NODE_SHAPE_SHADOW_RADIUS, + NODE_SHAPE_DOT_RADIUS, + NODE_BASE_SIZE, +} from '../constants/styles'; -import { isContrastMode } from '../utils/contrast-utils'; +// This path is already normalized so no dynamic rescaling is needed. +const CLOUD_PATH = 'M-125 23.333Q-125 44.036-110.352 58.685-95.703 73.333-75 73.333H66.667Q90.755 ' + + '73.333 107.878 56.211 125 39.089 125 15 125-2.188 115.755-16.445 106.51-30.703 91.406-37.734q' + + '0.26-3.646 0.261-5.599 0-27.604-19.532-47.136-19.531-19.531-47.135-19.531-20.573 0-37.305 ' + + '11.458-16.732 11.458-24.414 29.948-9.115-8.073-21.614-8.073-13.802 0-23.568 9.766-9.766 9.766-' + + '9.766 23.568 0 9.766 5.339 17.968-16.797 3.906-27.735 17.513-10.938 13.607-10.937 31.185z'; -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 - }); +export default function NodeShapeCloud({highlighted, color}) { + const pathProps = r => ({ d: CLOUD_PATH, transform: `scale(${r / NODE_BASE_SIZE})` }); return ( - {highlighted && } - - - + {highlighted && } + + + ); } diff --git a/client/app/scripts/charts/node-shape-heptagon.js b/client/app/scripts/charts/node-shape-heptagon.js index c3488ba7f..074fbc3e3 100644 --- a/client/app/scripts/charts/node-shape-heptagon.js +++ b/client/app/scripts/charts/node-shape-heptagon.js @@ -1,52 +1,41 @@ 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'; + +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'; -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; -} - - -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); +export default function NodeShapeHeptagon({ id, highlighted, color, metric }) { + const { height, hasMetric, formattedValue } = getMetricValue(metric); const metricStyle = { fill: getMetricColor(metric) }; - const className = classNames('shape', { metrics: hasMetric }); - const fontSize = size * CANVAS_METRIC_FONT_SIZE; - const halfSize = size * 0.5; + + const className = classNames('shape', 'shape-heptagon', { metrics: hasMetric }); + const pathProps = r => ({ d: nodeShapePolygon(r, 7) }); + const clipId = `mask-${id}`; return ( - {hasMetric && getClipPathDefinition(clipId, size, height, -halfSize, halfSize - height)} - {highlighted && } - - + {hasMetric && getClipPathDefinition(clipId, height)} + {highlighted && } + + {hasMetric && } - {highlighted && hasMetric ? - {formattedValue} : - } + {renderMetricValue(formattedValue, highlighted && hasMetric)} ); } diff --git a/client/app/scripts/charts/node-shape-hexagon.js b/client/app/scripts/charts/node-shape-hexagon.js index 7dba51ee2..511f103ae 100644 --- a/client/app/scripts/charts/node-shape-hexagon.js +++ b/client/app/scripts/charts/node-shape-hexagon.js @@ -1,74 +1,41 @@ 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'; + +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'; -const spline = line() - .curve(curveCardinalClosed.tension(0.65)); - - -function getWidth(h) { - return (Math.sqrt(3) / 2) * h; -} - - -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); +export default function NodeShapeHexagon({ id, highlighted, color, metric }) { + const { height, hasMetric, formattedValue } = getMetricValue(metric); 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; + + const className = classNames('shape', 'shape-hexagon', { metrics: hasMetric }); + const pathProps = r => ({ d: nodeShapePolygon(r, 6) }); + const clipId = `mask-${id}`; return ( - {hasMetric && getClipPathDefinition( - clipId, - size * (1 + (hexCurve * 2)), - height, - -(size * hexCurve), - (size - height) * (shadowSize * 2) - )} - {highlighted && } - - + {hasMetric && getClipPathDefinition(clipId, height)} + {highlighted && } + + {hasMetric && } - {highlighted && hasMetric ? - - {formattedValue} - : - } + {renderMetricValue(formattedValue, highlighted && hasMetric)} ); } diff --git a/client/app/scripts/charts/node-shape-square.js b/client/app/scripts/charts/node-shape-square.js index f99cfdceb..921653bbf 100644 --- a/client/app/scripts/charts/node-shape-square.js +++ b/client/app/scripts/charts/node-shape-square.js @@ -1,43 +1,47 @@ 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, 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); +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', { metrics: hasMetric }); - const fontSize = size * CANVAS_METRIC_FONT_SIZE; + + 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 + }); + const clipId = `mask-${id}`; return ( - {hasMetric && getClipPathDefinition(clipId, size, height)} - {highlighted && } - - + {hasMetric && getClipPathDefinition(clipId, height)} + {highlighted && } + + {hasMetric && } - {highlighted && hasMetric ? - - {formattedValue} - : - } + {renderMetricValue(formattedValue, highlighted && hasMetric)} ); } diff --git a/client/app/scripts/charts/node-shape-stack.js b/client/app/scripts/charts/node-shape-stack.js index 1c5cd5f65..638db478a 100644 --- a/client/app/scripts/charts/node-shape-stack.js +++ b/client/app/scripts/charts/node-shape-stack.js @@ -2,25 +2,22 @@ import React from 'react'; import { isContrastMode } from '../utils/contrast-utils'; export default function NodeShapeStack(props) { - 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]; + const dy = isContrastMode() ? 0.15 : 0.1; + const highlightScale = [1, 1 + dy]; + const Shape = props.shape; return ( - - + + - + - + - + diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index 1be2a0f3b..9cd392749 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -7,6 +7,7 @@ import { clickNode, enterNode, leaveNode } from '../actions/app-actions'; import { getNodeColor } from '../utils/color-utils'; import MatchedText from '../components/matched-text'; import MatchedResults from '../components/matched-results'; +import { NODE_BASE_SIZE } from '../constants/styles'; import NodeShapeCircle from './node-shape-circle'; import NodeShapeStack from './node-shape-stack'; @@ -15,18 +16,9 @@ import NodeShapeHexagon from './node-shape-hexagon'; import NodeShapeHeptagon from './node-shape-heptagon'; import NodeShapeCloud from './node-shape-cloud'; import NodeNetworksOverlay from './node-networks-overlay'; -import { MIN_NODE_LABEL_SIZE, BASE_NODE_LABEL_SIZE, BASE_NODE_SIZE } from '../constants/styles'; -function labelFontSize(nodeSize) { - return Math.max(MIN_NODE_LABEL_SIZE, (BASE_NODE_LABEL_SIZE / BASE_NODE_SIZE) * nodeSize); -} - -function stackedShape(Shape) { - const factory = React.createFactory(NodeShapeStack); - return props => factory(Object.assign({}, props, {shape: Shape})); -} - +const labelWidth = 1.4 * NODE_BASE_SIZE; const nodeShapes = { circle: NodeShapeCircle, hexagon: NodeShapeHexagon, @@ -35,6 +27,11 @@ const nodeShapes = { cloud: NodeShapeCloud }; +function stackedShape(Shape) { + const factory = React.createFactory(NodeShapeStack); + return props => factory(Object.assign({}, props, {shape: Shape})); +} + function getNodeShape({ shape, stack }) { const nodeShape = nodeShapes[shape]; if (!nodeShape) { @@ -43,58 +40,76 @@ function getNodeShape({ shape, stack }) { return stack ? stackedShape(nodeShape) : nodeShape; } -function svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) { - return ( - - {label} - - {subLabel} - - - ); -} class Node extends React.Component { - constructor(props, context) { super(props, context); - this.handleMouseClick = this.handleMouseClick.bind(this); - this.handleMouseEnter = this.handleMouseEnter.bind(this); - this.handleMouseLeave = this.handleMouseLeave.bind(this); - this.saveShapeRef = this.saveShapeRef.bind(this); this.state = { hovered: false, matched: false }; + + this.handleMouseClick = this.handleMouseClick.bind(this); + this.handleMouseEnter = this.handleMouseEnter.bind(this); + this.handleMouseLeave = this.handleMouseLeave.bind(this); + this.saveShapeRef = this.saveShapeRef.bind(this); } componentWillReceiveProps(nextProps) { // marks as matched only when search query changes if (nextProps.searchQuery !== this.props.searchQuery) { - this.setState({ - matched: nextProps.matched - }); + this.setState({ matched: nextProps.matched }); } else { - this.setState({ - matched: false - }); + this.setState({ matched: false }); } } + renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) { + const { label, subLabel } = this.props; + return ( + + {label} + + {subLabel} + + + ); + } + + renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents) { + const { label, subLabel, blurred, matches = makeMap() } = this.props; + const matchedMetadata = matches.get('metadata', makeList()); + const matchedParents = matches.get('parents', makeList()); + const matchedNodeDetails = matchedMetadata.concat(matchedParents); + + return ( + +
+
+ +
+
+ +
+ {!blurred && } +
+
+ ); + } + render() { - const { blurred, focused, highlighted, label, matches = makeMap(), networks, - pseudo, rank, subLabel, scaleFactor, transform, exportingGraph, - showingNetworks, stack } = this.props; + const { blurred, focused, highlighted, networks, pseudo, rank, label, + transform, exportingGraph, showingNetworks, stack } = this.props; const { hovered, matched } = this.state; - const nodeScale = focused ? this.props.selectedNodeScale : this.props.nodeScale; const color = getNodeColor(rank, label, pseudo); const truncate = !focused && !hovered; - const labelWidth = nodeScale(scaleFactor * 3); - const labelOffsetX = -labelWidth / 2; - const labelDy = (showingNetworks && networks) ? 0.70 : 0.55; - const labelOffsetY = nodeScale(labelDy * scaleFactor); - const networkOffset = nodeScale(scaleFactor * 0.67); + const labelOffsetY = (showingNetworks && networks) ? 40 : 28; const nodeClassName = classnames('node', { highlighted, @@ -109,53 +124,23 @@ class Node extends React.Component { const NodeShapeType = getNodeShape(this.props); const useSvgLabels = exportingGraph; - const size = nodeScale(scaleFactor); - const fontSize = labelFontSize(size); const mouseEvents = { onClick: this.handleMouseClick, onMouseEnter: this.handleMouseEnter, onMouseLeave: this.handleMouseLeave, }; - const matchedNodeDetails = matches.get('metadata', makeList()) - .concat(matches.get('parents', makeList())); return ( - {useSvgLabels ? - - svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) : - - -
-
- -
-
- -
- {!blurred && } -
-
} + this.renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) : + this.renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents)} - + - {showingNetworks && } + {showingNetworks && }
); } diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index 3e0d3ce90..402cf0b1b 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -7,9 +7,9 @@ import EdgeContainer from './edge-container'; class NodesChartEdges extends React.Component { render() { - const { hasSelectedNode, highlightedEdgeIds, layoutEdges, - layoutPrecision, searchNodeMatches = makeMap(), searchQuery, - selectedNodeId, selectedNetwork, selectedNetworkNodes } = this.props; + const { hasSelectedNode, highlightedEdgeIds, layoutEdges, searchQuery, + isAnimated, selectedScale, selectedNodeId, selectedNetwork, selectedNetworkNodes, + searchNodeMatches = makeMap() } = this.props; return ( @@ -35,10 +35,11 @@ class NodesChartEdges extends React.Component { id={edge.get('id')} source={edge.get('source')} target={edge.get('target')} - points={edge.get('points')} + waypoints={edge.get('points')} + scale={focused ? selectedScale : 1} + isAnimated={isAnimated} blurred={blurred} focused={focused} - layoutPrecision={layoutPrecision} highlighted={highlighted} /> ); diff --git a/client/app/scripts/charts/nodes-chart-elements.js b/client/app/scripts/charts/nodes-chart-elements.js index 19b1aa996..dc16e0b73 100644 --- a/client/app/scripts/charts/nodes-chart-elements.js +++ b/client/app/scripts/charts/nodes-chart-elements.js @@ -12,13 +12,12 @@ class NodesChartElements extends React.Component { + selectedScale={props.selectedScale} + isAnimated={props.isAnimated} /> + selectedScale={props.selectedScale} + isAnimated={props.isAnimated} /> ); } diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index 4bacb2d23..9ce3c1181 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -7,12 +7,9 @@ import NodeContainer from './node-container'; class NodesChartNodes extends React.Component { render() { - const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision, - mouseOverNodeId, nodeScale, scale, searchNodeMatches = makeMap(), - searchQuery, selectedMetric, selectedNetwork, selectedNodeScale, selectedNodeId, - topCardNode } = this.props; - - const zoomScale = scale; + const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId, + selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId, + topCardNode, searchNodeMatches = makeMap() } = this.props; // highlighter functions const setHighlighted = node => node.set('highlighted', @@ -73,12 +70,11 @@ class NodesChartNodes extends React.Component { subLabel={node.get('subLabel')} metric={metric(node)} rank={node.get('rank')} - layoutPrecision={layoutPrecision} - selectedNodeScale={selectedNodeScale} - nodeScale={nodeScale} - zoomScale={zoomScale} + isAnimated={isAnimated} + scale={node.get('focused') ? selectedScale : 1} dx={node.get('x')} - dy={node.get('y')} />)} + dy={node.get('y')} + />)} ); } diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 72869150f..33a40fd73 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -1,272 +1,63 @@ -import debug from 'debug'; import React from 'react'; import { connect } from 'react-redux'; -import { assign, pick, includes } from 'lodash'; -import { Map as makeMap, fromJS } from 'immutable'; -import timely from 'timely'; +import { assign, pick } from 'lodash'; +import { Map as makeMap } from 'immutable'; -import { scaleThreshold, scaleLinear } from 'd3-scale'; import { event as d3Event, select } from 'd3-selection'; import { zoom, zoomIdentity } from 'd3-zoom'; import { nodeAdjacenciesSelector, adjacentNodesSelector } from '../selectors/chartSelectors'; import { clickBackground } from '../actions/app-actions'; -import { EDGE_ID_SEPARATOR } from '../constants/naming'; -import { MIN_NODE_SIZE, DETAILS_PANEL_WIDTH, MAX_NODE_SIZE } from '../constants/styles'; import Logo from '../components/logo'; -import { doLayout } from './nodes-layout'; import NodesChartElements from './nodes-chart-elements'; -import { getActiveTopologyOptions } from '../utils/topology-utils'; +import { getActiveTopologyOptions, zoomCacheKey } from '../utils/topology-utils'; -const log = debug('scope:nodes-chart'); - -const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY']; - -// make sure circular layouts a bit denser with 3-6 nodes -const radiusDensity = scaleThreshold() - .domain([3, 6]) - .range([2.5, 3.5, 3]); - -/** - * dynamic coords precision based on topology size - */ -function getLayoutPrecision(nodesCount) { - let precision; - if (nodesCount >= 50) { - precision = 0; - } else if (nodesCount > 20) { - precision = 1; - } else if (nodesCount > 10) { - precision = 2; - } else { - precision = 3; - } - - return precision; -} +import { topologyZoomState } from '../selectors/nodes-chart-zoom'; +import { layoutWithSelectedNode } from '../selectors/nodes-chart-focus'; +import { graphLayout } from '../selectors/nodes-chart-layout'; -function initEdges(nodes) { - let edges = makeMap(); - - nodes.forEach((node, nodeId) => { - const adjacency = node.get('adjacency'); - if (adjacency) { - adjacency.forEach((adjacent) => { - const edge = [nodeId, adjacent]; - const edgeId = edge.join(EDGE_ID_SEPARATOR); - - if (!edges.has(edgeId)) { - const source = edge[0]; - const target = edge[1]; - if (nodes.has(source) && nodes.has(target)) { - edges = edges.set(edgeId, makeMap({ - id: edgeId, - value: 1, - source, - target - })); - } - } - }); - } - }); - - return edges; -} - - -function getNodeScale(nodesCount, width, height) { - const expanse = Math.min(height, width); - const nodeSize = expanse / 3; // single node should fill a third of the screen - const maxNodeSize = Math.min(MAX_NODE_SIZE, expanse / 10); - const normalizedNodeSize = Math.max(MIN_NODE_SIZE, - Math.min(nodeSize / Math.sqrt(nodesCount), maxNodeSize)); - - return scaleLinear().range([0, normalizedNodeSize]); -} - - -function updateLayout(width, height, nodes, baseOptions) { - const nodeScale = getNodeScale(nodes.size, width, height); - const edges = initEdges(nodes); - - const options = Object.assign({}, baseOptions, { - scale: nodeScale, - }); - - const timedLayouter = timely(doLayout); - const graph = timedLayouter(nodes, edges, options); - - log(`graph layout took ${timedLayouter.time}ms`); - - const layoutNodes = graph.nodes.map(node => makeMap({ - x: node.get('x'), - y: node.get('y'), - // extract coords and save for restore - px: node.get('x'), - py: node.get('y') - })); - - const layoutEdges = graph.edges - .map(edge => edge.set('ppoints', edge.get('points'))); - - return { layoutNodes, layoutEdges, layoutWidth: graph.width, layoutHeight: graph.height }; -} - - -function centerSelectedNode(props, state) { - let stateNodes = state.nodes; - let stateEdges = state.edges; - if (!stateNodes.has(props.selectedNodeId)) { - return {}; - } - - const adjacentNodes = props.adjacentNodes; - const adjacentLayoutNodeIds = []; - - adjacentNodes.forEach((adjacentId) => { - // filter loopback - if (adjacentId !== props.selectedNodeId) { - adjacentLayoutNodeIds.push(adjacentId); - } - }); - - // move origin node to center of viewport - const zoomScale = state.scale; - const translate = [state.panTranslateX, state.panTranslateY]; - const viewportHalfWidth = ((state.width + props.margins.left) - DETAILS_PANEL_WIDTH) / 2; - const viewportHalfHeight = (state.height + props.margins.top) / 2; - const centerX = (-translate[0] + viewportHalfWidth) / zoomScale; - const centerY = (-translate[1] + viewportHalfHeight) / zoomScale; - stateNodes = stateNodes.mergeIn([props.selectedNodeId], { - x: centerX, - y: centerY - }); - - // circle layout for adjacent nodes - const adjacentCount = adjacentLayoutNodeIds.length; - const density = radiusDensity(adjacentCount); - const radius = Math.min(state.width, state.height) / density / zoomScale; - const offsetAngle = Math.PI / 4; - - stateNodes = stateNodes.map((node, nodeId) => { - const index = adjacentLayoutNodeIds.indexOf(nodeId); - if (index > -1) { - const angle = offsetAngle + ((Math.PI * 2 * index) / adjacentCount); - return node.merge({ - x: centerX + (radius * Math.sin(angle)), - y: centerY + (radius * Math.cos(angle)) - }); - } - return node; - }); - - // fix all edges for circular nodes - stateEdges = stateEdges.map((edge) => { - if (edge.get('source') === props.selectedNodeId - || edge.get('target') === props.selectedNodeId - || includes(adjacentLayoutNodeIds, edge.get('source')) - || includes(adjacentLayoutNodeIds, edge.get('target'))) { - const source = stateNodes.get(edge.get('source')); - const target = stateNodes.get(edge.get('target')); - return edge.set('points', fromJS([ - {x: source.get('x'), y: source.get('y')}, - {x: target.get('x'), y: target.get('y')} - ])); - } - return edge; - }); - - // auto-scale node size for selected nodes - const selectedNodeScale = getNodeScale(adjacentNodes.size, state.width, state.height); - - return { - selectedNodeScale, - edges: stateEdges, - nodes: stateNodes - }; -} +const GRAPH_COMPLEXITY_NODES_TRESHOLD = 100; +const ZOOM_CACHE_FIELDS = [ + 'panTranslateX', 'panTranslateY', + 'zoomScale', 'minZoomScale', 'maxZoomScale' +]; class NodesChart extends React.Component { - constructor(props, context) { super(props, context); - this.handleMouseClick = this.handleMouseClick.bind(this); - this.zoomed = this.zoomed.bind(this); - this.state = { - edges: makeMap(), - nodes: makeMap(), - nodeScale: scaleLinear(), + layoutNodes: makeMap(), + layoutEdges: makeMap(), + zoomScale: 0, + minZoomScale: 0, + maxZoomScale: 0, panTranslateX: 0, panTranslateY: 0, - scale: 1, - selectedNodeScale: scaleLinear(), - hasZoomed: false, + selectedScale: 1, height: props.height || 0, width: props.width || 0, + // TODO: Move zoomCache to global Redux state. Now that we store + // it here, it gets reset every time the component gets destroyed. + // That happens e.g. when we switch to a grid mode in one topology, + // which resets the zoom cache across all topologies, which is bad. zoomCache: {}, }; + + this.handleMouseClick = this.handleMouseClick.bind(this); + this.zoomed = this.zoomed.bind(this); } componentWillMount() { - const state = this.updateGraphState(this.props, this.state); - this.setState(state); - } - - componentWillReceiveProps(nextProps) { - // gather state, setState should be called only once here - const state = assign({}, this.state); - - // wipe node states when showing different topology - if (nextProps.topologyId !== this.props.topologyId) { - // re-apply cached canvas zoom/pan to d3 behavior (or set the default values) - const defaultZoom = { scale: 1, panTranslateX: 0, panTranslateY: 0, hasZoomed: false }; - const nextZoom = this.state.zoomCache[nextProps.topologyId] || defaultZoom; - if (nextZoom) { - this.setZoom(nextZoom); - } - - // saving previous zoom state - const prevZoom = pick(this.state, ZOOM_CACHE_FIELDS); - const zoomCache = assign({}, this.state.zoomCache); - zoomCache[this.props.topologyId] = prevZoom; - - // clear canvas and apply zoom state - assign(state, nextZoom, { zoomCache }, { - nodes: makeMap(), - edges: makeMap() - }); - } - - // reset layout dimensions only when forced - state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height); - state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width); - - if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) { - assign(state, this.updateGraphState(nextProps, state)); - } - - if (this.props.selectedNodeId !== nextProps.selectedNodeId) { - assign(state, this.restoreLayout(state)); - } - if (nextProps.selectedNodeId) { - assign(state, centerSelectedNode(nextProps, state)); - } - - this.setState(state); + this.setState(graphLayout(this.state, this.props)); } componentDidMount() { // distinguish pan/zoom from click this.isZooming = false; - - this.zoom = zoom() - .scaleExtent([0.1, 2]) - .on('zoom', this.zoomed); + this.zoom = zoom().on('zoom', this.zoomed); this.svg = select('.nodes-chart svg'); this.svg.call(this.zoom); @@ -282,15 +73,40 @@ class NodesChart extends React.Component { .on('touchstart.zoom', null); } + componentWillReceiveProps(nextProps) { + // Don't modify the original state, as we only want to call setState once at the end. + const state = assign({}, this.state); + + // Reset layout dimensions only when forced (to prevent excessive rendering on resizing). + state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height); + state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width); + + // Update the state with memoized graph layout information based on props nodes and edges. + assign(state, graphLayout(state, nextProps)); + + // Now that we have the graph layout information, we use it to create a default zoom + // settings for the current topology if we are rendering its layout for the first time, or + // otherwise we use the cached zoom information from local state for this topology layout. + assign(state, topologyZoomState(state, nextProps)); + + // Finally we update the layout state with the circular + // subgraph centered around the selected node (if there is one). + if (nextProps.selectedNodeId) { + assign(state, layoutWithSelectedNode(state, nextProps)); + } + + this.applyZoomState(state); + this.setState(state); + } + render() { - const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state; + // Not passing transform into child components for perf reasons. + const { panTranslateX, panTranslateY, zoomScale } = this.state; + const transform = `translate(${panTranslateX}, ${panTranslateY}) scale(${zoomScale})`; - // not passing translates into child components for perf reasons, use getTranslate instead - const translate = [panTranslateX, panTranslateY]; - const transform = `translate(${translate}) scale(${scale})`; const svgClassNames = this.props.isEmpty ? 'hide' : ''; + const isAnimated = !this.isTopologyGraphComplex(); - const layoutPrecision = getLayoutPrecision(nodes.size); return (
+ isAnimated={isAnimated} />
); @@ -320,81 +134,39 @@ class NodesChart extends React.Component { } } - restoreLayout(state) { - // undo any pan/zooming that might have happened - this.setZoom(state); - - const nodes = state.nodes.map(node => node.merge({ - x: node.get('px'), - y: node.get('py') - })); - - const edges = state.edges.map((edge) => { - if (edge.has('ppoints')) { - return edge.set('points', edge.get('ppoints')); - } - return edge; - }); - - return { edges, nodes }; + isTopologyGraphComplex() { + return this.state.layoutNodes.size > GRAPH_COMPLEXITY_NODES_TRESHOLD; } - updateGraphState(props, state) { - if (props.nodes.size === 0) { - return { - nodes: makeMap(), - edges: makeMap() - }; - } + cacheZoomState(state) { + const zoomState = pick(state, ZOOM_CACHE_FIELDS); + const zoomCache = assign({}, state.zoomCache); + zoomCache[zoomCacheKey(this.props)] = zoomState; + return { zoomCache }; + } - const options = { - width: state.width, - height: state.height, - margins: props.margins, - forceRelayout: props.forceRelayout, - topologyId: props.topologyId, - topologyOptions: props.topologyOptions, - }; - - const { layoutNodes, layoutEdges, layoutWidth, layoutHeight } = updateLayout( - state.width, state.height, props.nodes, options); - // - // adjust layout based on viewport - const xFactor = (state.width - props.margins.left - props.margins.right) / layoutWidth; - const yFactor = state.height / layoutHeight; - const zoomFactor = Math.min(xFactor, yFactor); - let zoomScale = state.scale; - - if (this.svg && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) { - zoomScale = zoomFactor; - } - - return { - scale: zoomScale, - nodes: layoutNodes, - edges: layoutEdges, - nodeScale: getNodeScale(props.nodes.size, state.width, state.height), - }; + applyZoomState({ zoomScale, minZoomScale, maxZoomScale, panTranslateX, panTranslateY }) { + this.zoom = this.zoom.scaleExtent([minZoomScale, maxZoomScale]); + this.svg.call(this.zoom.transform, zoomIdentity + .translate(panTranslateX, panTranslateY) + .scale(zoomScale)); } zoomed() { this.isZooming = true; - // dont pan while node is selected + // don't pan while node is selected if (!this.props.selectedNodeId) { - this.setState({ - hasZoomed: true, + let state = assign({}, this.state, { panTranslateX: d3Event.transform.x, panTranslateY: d3Event.transform.y, - scale: d3Event.transform.k + zoomScale: d3Event.transform.k }); + // Cache the zoom state as soon as it changes as it is cheap, and makes us + // be able to skip difficult conditions on when this caching should happen. + state = assign(state, this.cacheZoomState(state)); + this.setState(state); } } - - setZoom(newZoom) { - this.svg.call(this.zoom.transform, zoomIdentity - .translate(newZoom.panTranslateX, newZoom.panTranslateY) - .scale(newZoom.scale)); - } } @@ -405,7 +177,7 @@ function mapStateToProps(state) { forceRelayout: state.get('forceRelayout'), selectedNodeId: state.get('selectedNodeId'), topologyId: state.get('currentTopologyId'), - topologyOptions: getActiveTopologyOptions(state) + topologyOptions: getActiveTopologyOptions(state), }; } diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index efd356541..635fb8092 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -2,6 +2,7 @@ import dagre from 'dagre'; import debug from 'debug'; import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable'; +import { NODE_BASE_SIZE } from '../constants/styles'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; import { featureIsEnabledAny } from '../utils/feature-utils'; import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils'; @@ -12,10 +13,9 @@ const topologyCaches = {}; export const DEFAULT_WIDTH = 800; export const DEFAULT_HEIGHT = DEFAULT_WIDTH / 2; export const DEFAULT_MARGINS = {top: 0, left: 0}; -const DEFAULT_SCALE = val => val * 2; -const NODE_SIZE_FACTOR = 1; -const NODE_SEPARATION_FACTOR = 2.0; -const RANK_SEPARATION_FACTOR = 3.0; +const NODE_SIZE_FACTOR = NODE_BASE_SIZE; +const NODE_SEPARATION_FACTOR = 2 * NODE_BASE_SIZE; +const RANK_SEPARATION_FACTOR = 3 * NODE_BASE_SIZE; let layoutRuns = 0; let layoutRunsTrivial = 0; @@ -34,19 +34,16 @@ function fromGraphNodeId(encodedId) { * @param {Object} graph dagre graph instance * @param {Map} imNodes new node set * @param {Map} imEdges new edge set - * @param {Object} opts dimensions, scales, etc. * @return {Object} Layout with nodes, edges, dimensions */ -function runLayoutEngine(graph, imNodes, imEdges, opts) { +function runLayoutEngine(graph, imNodes, imEdges) { let nodes = imNodes; let edges = imEdges; - const options = opts || {}; - const scale = options.scale || DEFAULT_SCALE; - const ranksep = scale(RANK_SEPARATION_FACTOR); - const nodesep = scale(NODE_SEPARATION_FACTOR); - const nodeWidth = scale(NODE_SIZE_FACTOR); - const nodeHeight = scale(NODE_SIZE_FACTOR); + const ranksep = RANK_SEPARATION_FACTOR; + const nodesep = NODE_SEPARATION_FACTOR; + const nodeWidth = NODE_SIZE_FACTOR; + const nodeHeight = NODE_SIZE_FACTOR; // configure node margins graph.setGraph({ @@ -154,12 +151,10 @@ function setSimpleEdgePoints(edge, nodeCache) { * @param {object} opts Options * @return {object} new layout object */ -export function doLayoutNewNodesOfExistingRank(layout, nodeCache, opts) { +export function doLayoutNewNodesOfExistingRank(layout, nodeCache) { const result = Object.assign({}, layout); - const options = opts || {}; - const scale = options.scale || DEFAULT_SCALE; - const nodesep = scale(NODE_SEPARATION_FACTOR); - const nodeWidth = scale(NODE_SIZE_FACTOR); + const nodesep = NODE_SEPARATION_FACTOR; + const nodeWidth = NODE_SIZE_FACTOR; // determine new nodes const oldNodes = ImmSet.fromKeys(nodeCache); @@ -200,11 +195,10 @@ function layoutSingleNodes(layout, opts) { const result = Object.assign({}, layout); const options = opts || {}; const margins = options.margins || DEFAULT_MARGINS; - const scale = options.scale || DEFAULT_SCALE; - const ranksep = scale(RANK_SEPARATION_FACTOR) / 2; // dagre splits it in half - const nodesep = scale(NODE_SEPARATION_FACTOR); - const nodeWidth = scale(NODE_SIZE_FACTOR); - const nodeHeight = scale(NODE_SIZE_FACTOR); + const ranksep = RANK_SEPARATION_FACTOR / 2; // dagre splits it in half + const nodesep = NODE_SEPARATION_FACTOR; + const nodeWidth = NODE_SIZE_FACTOR; + const nodeHeight = NODE_SIZE_FACTOR; const graphHeight = layout.graphHeight || layout.height; const graphWidth = layout.graphWidth || layout.width; const aspectRatio = graphHeight ? graphWidth / graphHeight : 1; @@ -271,50 +265,6 @@ function layoutSingleNodes(layout, opts) { return result; } -/** - * Shifts all coordinates of node and edge points to make the layout more centered - * @param {Object} layout Layout - * @param {Object} opts Options with width and margins - * @return {Object} modified layout - */ -export function shiftLayoutToCenter(layout, opts) { - const result = Object.assign({}, layout); - const options = opts || {}; - const margins = options.margins || DEFAULT_MARGINS; - const width = options.width || DEFAULT_WIDTH; - const height = options.height || DEFAULT_HEIGHT; - - let offsetX = 0 + margins.left; - let offsetY = 0 + margins.top; - - if (layout.width < width) { - const xMin = layout.nodes.minBy(n => n.get('x')); - const xMax = layout.nodes.maxBy(n => n.get('x')); - offsetX = ((width - (xMin.get('x') + xMax.get('x'))) / 2) + margins.left; - } - if (layout.height < height) { - const yMin = layout.nodes.minBy(n => n.get('y')); - const yMax = layout.nodes.maxBy(n => n.get('y')); - offsetY = ((height - (yMin.get('y') + yMax.get('y'))) / 2) + margins.top; - } - - if (offsetX || offsetY) { - result.nodes = layout.nodes.map(node => node.merge({ - x: node.get('x') + offsetX, - y: node.get('y') + offsetY - })); - - result.edges = layout.edges.map(edge => edge.update('points', - points => points.map(point => point.merge({ - x: point.get('x') + offsetX, - y: point.get('y') + offsetY - })) - )); - } - - return result; -} - /** * Determine if nodes were added between node sets * @param {Map} nodes new Map of nodes @@ -478,17 +428,16 @@ export function doLayout(immNodes, immEdges, opts) { log('skip layout, used rank-based insertion'); layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges); layout = copyLayoutProperties(layout, nodeCache, edgeCache); - layout = doLayoutNewNodesOfExistingRank(layout, nodeCache, opts); + layout = doLayoutNewNodesOfExistingRank(layout, nodeCache); } else { const graph = cache.graph; - layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts); + layout = runLayoutEngine(graph, nodesWithDegrees, immEdges); if (!layout) { return layout; } } layout = layoutSingleNodes(layout, opts); - layout = shiftLayoutToCenter(layout, opts); } // cache results diff --git a/client/app/scripts/components/sparkline.js b/client/app/scripts/components/sparkline.js index 49b22688d..9a47dc7df 100644 --- a/client/app/scripts/components/sparkline.js +++ b/client/app/scripts/components/sparkline.js @@ -6,7 +6,6 @@ import { line, curveLinear } from 'd3-shape'; import { scaleLinear } from 'd3-scale'; import { formatMetricSvg } from '../utils/string-utils'; -import { round } from '../utils/math-utils'; export default class Sparkline extends React.Component { @@ -64,7 +63,7 @@ export default class Sparkline extends React.Component { const min = formatMetricSvg(d3Min(data, d => d.value), this.props); const max = formatMetricSvg(d3Max(data, d => d.value), this.props); const mean = formatMetricSvg(d3Mean(data, d => d.value), this.props); - const title = `Last ${round((lastDate - firstDate) / 1000)} seconds, ` + + const title = `Last ${Math.round((lastDate - firstDate) / 1000)} seconds, ` + `${data.length} samples, min: ${min}, max: ${max}, mean: ${mean}`; return {title, lastX, lastY, data}; diff --git a/client/app/scripts/constants/animation.js b/client/app/scripts/constants/animation.js new file mode 100644 index 000000000..e24d70770 --- /dev/null +++ b/client/app/scripts/constants/animation.js @@ -0,0 +1,2 @@ + +export const NODES_SPRING_ANIMATION_CONFIG = { stiffness: 80, damping: 20, precision: 0.1 }; diff --git a/client/app/scripts/constants/styles.js b/client/app/scripts/constants/styles.js index 909b9a311..3a68e0564 100644 --- a/client/app/scripts/constants/styles.js +++ b/client/app/scripts/constants/styles.js @@ -9,8 +9,6 @@ export const DETAILS_PANEL_MARGINS = { export const DETAILS_PANEL_OFFSET = 8; -export const CANVAS_METRIC_FONT_SIZE = 0.19; - export const CANVAS_MARGINS = { top: 160, left: 40, @@ -18,14 +16,20 @@ export const CANVAS_MARGINS = { bottom: 100, }; -// -// The base size the shapes were defined at matches nicely w/ a 14px font. -// -export const BASE_NODE_SIZE = 64; -export const MIN_NODE_SIZE = 24; -export const MAX_NODE_SIZE = 96; -export const BASE_NODE_LABEL_SIZE = 14; -export const MIN_NODE_LABEL_SIZE = 12; +// Node shapes +export const NODE_SHAPE_HIGHLIGHT_RADIUS = 70; +export const NODE_SHAPE_BORDER_RADIUS = 50; +export const NODE_SHAPE_SHADOW_RADIUS = 45; +export const NODE_SHAPE_DOT_RADIUS = 10; +export const NODE_BLUR_OPACITY = 0.2; +// NOTE: This value represents the node unit radius (in pixels). Since zooming is +// controlled at the top level now, this renormalization would be obsolete (i.e. +// value 1 could be used instead), if it wasn't for the following factors: +// 1. `dagre` library only works with integer coordinates, +// so >> 1 value is used to increase layout precision. +// 2. Fonts don't behave nicely (especially on Firefox) if they +// are given on a small unit scale as foreign objects in SVG. +export const NODE_BASE_SIZE = 100; // Node details table constants export const NODE_DETAILS_TABLE_CW = { diff --git a/client/app/scripts/hoc/metric-feeder.js b/client/app/scripts/hoc/metric-feeder.js index 1180cf9d4..c2167fd66 100644 --- a/client/app/scripts/hoc/metric-feeder.js +++ b/client/app/scripts/hoc/metric-feeder.js @@ -2,8 +2,6 @@ import React from 'react'; import { isoParse as parseDate } from 'd3-time-format'; import { OrderedMap } from 'immutable'; -import { round } from '../utils/math-utils'; - const makeOrderedMap = OrderedMap; const sortDate = (v, d) => d; const DEFAULT_TICK_INTERVAL = 1000; // DEFAULT_TICK_INTERVAL + renderTime < 1000ms @@ -104,7 +102,7 @@ export default ComposedComponent => class extends React.Component { let lastIndex = bufferKeys.indexOf(movingLast); // speed up the window if it falls behind - const step = lastIndex > 0 ? round(buffer.size / lastIndex) : 1; + const step = lastIndex > 0 ? Math.round(buffer.size / lastIndex) : 1; // only move first if we have enough values in window const windowLength = lastIndex - firstIndex; diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 31a1532a9..3248c2eb6 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -7,8 +7,15 @@ import ActionTypes from '../constants/action-types'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; import { applyPinnedSearches, updateNodeMatches } from '../utils/search-utils'; import { getNetworkNodes, getAvailableNetworks } from '../utils/network-view-utils'; -import { findTopologyById, getAdjacentNodes, setTopologyUrlsById, updateTopologyIds, - filterHiddenTopologies, addTopologyFullname, getDefaultTopology, graphExceedsComplexityThresh +import { + findTopologyById, + getAdjacentNodes, + setTopologyUrlsById, + updateTopologyIds, + filterHiddenTopologies, + addTopologyFullname, + getDefaultTopology, + graphExceedsComplexityThresh } from '../utils/topology-utils'; const log = debug('scope:app-store'); diff --git a/client/app/scripts/selectors/nodes-chart-focus.js b/client/app/scripts/selectors/nodes-chart-focus.js new file mode 100644 index 000000000..15ad07f0f --- /dev/null +++ b/client/app/scripts/selectors/nodes-chart-focus.js @@ -0,0 +1,149 @@ +import { includes, without } from 'lodash'; +import { fromJS } from 'immutable'; +import { createSelector } from 'reselect'; +import { scaleThreshold } from 'd3-scale'; + +import { NODE_BASE_SIZE, DETAILS_PANEL_WIDTH } from '../constants/styles'; + + +const circularOffsetAngle = Math.PI / 4; + +// make sure circular layouts a bit denser with 3-6 nodes +const radiusDensity = scaleThreshold() + .domain([3, 6]) + .range([2.5, 3.5, 3]); + + +const layoutNodesSelector = state => state.layoutNodes; +const layoutEdgesSelector = state => state.layoutEdges; +const stateWidthSelector = state => state.width; +const stateHeightSelector = state => state.height; +const stateScaleSelector = state => state.zoomScale; +const stateTranslateXSelector = state => state.panTranslateX; +const stateTranslateYSelector = state => state.panTranslateY; +const propsSelectedNodeIdSelector = (_, props) => props.selectedNodeId; +const propsAdjacentNodesSelector = (_, props) => props.adjacentNodes; +const propsMarginsSelector = (_, props) => props.margins; + +// The narrower dimension of the viewport, used for scaling. +const viewportExpanseSelector = createSelector( + [ + stateWidthSelector, + stateHeightSelector, + ], + (width, height) => Math.min(width, height) +); + +// Coordinates of the viewport center (when the details +// panel is open), used for focusing the selected node. +const viewportCenterSelector = createSelector( + [ + stateWidthSelector, + stateHeightSelector, + stateTranslateXSelector, + stateTranslateYSelector, + stateScaleSelector, + propsMarginsSelector, + ], + (width, height, translateX, translateY, scale, margins) => { + const viewportHalfWidth = ((width + margins.left) - DETAILS_PANEL_WIDTH) / 2; + const viewportHalfHeight = (height + margins.top) / 2; + return { + x: (-translateX + viewportHalfWidth) / scale, + y: (-translateY + viewportHalfHeight) / scale, + }; + } +); + +// List of all the adjacent nodes to the selected +// one, excluding itself (in case of loops). +const selectedNodeNeighborsIdsSelector = createSelector( + [ + propsSelectedNodeIdSelector, + propsAdjacentNodesSelector, + ], + (selectedNodeId, adjacentNodes) => without(adjacentNodes.toArray(), selectedNodeId) +); + +const selectedNodesLayoutSettingsSelector = createSelector( + [ + selectedNodeNeighborsIdsSelector, + viewportExpanseSelector, + stateScaleSelector, + ], + (circularNodesIds, viewportExpanse, scale) => { + const circularNodesCount = circularNodesIds.length; + + // Here we calculate the zoom factor of the nodes that get selected into focus. + // The factor is a somewhat arbitrary function (based on what looks good) of the + // viewport dimensions and the number of nodes in the circular layout. The idea + // is that the node should never be zoomed more than to cover 1/3 of the viewport + // (`maxScale`) and then the factor gets decresed asymptotically to the inverse + // square of the number of circular nodes, with a little constant push to make + // the layout more stable for a small number of nodes. Finally, the zoom factor is + // divided by the zoom factor applied to the whole topology layout to cancel it out. + const maxScale = viewportExpanse / NODE_BASE_SIZE / 3; + const shrinkFactor = Math.sqrt(circularNodesCount + 10); + const selectedScale = maxScale / shrinkFactor / scale; + + // Following a similar logic as above, we set the radius of the circular + // layout based on the viewport dimensions and the number of circular nodes. + const circularRadius = viewportExpanse / radiusDensity(circularNodesCount) / scale; + const circularInnerAngle = (2 * Math.PI) / circularNodesCount; + + return { selectedScale, circularRadius, circularInnerAngle }; + } +); + +export const layoutWithSelectedNode = createSelector( + [ + layoutNodesSelector, + layoutEdgesSelector, + viewportCenterSelector, + propsSelectedNodeIdSelector, + selectedNodeNeighborsIdsSelector, + selectedNodesLayoutSettingsSelector, + ], + (layoutNodes, layoutEdges, viewportCenter, selectedNodeId, neighborsIds, layoutSettings) => { + // Do nothing if the layout doesn't contain the selected node anymore. + if (!layoutNodes.has(selectedNodeId)) { + return {}; + } + + const { selectedScale, circularRadius, circularInnerAngle } = layoutSettings; + + // Fix the selected node in the viewport center. + layoutNodes = layoutNodes.mergeIn([selectedNodeId], viewportCenter); + + // Put the nodes that are adjacent to the selected one in a circular layout around it. + layoutNodes = layoutNodes.map((node, nodeId) => { + const index = neighborsIds.indexOf(nodeId); + if (index > -1) { + const angle = circularOffsetAngle + (index * circularInnerAngle); + return node.merge({ + x: viewportCenter.x + (circularRadius * Math.sin(angle)), + y: viewportCenter.y + (circularRadius * Math.cos(angle)) + }); + } + return node; + }); + + // Update the edges in the circular layout to link the nodes in a straight line. + layoutEdges = layoutEdges.map((edge) => { + if (edge.get('source') === selectedNodeId + || edge.get('target') === selectedNodeId + || includes(neighborsIds, edge.get('source')) + || includes(neighborsIds, edge.get('target'))) { + const source = layoutNodes.get(edge.get('source')); + const target = layoutNodes.get(edge.get('target')); + return edge.set('points', fromJS([ + {x: source.get('x'), y: source.get('y')}, + {x: target.get('x'), y: target.get('y')} + ])); + } + return edge; + }); + + return { layoutNodes, layoutEdges, selectedScale }; + } +); diff --git a/client/app/scripts/selectors/nodes-chart-layout.js b/client/app/scripts/selectors/nodes-chart-layout.js new file mode 100644 index 000000000..e5c8a8e73 --- /dev/null +++ b/client/app/scripts/selectors/nodes-chart-layout.js @@ -0,0 +1,94 @@ +import debug from 'debug'; +import { createSelector } from 'reselect'; +import { Map as makeMap } from 'immutable'; +import timely from 'timely'; + +import { EDGE_ID_SEPARATOR } from '../constants/naming'; +import { doLayout } from '../charts/nodes-layout'; + +const log = debug('scope:nodes-chart'); + + +const stateWidthSelector = state => state.width; +const stateHeightSelector = state => state.height; +const inputNodesSelector = (_, props) => props.nodes; +const propsMarginsSelector = (_, props) => props.margins; +const forceRelayoutSelector = (_, props) => props.forceRelayout; +const topologyIdSelector = (_, props) => props.topologyId; +const topologyOptionsSelector = (_, props) => props.topologyOptions; + + +function initEdgesFromNodes(nodes) { + let edges = makeMap(); + + nodes.forEach((node, nodeId) => { + const adjacency = node.get('adjacency'); + if (adjacency) { + adjacency.forEach((adjacent) => { + const edge = [nodeId, adjacent]; + const edgeId = edge.join(EDGE_ID_SEPARATOR); + + if (!edges.has(edgeId)) { + const source = edge[0]; + const target = edge[1]; + if (nodes.has(source) && nodes.has(target)) { + edges = edges.set(edgeId, makeMap({ + id: edgeId, + value: 1, + source, + target + })); + } + } + }); + } + }); + + return edges; +} + +const layoutOptionsSelector = createSelector( + [ + stateWidthSelector, + stateHeightSelector, + propsMarginsSelector, + forceRelayoutSelector, + topologyIdSelector, + topologyOptionsSelector, + ], + (width, height, margins, forceRelayout, topologyId, topologyOptions) => ( + { width, height, margins, forceRelayout, topologyId, topologyOptions } + ) +); + +export const graphLayout = createSelector( + [ + inputNodesSelector, + layoutOptionsSelector, + ], + (nodes, options) => { + // If the graph is empty, skip computing the layout. + if (nodes.size === 0) { + return { + layoutNodes: makeMap(), + layoutEdges: makeMap(), + }; + } + + const edges = initEdgesFromNodes(nodes); + const timedLayouter = timely(doLayout); + const graph = timedLayouter(nodes, edges, options); + + // NOTE: We probably shouldn't log anything in a + // computed property, but this is still useful. + log(`graph layout calculation took ${timedLayouter.time}ms`); + + const layoutEdges = graph.edges; + const layoutNodes = graph.nodes.map(node => makeMap({ + x: node.get('x'), + y: node.get('y'), + })); + + return { layoutNodes, layoutEdges }; + } +); diff --git a/client/app/scripts/selectors/nodes-chart-zoom.js b/client/app/scripts/selectors/nodes-chart-zoom.js new file mode 100644 index 000000000..ede1e9c45 --- /dev/null +++ b/client/app/scripts/selectors/nodes-chart-zoom.js @@ -0,0 +1,74 @@ +import { createSelector } from 'reselect'; + +import { NODE_BASE_SIZE } from '../constants/styles'; +import { zoomCacheKey } from '../utils/topology-utils'; + +const layoutNodesSelector = state => state.layoutNodes; +const stateWidthSelector = state => state.width; +const stateHeightSelector = state => state.height; +const propsMarginsSelector = (_, props) => props.margins; +const cachedZoomStateSelector = (state, props) => state.zoomCache[zoomCacheKey(props)]; + +const viewportWidthSelector = createSelector( + [ + stateWidthSelector, + propsMarginsSelector, + ], + (width, margins) => width - margins.left - margins.right +); +const viewportHeightSelector = createSelector( + [ + stateHeightSelector, + propsMarginsSelector, + ], + (height, margins) => height - margins.top +); + +// Compute the default zoom settings for the given graph layout. +const defaultZoomSelector = createSelector( + [ + layoutNodesSelector, + viewportWidthSelector, + viewportHeightSelector, + propsMarginsSelector, + ], + (layoutNodes, width, height, margins) => { + if (layoutNodes.size === 0) { + return {}; + } + + const xMin = layoutNodes.minBy(n => n.get('x')).get('x'); + const xMax = layoutNodes.maxBy(n => n.get('x')).get('x'); + const yMin = layoutNodes.minBy(n => n.get('y')).get('y'); + const yMax = layoutNodes.maxBy(n => n.get('y')).get('y'); + + const xFactor = width / (xMax - xMin); + const yFactor = height / (yMax - yMin); + + // Maximal allowed zoom will always be such that a node covers 1/5 of the viewport. + const maxZoomScale = Math.min(width, height) / NODE_BASE_SIZE / 5; + + // Initial zoom is such that the graph covers 90% of either the viewport, + // or one half of maximal zoom constraint, whichever is smaller. + const zoomScale = Math.min(xFactor, yFactor, maxZoomScale / 2) * 0.9; + + // Finally, we always allow zooming out exactly 5x compared to the initial zoom. + const minZoomScale = zoomScale / 5; + + // This translation puts the graph in the center of the viewport, respecting the margins. + const panTranslateX = ((width - ((xMax + xMin) * zoomScale)) / 2) + margins.left; + const panTranslateY = ((height - ((yMax + yMin) * zoomScale)) / 2) + margins.top; + + return { zoomScale, minZoomScale, maxZoomScale, panTranslateX, panTranslateY }; + } +); + +// Use the cache to get the last zoom state for the selected topology, +// otherwise use the default zoom options computed from the graph layout. +export const topologyZoomState = createSelector( + [ + cachedZoomStateSelector, + defaultZoomSelector, + ], + (cachedZoomState, defaultZoomState) => cachedZoomState || defaultZoomState +); diff --git a/client/app/scripts/utils/__tests__/math-utils-test.js b/client/app/scripts/utils/__tests__/math-utils-test.js index 6d5f95398..ab46b1a96 100644 --- a/client/app/scripts/utils/__tests__/math-utils-test.js +++ b/client/app/scripts/utils/__tests__/math-utils-test.js @@ -19,21 +19,4 @@ describe('MathUtils', () => { expect(f(-5, 5)).toBe(0); }); }); - - describe('round', () => { - const f = MathUtils.round; - - it('it should round the decimal number to given precision', () => { - expect(f(-173.6499023, -2)).toBe(-200); - expect(f(-173.6499023, -1)).toBe(-170); - expect(f(-173.6499023, 0)).toBe(-174); - expect(f(-173.6499023)).toBe(-174); - expect(f(-173.6499023, 1)).toBe(-173.6); - expect(f(-173.6499023, 2)).toBe(-173.65); - expect(f(0.0013, 2)).toBe(0); - expect(f(0.0013, 3)).toBe(0.001); - expect(f(0.0013, 4)).toBe(0.0013); - expect(f(0.0013, 5)).toBe(0.0013); - }); - }); }); diff --git a/client/app/scripts/utils/math-utils.js b/client/app/scripts/utils/math-utils.js index 401bf76b8..578d83760 100644 --- a/client/app/scripts/utils/math-utils.js +++ b/client/app/scripts/utils/math-utils.js @@ -18,10 +18,3 @@ export function modulo(i, n) { return ((i % n) + n) % n; } - -// Does the same that the deprecated d3.round was doing. -// Possibly imprecise: This https://github.com/d3/d3/issues/210 -export function round(value, decimals = 0) { - const p = Math.pow(10, decimals); - return Math.round(value * p) / p; -} diff --git a/client/app/scripts/utils/metric-utils.js b/client/app/scripts/utils/metric-utils.js index 5acf724ae..e67852a3f 100644 --- a/client/app/scripts/utils/metric-utils.js +++ b/client/app/scripts/utils/metric-utils.js @@ -2,32 +2,30 @@ import { includes } from 'lodash'; import { scaleLog } from 'd3-scale'; import React from 'react'; +import { NODE_BASE_SIZE, NODE_SHAPE_DOT_RADIUS } from '../constants/styles'; import { formatMetricSvg } from './string-utils'; import { colors } from './color-utils'; -export function getClipPathDefinition(clipId, size, height, - x = -size * 0.5, y = (size * 0.5) - height) { +export function getClipPathDefinition(clipId, height) { return ( - - + + ); } +export function renderMetricValue(value, condition) { + return condition ? {value} : ; +} // // loadScale(1) == 0.5; E.g. a nicely balanced system :). const loadScale = scaleLog().domain([0.01, 100]).range([0, 1]); -export function getMetricValue(metric, size) { +export function getMetricValue(metric) { if (!metric) { return {height: 0, value: null, formattedValue: 'n/a'}; } @@ -48,10 +46,9 @@ export function getMetricValue(metric, size) { } else if (displayedValue >= m.max && displayedValue > 0) { displayedValue = 1; } - const height = size * displayedValue; return { - height, + height: displayedValue, hasMetric: value !== null, formattedValue: formatMetricSvg(value, m) }; diff --git a/client/app/scripts/utils/node-shape-utils.js b/client/app/scripts/utils/node-shape-utils.js new file mode 100644 index 000000000..45dbf6b2b --- /dev/null +++ b/client/app/scripts/utils/node-shape-utils.js @@ -0,0 +1,12 @@ +import { line, curveCardinalClosed } from 'd3-shape'; +import range from 'lodash/range'; + +const shapeSpline = line().curve(curveCardinalClosed.tension(0.65)); + +export function nodeShapePolygon(radius, n) { + const innerAngle = (2 * Math.PI) / n; + return shapeSpline(range(0, n).map(k => [ + radius * Math.sin(k * innerAngle), + -radius * Math.cos(k * innerAngle) + ])); +} diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index 5588c7e69..9a7485641 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -182,3 +182,7 @@ export function graphExceedsComplexityThresh(stats) { // Check to see if complexity is high. Used to trigger table view on page load. return (stats.get('node_count') + (2 * stats.get('edge_count'))) > 500; } + +export function zoomCacheKey(props) { + return `${props.topologyId}-${JSON.stringify(props.topologyOptions)}`; +} diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 22601a7d8..01e7df8df 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -299,7 +299,7 @@ fill: $text-secondary-color; } - .nodes-chart-nodes > .node { + .nodes-chart-nodes .node { transition: opacity .5s $base-ease; text-align: center; @@ -316,6 +316,11 @@ color: $text-color; } + .node-labels-container { + transform: scale($node-text-scale); + pointer-events: none; + } + .node-label-wrapper { // // Base line height doesn't hop across foreignObject =/ @@ -336,6 +341,9 @@ vertical-align: top; cursor: pointer; + pointer-events: all; + font-size: 12px; + width: 100%; } .node-sublabel { @@ -344,7 +352,6 @@ } .node-label, .node-sublabel { - span { border-radius: 2px; } @@ -411,15 +418,13 @@ } .link { - stroke: $text-secondary-color; - stroke-width: $edge-link-stroke-width; fill: none; + stroke: $text-secondary-color; stroke-opacity: $edge-opacity; } .shadow { - stroke: $weave-blue; - stroke-width: 10px; fill: none; + stroke: $weave-blue; stroke-opacity: 0; } &.highlighted { @@ -433,7 +438,7 @@ display: none; } - .stack .onlyHighlight .shape { + .stack .highlight .shape { .border { display: none; } .shadow { display: none; } .node { display: none; } @@ -448,8 +453,7 @@ transform: scale(1); cursor: pointer; - /* cloud paths have stroke-width set dynamically */ - &:not(.shape-cloud) .border { + .border { stroke-width: $node-border-stroke-width; fill: $background-color; transition: stroke-opacity 0.333s $base-ease, fill 0.333s $base-ease; @@ -475,11 +479,12 @@ .node { fill: $text-color; stroke: $background-lighter-color; - stroke-width: 2px; + stroke-width: 0.05; } text { - font-size: 12px; + transform: scale($node-text-scale); + font-size: 10px; dominant-baseline: middle; text-anchor: middle; } @@ -494,7 +499,7 @@ } .stack .shape .border { - stroke-width: $node-border-stroke-width - 0.5; + stroke-width: $node-border-stroke-width * 0.8; } } diff --git a/client/app/styles/_contrast-overrides.scss b/client/app/styles/_contrast-overrides.scss index adcc59256..1ae13c32b 100644 --- a/client/app/styles/_contrast-overrides.scss +++ b/client/app/styles/_contrast-overrides.scss @@ -14,13 +14,12 @@ $white: white; $node-opacity-blurred: 0.6; $node-highlight-fill-opacity: 0.3; $node-highlight-stroke-opacity: 0.5; -$node-highlight-stroke-width: 3px; -$node-border-stroke-width: 5px; +$node-highlight-stroke-width: 8; +$node-border-stroke-width: 10; $node-pseudo-opacity: 1; $edge-highlight-opacity: 0.3; $edge-opacity-blurred: 0; $edge-opacity: 0.5; -$edge-link-stroke-width: 3px; $btn-opacity-default: 1; $btn-opacity-hover: 1; diff --git a/client/app/styles/_variables.scss b/client/app/styles/_variables.scss index 8e28a72e2..778322088 100644 --- a/client/app/styles/_variables.scss +++ b/client/app/styles/_variables.scss @@ -33,13 +33,13 @@ $terminal-header-height: 44px; $node-opacity-blurred: 0.25; $node-highlight-fill-opacity: 0.1; $node-highlight-stroke-opacity: 0.4; -$node-highlight-stroke-width: 1px; -$node-border-stroke-width: 2.5px; +$node-highlight-stroke-width: 2; +$node-border-stroke-width: 6; $node-pseudo-opacity: 0.8; +$node-text-scale: 2; $edge-highlight-opacity: 0.1; $edge-opacity-blurred: 0.2; $edge-opacity: 0.5; -$edge-link-stroke-width: 1px; $btn-opacity-default: 0.7; $btn-opacity-hover: 1;