From 632e3756c4281c954e25967b4f00856cdfe48496 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Wed, 11 Jan 2017 14:36:27 +0100 Subject: [PATCH] Optimized rendering of graph layout and zooming events --- client/.eslintrc | 1 + client/app/scripts/charts/edge-container.js | 90 +++--- 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 | 62 ++-- client/app/scripts/charts/node-shape-stack.js | 19 +- client/app/scripts/charts/node.js | 115 +++----- .../app/scripts/charts/nodes-chart-edges.js | 6 +- .../scripts/charts/nodes-chart-elements.js | 7 +- .../app/scripts/charts/nodes-chart-nodes.js | 18 +- client/app/scripts/charts/nodes-chart.js | 279 +++++++++--------- 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 | 15 +- client/app/scripts/hoc/metric-feeder.js | 4 +- .../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/styles/_base.scss | 30 +- client/app/styles/_contrast-overrides.scss | 5 +- client/app/styles/_variables.scss | 7 +- 27 files changed, 506 insertions(+), 633 deletions(-) create mode 100644 client/app/scripts/constants/animation.js create mode 100644 client/app/scripts/utils/node-shape-utils.js diff --git a/client/.eslintrc b/client/.eslintrc index c6ed99ee7..c67801e0a 100644 --- a/client/.eslintrc +++ b/client/.eslintrc @@ -36,5 +36,6 @@ "react/prefer-stateless-function": 0, "react/sort-comp": 0, "react/prop-types": 0, + "no-unused-vars": 0, } } 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/node-container.js b/client/app/scripts/charts/node-container.js index 4faa3ae1d..dc92b46bc 100644 --- a/client/app/scripts/charts/node-container.js +++ b/client/app/scripts/charts/node-container.js @@ -3,29 +3,40 @@ 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_BASE_SIZE, 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, 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; + // 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..b8af48b36 100644 --- a/client/app/scripts/charts/node-networks-overlay.js +++ b/client/app/scripts/charts/node-networks-overlay.js @@ -4,50 +4,40 @@ import { List as makeList } from 'immutable'; import { getNetworkColor } from '../utils/color-utils'; import { isContrastMode } from '../utils/contrast-utils'; - -// 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 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({offset, 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..61af2f392 100644 --- a/client/app/scripts/charts/node-shape-cloud.js +++ b/client/app/scripts/charts/node-shape-cloud.js @@ -1,46 +1,27 @@ 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, +} from '../constants/styles'; -import { isContrastMode } from '../utils/contrast-utils'; +// 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'; -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})` }); 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..5e5eb1e07 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -15,13 +15,8 @@ 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})); @@ -43,58 +38,72 @@ 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 : 30; + const networkOffset = 0.67; const nodeClassName = classnames('node', { highlighted, @@ -109,51 +118,25 @@ 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 && } -
-
} + {useSvgLabels || false ? + this.renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) : + this.renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents)} - + {showingNetworks && }
diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index 3e0d3ce90..350e5e02d 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -8,7 +8,7 @@ import EdgeContainer from './edge-container'; class NodesChartEdges extends React.Component { render() { const { hasSelectedNode, highlightedEdgeIds, layoutEdges, - layoutPrecision, searchNodeMatches = makeMap(), searchQuery, + searchNodeMatches = makeMap(), searchQuery, isAnimated, selectedNodeId, selectedNetwork, selectedNetworkNodes } = this.props; return ( @@ -35,10 +35,10 @@ 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')} + 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..df193d9fe 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 { + 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..0477d0ae4 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, scale, + 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} + magnified={node.get('focused') ? selectedScale / scale : 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..1d8f48a74 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -5,14 +5,14 @@ import { assign, pick, includes } from 'lodash'; import { Map as makeMap, fromJS } from 'immutable'; import timely from 'timely'; -import { scaleThreshold, scaleLinear } from 'd3-scale'; +import { scaleThreshold } from 'd3-scale'; import { event as d3Event, select } from 'd3-selection'; import { zoom, zoomIdentity } from 'd3-zoom'; import { nodeAdjacenciesSelector, adjacentNodesSelector } from '../selectors/chartSelectors'; import { clickBackground } from '../actions/app-actions'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; -import { MIN_NODE_SIZE, DETAILS_PANEL_WIDTH, MAX_NODE_SIZE } from '../constants/styles'; +import { DETAILS_PANEL_WIDTH, NODE_BASE_SIZE } from '../constants/styles'; import Logo from '../components/logo'; import { doLayout } from './nodes-layout'; import NodesChartElements from './nodes-chart-elements'; @@ -20,32 +20,20 @@ import { getActiveTopologyOptions } from '../utils/topology-utils'; const log = debug('scope:nodes-chart'); -const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY']; +const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY', 'minScale', 'maxScale']; // make sure circular layouts a bit denser with 3-6 nodes const radiusDensity = scaleThreshold() .domain([3, 6]) .range([2.5, 3.5, 3]); -/** - * dynamic coords precision based on topology size - */ -function getLayoutPrecision(nodesCount) { - let precision; - if (nodesCount >= 50) { - precision = 0; - } else if (nodesCount > 20) { - precision = 1; - } else if (nodesCount > 10) { - precision = 2; - } else { - precision = 3; - } - - return precision; -} +const emptyLayoutState = { + nodes: makeMap(), + edges: makeMap(), +}; +// EDGES function initEdges(nodes) { let edges = makeMap(); @@ -76,24 +64,45 @@ function initEdges(nodes) { } -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)); +// ZOOM STATE +function getLayoutDefaultZoom(layoutNodes, width, height) { + const xMin = layoutNodes.minBy(n => n.get('x')).get('x'); + const xMax = layoutNodes.maxBy(n => n.get('x')).get('x'); + const yMin = layoutNodes.minBy(n => n.get('y')).get('y'); + const yMax = layoutNodes.maxBy(n => n.get('y')).get('y'); - return scaleLinear().range([0, normalizedNodeSize]); + const xFactor = width / (xMax - xMin); + const yFactor = height / (yMax - yMin); + const scale = Math.min(xFactor, yFactor); + + return { + translateX: (width - ((xMax + xMin) * scale)) / 2, + translateY: (height - ((yMax + yMin) * scale)) / 2, + scale, + }; +} + +function defaultZoomState(props, state) { + // adjust layout based on viewport + const width = state.width - props.margins.left - props.margins.right; + const height = state.height - props.margins.top - props.margins.bottom; + + const { translateX, translateY, scale } = getLayoutDefaultZoom(state.nodes, width, height); + + return { + scale, + minScale: scale / 5, + maxScale: Math.min(width, height) / NODE_BASE_SIZE / 3, + panTranslateX: translateX + props.margins.left, + panTranslateY: translateY + props.margins.top, + }; } +// LAYOUT STATE function updateLayout(width, height, nodes, baseOptions) { - const nodeScale = getNodeScale(nodes.size, width, height); const edges = initEdges(nodes); - - const options = Object.assign({}, baseOptions, { - scale: nodeScale, - }); + const options = Object.assign({}, baseOptions); const timedLayouter = timely(doLayout); const graph = timedLayouter(nodes, edges, options); @@ -108,13 +117,52 @@ function updateLayout(width, height, nodes, baseOptions) { py: node.get('y') })); - const layoutEdges = graph.edges - .map(edge => edge.set('ppoints', edge.get('points'))); + const layoutEdges = graph.edges.map(edge => edge.set('ppoints', edge.get('points'))); - return { layoutNodes, layoutEdges, layoutWidth: graph.width, layoutHeight: graph.height }; + return { layoutNodes, layoutEdges }; +} + +function updatedGraphState(props, state) { + if (props.nodes.size === 0) { + return emptyLayoutState; + } + + const options = { + width: state.width, + height: state.height, + margins: props.margins, + forceRelayout: props.forceRelayout, + topologyId: props.topologyId, + topologyOptions: props.topologyOptions, + }; + + const { layoutNodes, layoutEdges } = + updateLayout(state.width, state.height, props.nodes, options); + + return { + nodes: layoutNodes, + edges: layoutEdges, + }; +} + +function restoredLayout(state) { + const restoredNodes = state.nodes.map(node => node.merge({ + x: node.get('px'), + y: node.get('py') + })); + + const restoredEdges = state.edges.map(edge => ( + edge.has('ppoints') ? edge.set('points', edge.get('ppoints')) : edge + )); + + return { + nodes: restoredNodes, + edges: restoredEdges, + }; } +// SELECTED NODE function centerSelectedNode(props, state) { let stateNodes = state.nodes; let stateEdges = state.edges; @@ -179,10 +227,10 @@ function centerSelectedNode(props, state) { }); // auto-scale node size for selected nodes - const selectedNodeScale = getNodeScale(adjacentNodes.size, state.width, state.height); + // const selectedNodeScale = getNodeScale(adjacentNodes.size, state.width, state.height); return { - selectedNodeScale, + selectedScale: 1, edges: stateEdges, nodes: stateNodes }; @@ -193,27 +241,26 @@ 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(), + this.state = Object.assign({ + scale: 1, + minScale: 1, + maxScale: 1, panTranslateX: 0, panTranslateY: 0, - scale: 1, - selectedNodeScale: scaleLinear(), - hasZoomed: false, + selectedScale: 1, height: props.height || 0, width: props.width || 0, zoomCache: {}, - }; + }, emptyLayoutState); + + this.handleMouseClick = this.handleMouseClick.bind(this); + this.zoomed = this.zoomed.bind(this); } componentWillMount() { - const state = this.updateGraphState(this.props, this.state); + const state = updatedGraphState(this.props, this.state); + // debugger; + // assign(state, this.restoreZoomState(this.props, Object.assign(this.state, state))); this.setState(state); } @@ -221,25 +268,11 @@ class NodesChart extends React.Component { // gather state, setState should be called only once here const state = assign({}, this.state); + const topologyChanged = nextProps.topologyId !== this.props.topologyId; + // wipe node states when showing different topology - if (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() - }); + if (topologyChanged) { + assign(state, emptyLayoutState); } // reset layout dimensions only when forced @@ -247,29 +280,50 @@ class NodesChart extends React.Component { state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width); if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) { - assign(state, this.updateGraphState(nextProps, state)); + assign(state, updatedGraphState(nextProps, state)); } - if (this.props.selectedNodeId !== nextProps.selectedNodeId) { - assign(state, this.restoreLayout(state)); - } - if (nextProps.selectedNodeId) { - assign(state, centerSelectedNode(nextProps, state)); + console.log(`Prepare ${nextProps.nodes.size}`); + if (nextProps.nodes.size > 0) { + console.log(state.zoomCache); + assign(state, this.restoreZoomState(nextProps, state)); } + // if (this.props.selectedNodeId !== nextProps.selectedNodeId) { + // // undo any pan/zooming that might have happened + // this.setZoom(state); + // assign(state, restoredLayout(state)); + // } + // + // if (nextProps.selectedNodeId) { + // assign(state, centerSelectedNode(nextProps, state)); + // } + + if (topologyChanged) { + // saving previous zoom state + const prevZoom = pick(this.state, ZOOM_CACHE_FIELDS); + const zoomCache = assign({}, this.state.zoomCache); + zoomCache[this.props.topologyId] = prevZoom; + assign(state, { zoomCache }); + } + + // console.log(topologyChanged); + // console.log(state); this.setState(state); } componentDidMount() { // distinguish pan/zoom from click this.isZooming = false; + // debugger; this.zoom = zoom() - .scaleExtent([0.1, 2]) + .scaleExtent([this.state.minScale, this.state.maxScale]) .on('zoom', this.zoomed); this.svg = select('.nodes-chart svg'); this.svg.call(this.zoom); + // this.setZoom(this.state); } componentWillUnmount() { @@ -282,15 +336,19 @@ class NodesChart extends React.Component { .on('touchstart.zoom', null); } + isSmallTopology() { + return this.state.nodes.size < 100; + } + render() { const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state; + console.log(`Render ${nodes.size}`); // 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 layoutPrecision = getLayoutPrecision(nodes.size); return (
+ isAnimated={this.isSmallTopology()} />
); @@ -320,74 +377,28 @@ 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 }; - } - - updateGraphState(props, state) { - if (props.nodes.size === 0) { - return { - nodes: makeMap(), - edges: makeMap() - }; + restoreZoomState(props, state) { + // re-apply cached canvas zoom/pan to d3 behavior (or set the default values) + const nextZoom = state.zoomCache[props.topologyId] || defaultZoomState(props, state); + if (this.zoom) { + this.zoom = this.zoom.scaleExtent([nextZoom.minScale, nextZoom.maxScale]); + this.setZoom(nextZoom); } - 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), - }; + return nextZoom; } 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, panTranslateX: d3Event.transform.x, panTranslateY: d3Event.transform.y, scale: d3Event.transform.k }); } + // console.log(d3Event.transform); } setZoom(newZoom) { 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..c81285f49 100644 --- a/client/app/scripts/constants/styles.js +++ b/client/app/scripts/constants/styles.js @@ -18,14 +18,13 @@ 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 = 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; +export const NODE_BASE_SIZE = 50; // 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/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..f27a56760 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_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/styles/_base.scss b/client/app/styles/_base.scss index 22601a7d8..8456f8974 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,14 @@ 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 =/ @@ -336,6 +344,9 @@ vertical-align: top; cursor: pointer; + pointer-events: all; + font-size: 12px; + width: 100%; } .node-sublabel { @@ -344,7 +355,6 @@ } .node-label, .node-sublabel { - span { border-radius: 2px; } @@ -411,15 +421,13 @@ } .link { - stroke: $text-secondary-color; - stroke-width: $edge-link-stroke-width; fill: none; + stroke-width: $edge-link-stroke-width; stroke-opacity: $edge-opacity; } .shadow { - stroke: $weave-blue; - stroke-width: 10px; fill: none; + stroke: $weave-blue; stroke-opacity: 0; } &.highlighted { @@ -433,7 +441,7 @@ display: none; } - .stack .onlyHighlight .shape { + .stack .highlight .shape { .border { display: none; } .shadow { display: none; } .node { display: none; } @@ -448,8 +456,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,10 +482,11 @@ .node { fill: $text-color; stroke: $background-lighter-color; - stroke-width: 2px; + stroke-width: 0.05; } text { + transform: scale($node-text-scale); font-size: 12px; dominant-baseline: middle; text-anchor: middle; @@ -494,7 +502,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..a62c5de5f 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: 0.06; +$node-border-stroke-width: 0.1; $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..14c434d06 100644 --- a/client/app/styles/_variables.scss +++ b/client/app/styles/_variables.scss @@ -33,13 +33,14 @@ $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: 0.02; +$node-border-stroke-width: 0.06; $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;