Optimized rendering of graph layout and zooming events

This commit is contained in:
Filip Barl
2017-01-11 14:36:27 +01:00
parent b25417ea2f
commit 632e3756c4
27 changed files with 506 additions and 633 deletions

View File

@@ -36,5 +36,6 @@
"react/prefer-stateless-function": 0,
"react/sort-comp": 0,
"react/prop-types": 0,
"no-unused-vars": 0,
}
}

View File

@@ -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) => (
<Edge {...props} path={spline(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 <Edge {...other} path={path} />;
if (!isAnimated) {
return transformedEdge(forwardedProps, waypoints.toJS());
}
return (
<Motion style={this.state.pointsMap.toJS()}>
{(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 <Edge {...other} path={path} />;
}}
// 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.
<Motion style={this.state.waypointsMap.toJS()}>
{interpolated => transformedEdge(forwardedProps, waypointsMapToArray(interpolated))}
</Motion>
);
}
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);

View File

@@ -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 }) => (
<Node transform={`translate(${x},${y}) scale(${k})`} {...otherProps} />
);
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 (
<Motion
style={{
x: spring(dx, animConfig),
y: spring(dy, animConfig),
f: spring(scaleFactor, animConfig)
}}>
{(interpolated) => {
const transform = `translate(${round(interpolated.x, layoutPrecision)},`
+ `${round(interpolated.y, layoutPrecision)})`;
return <Node {...other} transform={transform} scaleFactor={interpolated.f} />;
}}
</Motion>
<g className="node-container" style={{opacity}}>
{!isAnimated ?
// Show static node for optimized rendering
transformedNode(forwardedProps, { x: dx, y: dy, k: scale }) :
// Animate the node if the layout is sufficiently small
<Motion
style={{
x: spring(dx, NODES_SPRING_ANIMATION_CONFIG),
y: spring(dy, NODES_SPRING_ANIMATION_CONFIG),
k: spring(scale, NODES_SPRING_ANIMATION_CONFIG)
}}>
{interpolated => transformedNode(forwardedProps, interpolated)}
</Motion>}
</g>
);
}
}

View File

@@ -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) => (
<rect
x={x(i)}
y={offset - (barHeight * 0.5)}
width={x.bandwidth()}
height={barHeight}
rx={rx}
ry={ry}
className="node-network"
style={{
fill: getNetworkColor(n.get('colorKey', n.get('id')))
}}
key={n.get('id')}
x={x(i)}
y={yPosition}
width={bandwidth}
height={barHeight}
rx={borderRadius}
ry={borderRadius}
style={{ fill: getNetworkColor(n.get('colorKey', n.get('id'))) }}
/>
));
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 (
<g transform={transform}>
<g transform={`translate(0, ${translateY})`}>
{bars.toJS()}
</g>
);

View File

@@ -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 (
<g className={className}>
{hasMetric && getClipPathDefinition(clipId, size, height)}
{highlighted && <circle r={size * 0.7} className="highlighted" />}
<circle r={size * 0.5} className="border" stroke={color} />
<circle r={size * 0.45} className="shadow" />
{hasMetric && getClipPathDefinition(clipId, height)}
{highlighted && <circle className="highlighted" r={NODE_SHAPE_HIGHLIGHT_RADIUS} />}
<circle className="border" stroke={color} r={NODE_SHAPE_BORDER_RADIUS} />
<circle className="shadow" r={NODE_SHAPE_SHADOW_RADIUS} />
{hasMetric && <circle
r={size * 0.45}
className="metric-fill"
style={metricStyle}
clipPath={`url(#${clipId})`}
style={metricStyle}
r={NODE_SHAPE_SHADOW_RADIUS}
/>}
{highlighted && hasMetric ?
<text style={{fontSize}}>{formattedValue}</text> :
<circle className="node" r={Math.max(2, (size * 0.125))} />}
{renderMetricValue(formattedValue, highlighted && hasMetric)}
</g>
);
}

View File

@@ -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 (
<g className="shape shape-cloud">
{highlighted && <path className="highlighted" {...pathProps(0.7)} />}
<path className="border" stroke={color} {...pathProps(0.5)} />
<path className="shadow" {...pathProps(0.45)} />
<circle className="node" r={Math.max(2, (size * 0.125))} />
{highlighted && <path className="highlighted" {...pathProps(NODE_SHAPE_HIGHLIGHT_RADIUS)} />}
<path className="border" stroke={color} {...pathProps(NODE_SHAPE_BORDER_RADIUS)} />
<path className="shadow" {...pathProps(NODE_SHAPE_SHADOW_RADIUS)} />
<circle className="node" r={NODE_SHAPE_DOT_RADIUS} />
</g>
);
}

View File

@@ -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 (
<g className={className}>
{hasMetric && getClipPathDefinition(clipId, size, height, -halfSize, halfSize - height)}
{highlighted && <path className="highlighted" {...pathProps(0.7)} />}
<path className="border" stroke={color} {...pathProps(0.5)} />
<path className="shadow" {...pathProps(0.45)} />
{hasMetric && getClipPathDefinition(clipId, height)}
{highlighted && <path className="highlighted" {...pathProps(NODE_SHAPE_HIGHLIGHT_RADIUS)} />}
<path className="border" stroke={color} {...pathProps(NODE_SHAPE_BORDER_RADIUS)} />
<path className="shadow" {...pathProps(NODE_SHAPE_SHADOW_RADIUS)} />
{hasMetric && <path
className="metric-fill"
clipPath={`url(#${clipId})`}
style={metricStyle}
{...pathProps(0.45)}
{...pathProps(NODE_SHAPE_SHADOW_RADIUS)}
/>}
{highlighted && hasMetric ?
<text style={{fontSize}}>{formattedValue}</text> :
<circle className="node" r={Math.max(2, (size * 0.125))} />}
{renderMetricValue(formattedValue, highlighted && hasMetric)}
</g>
);
}

View File

@@ -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 (
<g className={className}>
{hasMetric && getClipPathDefinition(
clipId,
size * (1 + (hexCurve * 2)),
height,
-(size * hexCurve),
(size - height) * (shadowSize * 2)
)}
{highlighted && <path className="highlighted" {...pathProps(0.7)} />}
<path className="border" stroke={color} {...pathProps(0.5)} />
<path className="shadow" {...pathProps(shadowSize)} />
{hasMetric && getClipPathDefinition(clipId, height)}
{highlighted && <path className="highlighted" {...pathProps(NODE_SHAPE_HIGHLIGHT_RADIUS)} />}
<path className="border" stroke={color} {...pathProps(NODE_SHAPE_BORDER_RADIUS)} />
<path className="shadow" {...pathProps(NODE_SHAPE_SHADOW_RADIUS)} />
{hasMetric && <path
className="metric-fill"
style={metricStyle}
clipPath={`url(#${clipId})`}
{...pathProps(shadowSize)}
style={metricStyle}
{...pathProps(NODE_SHAPE_SHADOW_RADIUS)}
/>}
{highlighted && hasMetric ?
<text style={{fontSize}}>
{formattedValue}
</text> :
<circle className="node" r={Math.max(2, (size * 0.125))} />}
{renderMetricValue(formattedValue, highlighted && hasMetric)}
</g>
);
}

View File

@@ -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 (
<g className={className}>
{hasMetric && getClipPathDefinition(clipId, size, height)}
{highlighted && <rect className="highlighted" {...rectProps(0.7)} />}
<rect className="border" stroke={color} {...rectProps(0.5, 0.5)} />
<rect className="shadow" {...rectProps(0.45, 0.39)} />
{hasMetric && getClipPathDefinition(clipId, height)}
{highlighted && <rect className="highlighted" {...rectProps(NODE_SHAPE_HIGHLIGHT_RADIUS)} />}
<rect className="border" stroke={color} {...rectProps(NODE_SHAPE_BORDER_RADIUS)} />
<rect className="shadow" {...rectProps(NODE_SHAPE_SHADOW_RADIUS)} />
{hasMetric && <rect
className="metric-fill" style={metricStyle}
className="metric-fill"
clipPath={`url(#${clipId})`}
{...rectProps(0.45, 0.39)}
style={metricStyle}
{...rectProps(NODE_SHAPE_SHADOW_RADIUS, 0.85)}
/>}
{highlighted && hasMetric ?
<text style={{fontSize}}>
{formattedValue}
</text> :
<circle className="node" r={Math.max(2, (size * 0.125))} />}
{renderMetricValue(formattedValue, highlighted && hasMetric)}
</g>
);
}

View File

@@ -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 (
<g transform={`translate(${dx * -1}, ${dy * -2.5})`} className="stack">
<g transform={`scale(${hls})translate(${dx}, ${dy})`} className="onlyHighlight">
<g transform={`translate(0, ${dy * -2.5})`} className="stack">
<g transform={`scale(${highlightScale}) translate(0, ${dy})`} className="highlight">
<Shape {...props} />
</g>
<g transform={`translate(${dx * 2}, ${dy * 2})`}>
<g transform={`translate(0, ${dy * 2})`}>
<Shape {...props} />
</g>
<g transform={`translate(${dx * 1}, ${dy * 1})`}>
<g transform={`translate(0, ${dy * 1})`}>
<Shape {...props} />
</g>
<g className="onlyMetrics">
<g className="only-metrics">
<Shape {...props} />
</g>
</g>

View File

@@ -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 (
<g className="node-label-svg">
<text className={labelClassName} y={labelOffsetY + 18} textAnchor="middle">{label}</text>
<text className={subLabelClassName} y={labelOffsetY + 35} textAnchor="middle">
{subLabel}
</text>
</g>
);
}
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 (
<g className="node-labels-container" y={labelOffsetY}>
<text className={labelClassName} y={13} textAnchor="middle">{label}</text>
<text className={subLabelClassName} y={30} textAnchor="middle">
{subLabel}
</text>
</g>
);
}
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 (
<foreignObject className="node-labels-container" y={labelOffsetY}>
<div className="node-label-wrapper" {...mouseEvents}>
<div className={labelClassName}>
<MatchedText text={label} match={matches.get('label')} />
</div>
<div className={subLabelClassName}>
<MatchedText text={subLabel} match={matches.get('sublabel')} />
</div>
{!blurred && <MatchedResults matches={matchedNodeDetails} />}
</div>
</foreignObject>
);
}
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 (
<g className={nodeClassName} transform={transform}>
{useSvgLabels ?
svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) :
<foreignObject
style={{pointerEvents: 'none'}}
x={labelOffsetX} y={labelOffsetY}
width={labelWidth} height="100em">
<div
className="node-label-wrapper"
style={{pointerEvents: 'all', fontSize, maxWidth: labelWidth}}
{...mouseEvents}>
<div className={labelClassName}>
<MatchedText text={label} match={matches.get('label')} />
</div>
<div className={subLabelClassName}>
<MatchedText text={subLabel} match={matches.get('sublabel')} />
</div>
{!blurred && <MatchedResults matches={matchedNodeDetails} />}
</div>
</foreignObject>}
{useSvgLabels || false ?
this.renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) :
this.renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents)}
<g {...mouseEvents} ref={this.saveShapeRef}>
<NodeShapeType
size={size}
color={color}
{...this.props} />
<NodeShapeType color={color} {...this.props} />
</g>
{showingNetworks && <NodeNetworksOverlay
offset={networkOffset}
size={size} networks={networks}
networks={networks}
stack={stack}
/>}
</g>

View File

@@ -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}
/>
);

View File

@@ -12,13 +12,12 @@ class NodesChartElements extends React.Component {
<g className="nodes-chart-elements" transform={props.transform}>
<NodesChartEdges
layoutEdges={props.layoutEdges}
layoutPrecision={props.layoutPrecision} />
isAnimated={props.isAnimated} />
<NodesChartNodes
layoutNodes={props.completeNodes}
nodeScale={props.nodeScale}
scale={props.scale}
selectedNodeScale={props.selectedNodeScale}
layoutPrecision={props.layoutPrecision} />
selectedScale={props.selectedScale}
isAnimated={props.isAnimated} />
</g>
);
}

View File

@@ -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')}
/>)}
</g>
);
}

View File

@@ -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 (
<div className="nodes-chart">
<svg
@@ -302,11 +360,10 @@ class NodesChart extends React.Component {
<NodesChartElements
layoutNodes={nodes}
layoutEdges={edges}
nodeScale={this.state.nodeScale}
scale={scale}
selectedScale={this.state.selectedScale}
transform={transform}
selectedNodeScale={this.state.selectedNodeScale}
layoutPrecision={layoutPrecision} />
isAnimated={this.isSmallTopology()} />
</svg>
</div>
);
@@ -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) {

View File

@@ -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

View File

@@ -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};

View File

@@ -0,0 +1,2 @@
export const NODES_SPRING_ANIMATION_CONFIG = { stiffness: 80, damping: 20, precision: 0.1 };

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -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;
}

View File

@@ -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 (
<defs>
<clipPath id={clipId}>
<rect
width={size}
height={size}
x={x}
y={y}
/>
<rect width={1} height={1} x={-0.5} y={0.5 - height} />
</clipPath>
</defs>
);
}
export function renderMetricValue(value, condition) {
return condition ? <text>{value}</text> : <circle className="node" r={NODE_SHAPE_DOT_RADIUS} />;
}
//
// 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)
};

View File

@@ -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)
]));
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;