Revert "Graph layout optimizations"

This commit is contained in:
Filip Barl
2017-02-02 11:42:12 +01:00
committed by GitHub
parent 95c688d1b1
commit 8eaa12e680
33 changed files with 870 additions and 795 deletions

View File

@@ -167,6 +167,54 @@ describe('NodesLayout', () => {
expect(hasUnseen).toBeTruthy();
});
it('shifts layouts to center', () => {
let xMin;
let xMax;
let yMin;
let yMax;
let xCenter;
let yCenter;
// make sure initial layout is centered
const original = NodesLayout.doLayout(
nodeSets.initial4.nodes,
nodeSets.initial4.edges
);
xMin = original.nodes.minBy(n => n.get('x'));
xMax = original.nodes.maxBy(n => n.get('x'));
yMin = original.nodes.minBy(n => n.get('y'));
yMax = original.nodes.maxBy(n => n.get('y'));
xCenter = (xMin.get('x') + xMax.get('x')) / 2;
yCenter = (yMin.get('y') + yMax.get('y')) / 2;
expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2);
expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2);
// make sure re-running is idempotent
const rerun = NodesLayout.shiftLayoutToCenter(original);
xMin = rerun.nodes.minBy(n => n.get('x'));
xMax = rerun.nodes.maxBy(n => n.get('x'));
yMin = rerun.nodes.minBy(n => n.get('y'));
yMax = rerun.nodes.maxBy(n => n.get('y'));
xCenter = (xMin.get('x') + xMax.get('x')) / 2;
yCenter = (yMin.get('y') + yMax.get('y')) / 2;
expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2);
expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2);
// shift after window was resized
const shifted = NodesLayout.shiftLayoutToCenter(original, {
width: 128,
height: 256
});
xMin = shifted.nodes.minBy(n => n.get('x'));
xMax = shifted.nodes.maxBy(n => n.get('x'));
yMin = shifted.nodes.minBy(n => n.get('y'));
yMax = shifted.nodes.maxBy(n => n.get('y'));
xCenter = (xMin.get('x') + xMax.get('x')) / 2;
yCenter = (yMin.get('y') + yMax.get('y')) / 2;
expect(xCenter).toEqual(128 / 2);
expect(yCenter).toEqual(256 / 2);
});
it('lays out initial nodeset in a rectangle', () => {
const result = NodesLayout.doLayout(
nodeSets.initial4.nodes,

View File

@@ -5,102 +5,106 @@ import { Map as makeMap } from 'immutable';
import { line, curveBasis } from 'd3-shape';
import { each, omit, times, constant } from 'lodash';
import { NODES_SPRING_ANIMATION_CONFIG } from '../constants/animation';
import { uniformSelect } from '../utils/array-utils';
import { round } from '../utils/math-utils';
import Edge from './edge';
// Spring stiffness & damping respectively
const ANIMATION_CONFIG = [80, 20];
// Tweak this value for the number of control
// points along the edge curve, e.g. values:
// * 2 -> edges are simply straight lines
// * 4 -> minimal value for loops to look ok
const WAYPOINTS_COUNT = 8;
const WAYPOINTS_CAP = 8;
const spline = line()
.curve(curveBasis)
.x(d => d.x)
.y(d => d.y);
const transformedEdge = (props, path) => (
<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;
const buildPath = (points, layoutPrecision) => {
const extracted = [];
each(points, (value, key) => {
const axis = key[0];
const index = key.slice(1);
if (!extracted[index]) {
extracted[index] = {};
}
extracted[index][axis] = round(value, layoutPrecision);
});
return waypointsArray;
return extracted;
};
class EdgeContainer extends React.Component {
constructor(props, context) {
super(props, context);
this.state = { waypointsMap: makeMap() };
this.state = {
pointsMap: makeMap()
};
}
componentWillMount() {
if (this.props.isAnimated) {
this.prepareWaypointsForMotion(this.props.waypoints);
}
this.preparePoints(this.props.points);
}
componentWillReceiveProps(nextProps) {
// immutablejs allows us to `===`! \o/
if (this.props.isAnimated && nextProps.waypoints !== this.props.waypoints) {
this.prepareWaypointsForMotion(nextProps.waypoints);
if (nextProps.points !== this.props.points) {
this.preparePoints(nextProps.points);
}
}
render() {
const { isAnimated, waypoints } = this.props;
const forwardedProps = omit(this.props, 'isAnimated', 'waypoints');
const { layoutPrecision, points } = this.props;
const other = omit(this.props, 'points');
if (!isAnimated) {
return transformedEdge(forwardedProps, waypoints.toJS());
if (layoutPrecision === 0) {
const path = spline(points.toJS());
return <Edge {...other} path={path} />;
}
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.
<Motion style={this.state.waypointsMap.toJS()}>
{interpolated => transformedEdge(forwardedProps, waypointsMapToArray(interpolated))}
<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} />;
}}
</Motion>
);
}
prepareWaypointsForMotion(nextWaypoints) {
nextWaypoints = nextWaypoints.toJS();
preparePoints(nextPoints) {
nextPoints = nextPoints.toJS();
// Motion requires a constant number of waypoints along the path of each edge
// for the animation to work correctly, but dagre might be changing their number
// depending on the dynamic topology reconfiguration. Here we are transforming
// the waypoints array given by dagre to the fixed size of `WAYPOINTS_COUNT` that
// the waypoints array given by dagre to the fixed size of `WAYPOINTS_CAP` that
// Motion could take over.
const waypointsMissing = WAYPOINTS_COUNT - nextWaypoints.length;
if (waypointsMissing > 0) {
const pointsMissing = WAYPOINTS_CAP - nextPoints.length;
if (pointsMissing > 0) {
// Whenever there are some waypoints missing, we simply populate the beginning of the
// array with the first element, as this leaves the curve interpolation unchanged.
nextWaypoints = times(waypointsMissing, constant(nextWaypoints[0])).concat(nextWaypoints);
} else if (waypointsMissing < 0) {
nextPoints = times(pointsMissing, constant(nextPoints[0])).concat(nextPoints);
} else if (pointsMissing < 0) {
// If there are 'too many' waypoints given by dagre, we select a sub-array of
// uniformly distributed indices. Note that it is very important to keep the first
// and the last endpoints in the array as they are the ones connecting the nodes.
nextWaypoints = uniformSelect(nextWaypoints, WAYPOINTS_COUNT);
nextPoints = uniformSelect(nextPoints, WAYPOINTS_CAP);
}
let { waypointsMap } = this.state;
nextWaypoints.forEach((point, index) => {
waypointsMap = waypointsMap.set(`x${index}`, spring(point.x, NODES_SPRING_ANIMATION_CONFIG));
waypointsMap = waypointsMap.set(`y${index}`, spring(point.y, NODES_SPRING_ANIMATION_CONFIG));
let { pointsMap } = this.state;
nextPoints.forEach((point, index) => {
pointsMap = pointsMap.set(`x${index}`, spring(point.x, ANIMATION_CONFIG));
pointsMap = pointsMap.set(`y${index}`, spring(point.y, ANIMATION_CONFIG));
});
this.setState({ waypointsMap });
this.setState({ pointsMap });
}
}
export default connect()(EdgeContainer);

View File

@@ -3,8 +3,6 @@ import { connect } from 'react-redux';
import classNames from 'classnames';
import { enterEdge, leaveEdge } from '../actions/app-actions';
import { isContrastMode } from '../utils/contrast-utils';
import { NODE_BASE_SIZE } from '../constants/styles';
class Edge extends React.Component {
@@ -15,19 +13,15 @@ class Edge extends React.Component {
}
render() {
const { id, path, highlighted, blurred, focused, scale } = this.props;
const className = classNames('edge', { highlighted, blurred, focused });
const thickness = scale * (isContrastMode() ? 0.02 : 0.01) * NODE_BASE_SIZE;
const { id, path, highlighted, blurred, focused } = this.props;
const className = classNames('edge', {highlighted, blurred, focused});
// Draws the edge so that its thickness reflects the zoom scale.
// Edge shadow is always made 10x thicker than the edge itself.
return (
<g
id={id} className={className}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}>
<path className="shadow" d={path} style={{ strokeWidth: 10 * thickness }} />
<path className="link" d={path} style={{ strokeWidth: thickness }} />
className={className} onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave} id={id}>
<path d={path} className="shadow" />
<path d={path} className="link" />
</g>
);
}

View File

@@ -3,40 +3,29 @@ import { omit } from 'lodash';
import { connect } from 'react-redux';
import { Motion, spring } from 'react-motion';
import { NODES_SPRING_ANIMATION_CONFIG } from '../constants/animation';
import { NODE_BASE_SIZE, NODE_BLUR_OPACITY } from '../constants/styles';
import { round } from '../utils/math-utils';
import Node from './node';
const transformedNode = (otherProps, { x, y, k }) => (
<Node transform={`translate(${x},${y}) scale(${k})`} {...otherProps} />
);
class NodeContainer extends React.Component {
render() {
const { dx, dy, isAnimated, magnified, blurred } = this.props;
const forwardedProps = omit(this.props, 'dx', 'dy', 'isAnimated', 'magnified', 'blurred');
const opacity = blurred ? NODE_BLUR_OPACITY : 1;
const scale = magnified * NODE_BASE_SIZE;
const { dx, dy, focused, layoutPrecision, zoomScale } = this.props;
const animConfig = [80, 20]; // stiffness, damping
const scaleFactor = focused ? (1 / zoomScale) : 1;
const other = omit(this.props, 'dx', 'dy');
// NOTE: Controlling blurring from here seems to re-render faster
// than adding a CSS class and controlling it from there.
return (
<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>
<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>
);
}
}

View File

@@ -4,40 +4,50 @@ import { List as makeList } from 'immutable';
import { getNetworkColor } from '../utils/color-utils';
import { isContrastMode } from '../utils/contrast-utils';
// Min size is about a quarter of the width, feels about right.
const minBarWidth = 0.25;
const barHeight = 0.08;
const innerPadding = 0.04;
const borderRadius = 0.01;
// Gap size between bar segments.
const minBarHeight = 3;
const padding = 0.05;
const rx = 1;
const ry = rx;
const x = scaleBand();
function NodeNetworksOverlay({offset, stack, networks = makeList()}) {
const barWidth = Math.max(1, minBarWidth * networks.size);
const yPosition = offset - (barHeight * 0.5);
function NodeNetworksOverlay({offset, size, stack, networks = makeList()}) {
// Min size is about a quarter of the width, feels about right.
const minBarWidth = (size / 4);
const barWidth = Math.max(size, minBarWidth * networks.size);
const barHeight = Math.max(size * 0.085, minBarHeight);
// Update singleton scale.
x.domain(networks.map((n, i) => i).toJS());
x.range([barWidth * -0.5, barWidth * 0.5]);
x.paddingInner(innerPadding);
x.paddingInner(padding);
const bandwidth = x.bandwidth();
const bars = networks.map((n, i) => (
<rect
className="node-network"
key={n.get('id')}
x={x(i)}
y={yPosition}
width={bandwidth}
y={offset - (barHeight * 0.5)}
width={x.bandwidth()}
height={barHeight}
rx={borderRadius}
ry={borderRadius}
style={{ fill: getNetworkColor(n.get('colorKey', n.get('id'))) }}
rx={rx}
ry={ry}
className="node-network"
style={{
fill: getNetworkColor(n.get('colorKey', n.get('id')))
}}
key={n.get('id')}
/>
));
const translateY = stack && isContrastMode() ? 0.15 : 0;
let transform = '';
if (stack) {
const contrastMode = isContrastMode();
const [dx, dy] = contrastMode ? [0, 8] : [0, 0];
transform = `translate(${dx}, ${dy * -1.5})`;
}
return (
<g transform={`translate(0, ${translateY})`}>
<g transform={transform}>
{bars.toJS()}
</g>
);

View File

@@ -1,39 +1,31 @@
import React from 'react';
import classNames from 'classnames';
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';
import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils';
import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles';
export default function NodeShapeCircle({id, highlighted, color, metric}) {
const { height, hasMetric, formattedValue } = getMetricValue(metric);
const metricStyle = { fill: getMetricColor(metric) };
const className = classNames('shape', 'shape-circle', { metrics: hasMetric });
export default function NodeShapeCircle({id, highlighted, size, color, metric}) {
const clipId = `mask-${id}`;
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
const metricStyle = { fill: getMetricColor(metric) };
const className = classNames('shape', { metrics: hasMetric });
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
return (
<g className={className}>
{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 && 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 && <circle
r={size * 0.45}
className="metric-fill"
clipPath={`url(#${clipId})`}
style={metricStyle}
r={NODE_SHAPE_SHADOW_RADIUS}
clipPath={`url(#${clipId})`}
/>}
{renderMetricValue(formattedValue, highlighted && hasMetric)}
{highlighted && hasMetric ?
<text style={{fontSize}}>{formattedValue}</text> :
<circle className="node" r={Math.max(2, (size * 0.125))} />}
</g>
);
}

View File

@@ -1,27 +1,46 @@
import React from 'react';
import {
NODE_SHAPE_HIGHLIGHT_RADIUS,
NODE_SHAPE_BORDER_RADIUS,
NODE_SHAPE_SHADOW_RADIUS,
NODE_SHAPE_DOT_RADIUS,
} from '../constants/styles';
import { extent } from 'd3-array';
// This path is already normalized so no rescaling is needed.
const CLOUD_PATH = 'M-1.25 0.233Q-1.25 0.44-1.104 0.587-0.957 0.733-0.75 0.733H0.667Q0.908 '
+ '0.733 1.079 0.562 1.25 0.391 1.25 0.15 1.25-0.022 1.158-0.164 1.065-0.307 0.914-0.377q'
+ '0.003-0.036 0.003-0.056 0-0.276-0.196-0.472-0.195-0.195-0.471-0.195-0.206 0-0.373 0.115'
+ '-0.167 0.115-0.244 0.299-0.091-0.081-0.216-0.081-0.138 0-0.236 0.098-0.098 0.098-0.098 '
+ '0.236 0 0.098 0.054 0.179-0.168 0.039-0.278 0.175-0.109 0.136-0.109 0.312z';
import { isContrastMode } from '../utils/contrast-utils';
export default function NodeShapeCloud({highlighted, color}) {
const pathProps = r => ({ d: CLOUD_PATH, transform: `scale(${r})` });
const CLOUD_PATH = 'M 1920,384 Q 1920,225 1807.5,112.5 1695,0 1536,0 H 448 '
+ 'Q 263,0 131.5,131.5 0,263 0,448 0,580 71,689.5 142,799 258,853 '
+ 'q -2,28 -2,43 0,212 150,362 150,150 362,150 158,0 286.5,-88 128.5,-88 '
+ '187.5,-230 70,62 166,62 106,0 181,-75 75,-75 75,-181 0,-75 -41,-138 '
+ '129,-30 213,-134.5 84,-104.5 84,-239.5 z';
function toPoint(stringPair) {
return stringPair.split(',').map(p => parseFloat(p, 10));
}
function getExtents(svgPath) {
const points = svgPath.split(' ').filter(s => s.length > 1).map(toPoint);
return [extent(points, p => p[0]), extent(points, p => p[1])];
}
export default function NodeShapeCloud({highlighted, size, color}) {
const [[minx, maxx], [miny, maxy]] = getExtents(CLOUD_PATH);
const width = (maxx - minx);
const height = (maxy - miny);
const cx = width / 2;
const cy = height / 2;
const pathSize = (width + height) / 2;
const baseScale = (size * 2) / pathSize;
const strokeWidth = isContrastMode() ? 6 / baseScale : 4 / baseScale;
const pathProps = v => ({
d: CLOUD_PATH,
fill: 'none',
transform: `scale(-${v * baseScale}) translate(-${cx},-${cy})`,
strokeWidth
});
return (
<g className="shape shape-cloud">
{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} />
{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))} />
</g>
);
}

View File

@@ -1,41 +1,52 @@
import React from 'react';
import classNames from 'classnames';
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';
import { line, curveCardinalClosed } from 'd3-shape';
import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils';
import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles';
export default function NodeShapeHeptagon({ id, highlighted, color, metric }) {
const { height, hasMetric, formattedValue } = getMetricValue(metric);
const metricStyle = { fill: getMetricColor(metric) };
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 className = classNames('shape', 'shape-heptagon', { metrics: hasMetric });
const pathProps = r => ({ d: nodeShapePolygon(r, 7) });
const clipId = `mask-${id}`;
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
const metricStyle = { fill: getMetricColor(metric) };
const className = classNames('shape', { metrics: hasMetric });
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
const halfSize = size * 0.5;
return (
<g className={className}>
{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 && 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 && <path
className="metric-fill"
clipPath={`url(#${clipId})`}
style={metricStyle}
{...pathProps(NODE_SHAPE_SHADOW_RADIUS)}
{...pathProps(0.45)}
/>}
{renderMetricValue(formattedValue, highlighted && hasMetric)}
{highlighted && hasMetric ?
<text style={{fontSize}}>{formattedValue}</text> :
<circle className="node" r={Math.max(2, (size * 0.125))} />}
</g>
);
}

View File

@@ -1,41 +1,74 @@
import React from 'react';
import classNames from 'classnames';
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';
import { line, curveCardinalClosed } from 'd3-shape';
import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils';
import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles';
export default function NodeShapeHexagon({ id, highlighted, color, metric }) {
const { height, hasMetric, formattedValue } = getMetricValue(metric);
const metricStyle = { fill: getMetricColor(metric) };
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 className = classNames('shape', 'shape-hexagon', { metrics: hasMetric });
const pathProps = r => ({ d: nodeShapePolygon(r, 6) });
const clipId = `mask-${id}`;
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
const metricStyle = { fill: getMetricColor(metric) };
const className = classNames('shape', { metrics: hasMetric });
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
// how much the hex curve line interpolator curves outside the original shape definition in
// percent (very roughly)
const hexCurve = 0.05;
return (
<g className={className}>
{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 && 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 && <path
className="metric-fill"
clipPath={`url(#${clipId})`}
style={metricStyle}
{...pathProps(NODE_SHAPE_SHADOW_RADIUS)}
clipPath={`url(#${clipId})`}
{...pathProps(shadowSize)}
/>}
{renderMetricValue(formattedValue, highlighted && hasMetric)}
{highlighted && hasMetric ?
<text style={{fontSize}}>
{formattedValue}
</text> :
<circle className="node" r={Math.max(2, (size * 0.125))} />}
</g>
);
}

View File

@@ -1,47 +1,43 @@
import React from 'react';
import classNames from 'classnames';
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';
import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils';
import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles';
export default function NodeShapeSquare({ id, highlighted, color, rx = 0, ry = 0, metric }) {
const { height, hasMetric, formattedValue } = getMetricValue(metric);
const metricStyle = { fill: getMetricColor(metric) };
const className = classNames('shape', 'shape-square', { metrics: hasMetric });
const rectProps = (scale, borderRadiusAdjustmentFactor = 1) => ({
width: scale * 2,
height: scale * 2,
rx: scale * rx * borderRadiusAdjustmentFactor,
ry: scale * ry * borderRadiusAdjustmentFactor,
x: -scale,
y: -scale
export default function NodeShapeSquare({
id, highlighted, size, color, rx = 0, ry = 0, metric
}) {
const rectProps = (scale, radiusScale) => ({
width: scale * size * 2,
height: scale * size * 2,
rx: (radiusScale || scale) * size * rx,
ry: (radiusScale || scale) * size * ry,
x: -size * scale,
y: -size * scale
});
const clipId = `mask-${id}`;
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
const metricStyle = { fill: getMetricColor(metric) };
const className = classNames('shape', { metrics: hasMetric });
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
return (
<g className={className}>
{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 && 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 && <rect
className="metric-fill"
className="metric-fill" style={metricStyle}
clipPath={`url(#${clipId})`}
style={metricStyle}
{...rectProps(NODE_SHAPE_SHADOW_RADIUS, 0.85)}
{...rectProps(0.45, 0.39)}
/>}
{renderMetricValue(formattedValue, highlighted && hasMetric)}
{highlighted && hasMetric ?
<text style={{fontSize}}>
{formattedValue}
</text> :
<circle className="node" r={Math.max(2, (size * 0.125))} />}
</g>
);
}

View File

@@ -2,22 +2,25 @@ import React from 'react';
import { isContrastMode } from '../utils/contrast-utils';
export default function NodeShapeStack(props) {
const dy = isContrastMode() ? 0.15 : 0.1;
const highlightScale = [1, 1 + dy];
const contrastMode = isContrastMode();
const Shape = props.shape;
const [dx, dy] = contrastMode ? [0, 8] : [0, 5];
const dsx = (props.size + dx) / props.size;
const dsy = (props.size + dy) / props.size;
const hls = [dsx, dsy];
return (
<g transform={`translate(0, ${dy * -2.5})`} className="stack">
<g transform={`scale(${highlightScale}) translate(0, ${dy})`} className="highlight">
<g transform={`translate(${dx * -1}, ${dy * -2.5})`} className="stack">
<g transform={`scale(${hls})translate(${dx}, ${dy})`} className="onlyHighlight">
<Shape {...props} />
</g>
<g transform={`translate(0, ${dy * 2})`}>
<g transform={`translate(${dx * 2}, ${dy * 2})`}>
<Shape {...props} />
</g>
<g transform={`translate(0, ${dy * 1})`}>
<g transform={`translate(${dx * 1}, ${dy * 1})`}>
<Shape {...props} />
</g>
<g className="only-metrics">
<g className="onlyMetrics">
<Shape {...props} />
</g>
</g>

View File

@@ -15,8 +15,13 @@ import NodeShapeHexagon from './node-shape-hexagon';
import NodeShapeHeptagon from './node-shape-heptagon';
import NodeShapeCloud from './node-shape-cloud';
import NodeNetworksOverlay from './node-networks-overlay';
import { MIN_NODE_LABEL_SIZE, BASE_NODE_LABEL_SIZE, BASE_NODE_SIZE } from '../constants/styles';
function labelFontSize(nodeSize) {
return Math.max(MIN_NODE_LABEL_SIZE, (BASE_NODE_LABEL_SIZE / BASE_NODE_SIZE) * nodeSize);
}
function stackedShape(Shape) {
const factory = React.createFactory(NodeShapeStack);
return props => factory(Object.assign({}, props, {shape: Shape}));
@@ -38,72 +43,58 @@ function getNodeShape({ shape, stack }) {
return stack ? stackedShape(nodeShape) : nodeShape;
}
function svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) {
return (
<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.state = {
hovered: false,
matched: false
};
this.handleMouseClick = this.handleMouseClick.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.saveShapeRef = this.saveShapeRef.bind(this);
this.state = {
hovered: false,
matched: false
};
}
componentWillReceiveProps(nextProps) {
// marks as matched only when search query changes
if (nextProps.searchQuery !== this.props.searchQuery) {
this.setState({ matched: nextProps.matched });
this.setState({
matched: nextProps.matched
});
} else {
this.setState({ matched: false });
this.setState({
matched: false
});
}
}
renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) {
const { label, subLabel } = this.props;
return (
<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, networks, pseudo, rank, label,
transform, exportingGraph, showingNetworks, stack } = this.props;
const { blurred, focused, highlighted, label, matches = makeMap(), networks,
pseudo, rank, subLabel, scaleFactor, transform, exportingGraph,
showingNetworks, stack } = this.props;
const { hovered, matched } = this.state;
const nodeScale = focused ? this.props.selectedNodeScale : this.props.nodeScale;
const color = getNodeColor(rank, label, pseudo);
const truncate = !focused && !hovered;
const labelOffsetY = (showingNetworks && networks) ? 40 : 28;
const networkOffset = 0.67;
const labelWidth = nodeScale(scaleFactor * 3);
const labelOffsetX = -labelWidth / 2;
const labelDy = (showingNetworks && networks) ? 0.70 : 0.55;
const labelOffsetY = nodeScale(labelDy * scaleFactor);
const networkOffset = nodeScale(scaleFactor * 0.67);
const nodeClassName = classnames('node', {
highlighted,
@@ -118,25 +109,51 @@ class Node extends React.Component {
const NodeShapeType = getNodeShape(this.props);
const useSvgLabels = exportingGraph;
const size = nodeScale(scaleFactor);
const fontSize = labelFontSize(size);
const mouseEvents = {
onClick: this.handleMouseClick,
onMouseEnter: this.handleMouseEnter,
onMouseLeave: this.handleMouseLeave,
};
const matchedNodeDetails = matches.get('metadata', makeList())
.concat(matches.get('parents', makeList()));
return (
<g className={nodeClassName} transform={transform}>
{useSvgLabels || false ?
this.renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) :
this.renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents)}
{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>}
<g {...mouseEvents} ref={this.saveShapeRef}>
<NodeShapeType color={color} {...this.props} />
<NodeShapeType
size={size}
color={color}
{...this.props} />
</g>
{showingNetworks && <NodeNetworksOverlay
offset={networkOffset}
networks={networks}
size={size} networks={networks}
stack={stack}
/>}
</g>

View File

@@ -7,9 +7,9 @@ import EdgeContainer from './edge-container';
class NodesChartEdges extends React.Component {
render() {
const { hasSelectedNode, highlightedEdgeIds, layoutEdges, searchQuery,
isAnimated, selectedScale, selectedNodeId, selectedNetwork, selectedNetworkNodes,
searchNodeMatches = makeMap() } = this.props;
const { hasSelectedNode, highlightedEdgeIds, layoutEdges,
layoutPrecision, searchNodeMatches = makeMap(), searchQuery,
selectedNodeId, selectedNetwork, selectedNetworkNodes } = this.props;
return (
<g className="nodes-chart-edges">
@@ -35,11 +35,10 @@ class NodesChartEdges extends React.Component {
id={edge.get('id')}
source={edge.get('source')}
target={edge.get('target')}
waypoints={edge.get('points')}
scale={focused ? selectedScale : 1}
isAnimated={isAnimated}
points={edge.get('points')}
blurred={blurred}
focused={focused}
layoutPrecision={layoutPrecision}
highlighted={highlighted}
/>
);

View File

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

View File

@@ -7,9 +7,12 @@ import NodeContainer from './node-container';
class NodesChartNodes extends React.Component {
render() {
const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId,
selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId,
topCardNode, searchNodeMatches = makeMap() } = this.props;
const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision,
mouseOverNodeId, nodeScale, scale, searchNodeMatches = makeMap(),
searchQuery, selectedMetric, selectedNetwork, selectedNodeScale, selectedNodeId,
topCardNode } = this.props;
const zoomScale = scale;
// highlighter functions
const setHighlighted = node => node.set('highlighted',
@@ -70,11 +73,12 @@ class NodesChartNodes extends React.Component {
subLabel={node.get('subLabel')}
metric={metric(node)}
rank={node.get('rank')}
isAnimated={isAnimated}
magnified={node.get('focused') ? selectedScale : 1}
layoutPrecision={layoutPrecision}
selectedNodeScale={selectedNodeScale}
nodeScale={nodeScale}
zoomScale={zoomScale}
dx={node.get('x')}
dy={node.get('y')}
/>)}
dy={node.get('y')} />)}
</g>
);
}

View File

@@ -1,63 +1,272 @@
import debug from 'debug';
import React from 'react';
import { connect } from 'react-redux';
import { assign, pick } from 'lodash';
import { Map as makeMap } from 'immutable';
import { assign, pick, includes } from 'lodash';
import { Map as makeMap, fromJS } from 'immutable';
import timely from 'timely';
import { scaleThreshold, scaleLinear } from 'd3-scale';
import { event as d3Event, select } from 'd3-selection';
import { zoom, zoomIdentity } from 'd3-zoom';
import { nodeAdjacenciesSelector, adjacentNodesSelector } from '../selectors/chartSelectors';
import { clickBackground } from '../actions/app-actions';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { MIN_NODE_SIZE, DETAILS_PANEL_WIDTH, MAX_NODE_SIZE } from '../constants/styles';
import Logo from '../components/logo';
import { doLayout } from './nodes-layout';
import NodesChartElements from './nodes-chart-elements';
import { getActiveTopologyOptions, zoomCacheKey } from '../utils/topology-utils';
import { getActiveTopologyOptions } from '../utils/topology-utils';
import { topologyZoomState } from '../selectors/nodes-chart-zoom';
import { layoutWithSelectedNode } from '../selectors/nodes-chart-focus';
import { graphLayout } from '../selectors/nodes-chart-layout';
const log = debug('scope:nodes-chart');
const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY'];
// make sure circular layouts a bit denser with 3-6 nodes
const radiusDensity = scaleThreshold()
.domain([3, 6])
.range([2.5, 3.5, 3]);
/**
* dynamic coords precision based on topology size
*/
function getLayoutPrecision(nodesCount) {
let precision;
if (nodesCount >= 50) {
precision = 0;
} else if (nodesCount > 20) {
precision = 1;
} else if (nodesCount > 10) {
precision = 2;
} else {
precision = 3;
}
return precision;
}
const GRAPH_COMPLEXITY_NODES_TRESHOLD = 100;
const ZOOM_CACHE_FIELDS = [
'panTranslateX', 'panTranslateY',
'zoomScale', 'minZoomScale', 'maxZoomScale'
];
function initEdges(nodes) {
let edges = makeMap();
nodes.forEach((node, nodeId) => {
const adjacency = node.get('adjacency');
if (adjacency) {
adjacency.forEach((adjacent) => {
const edge = [nodeId, adjacent];
const edgeId = edge.join(EDGE_ID_SEPARATOR);
if (!edges.has(edgeId)) {
const source = edge[0];
const target = edge[1];
if (nodes.has(source) && nodes.has(target)) {
edges = edges.set(edgeId, makeMap({
id: edgeId,
value: 1,
source,
target
}));
}
}
});
}
});
return edges;
}
function getNodeScale(nodesCount, width, height) {
const expanse = Math.min(height, width);
const nodeSize = expanse / 3; // single node should fill a third of the screen
const maxNodeSize = Math.min(MAX_NODE_SIZE, expanse / 10);
const normalizedNodeSize = Math.max(MIN_NODE_SIZE,
Math.min(nodeSize / Math.sqrt(nodesCount), maxNodeSize));
return scaleLinear().range([0, normalizedNodeSize]);
}
function updateLayout(width, height, nodes, baseOptions) {
const nodeScale = getNodeScale(nodes.size, width, height);
const edges = initEdges(nodes);
const options = Object.assign({}, baseOptions, {
scale: nodeScale,
});
const timedLayouter = timely(doLayout);
const graph = timedLayouter(nodes, edges, options);
log(`graph layout took ${timedLayouter.time}ms`);
const layoutNodes = graph.nodes.map(node => makeMap({
x: node.get('x'),
y: node.get('y'),
// extract coords and save for restore
px: node.get('x'),
py: node.get('y')
}));
const layoutEdges = graph.edges
.map(edge => edge.set('ppoints', edge.get('points')));
return { layoutNodes, layoutEdges, layoutWidth: graph.width, layoutHeight: graph.height };
}
function centerSelectedNode(props, state) {
let stateNodes = state.nodes;
let stateEdges = state.edges;
if (!stateNodes.has(props.selectedNodeId)) {
return {};
}
const adjacentNodes = props.adjacentNodes;
const adjacentLayoutNodeIds = [];
adjacentNodes.forEach((adjacentId) => {
// filter loopback
if (adjacentId !== props.selectedNodeId) {
adjacentLayoutNodeIds.push(adjacentId);
}
});
// move origin node to center of viewport
const zoomScale = state.scale;
const translate = [state.panTranslateX, state.panTranslateY];
const viewportHalfWidth = ((state.width + props.margins.left) - DETAILS_PANEL_WIDTH) / 2;
const viewportHalfHeight = (state.height + props.margins.top) / 2;
const centerX = (-translate[0] + viewportHalfWidth) / zoomScale;
const centerY = (-translate[1] + viewportHalfHeight) / zoomScale;
stateNodes = stateNodes.mergeIn([props.selectedNodeId], {
x: centerX,
y: centerY
});
// circle layout for adjacent nodes
const adjacentCount = adjacentLayoutNodeIds.length;
const density = radiusDensity(adjacentCount);
const radius = Math.min(state.width, state.height) / density / zoomScale;
const offsetAngle = Math.PI / 4;
stateNodes = stateNodes.map((node, nodeId) => {
const index = adjacentLayoutNodeIds.indexOf(nodeId);
if (index > -1) {
const angle = offsetAngle + ((Math.PI * 2 * index) / adjacentCount);
return node.merge({
x: centerX + (radius * Math.sin(angle)),
y: centerY + (radius * Math.cos(angle))
});
}
return node;
});
// fix all edges for circular nodes
stateEdges = stateEdges.map((edge) => {
if (edge.get('source') === props.selectedNodeId
|| edge.get('target') === props.selectedNodeId
|| includes(adjacentLayoutNodeIds, edge.get('source'))
|| includes(adjacentLayoutNodeIds, edge.get('target'))) {
const source = stateNodes.get(edge.get('source'));
const target = stateNodes.get(edge.get('target'));
return edge.set('points', fromJS([
{x: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')}
]));
}
return edge;
});
// auto-scale node size for selected nodes
const selectedNodeScale = getNodeScale(adjacentNodes.size, state.width, state.height);
return {
selectedNodeScale,
edges: stateEdges,
nodes: stateNodes
};
}
class NodesChart extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
layoutNodes: makeMap(),
layoutEdges: makeMap(),
zoomScale: 0,
minZoomScale: 0,
maxZoomScale: 0,
panTranslateX: 0,
panTranslateY: 0,
selectedScale: 1,
height: props.height || 0,
width: props.width || 0,
// TODO: Move zoomCache to global Redux state. Now that we store
// it here, it gets reset every time the component gets destroyed.
// That happens e.g. when we switch to a grid mode in one topology,
// which resets the zoom cache across all topologies, which is bad.
zoomCache: {},
};
this.handleMouseClick = this.handleMouseClick.bind(this);
this.zoomed = this.zoomed.bind(this);
this.state = {
edges: makeMap(),
nodes: makeMap(),
nodeScale: scaleLinear(),
panTranslateX: 0,
panTranslateY: 0,
scale: 1,
selectedNodeScale: scaleLinear(),
hasZoomed: false,
height: props.height || 0,
width: props.width || 0,
zoomCache: {},
};
}
componentWillMount() {
this.setState(graphLayout(this.state, this.props));
const state = this.updateGraphState(this.props, this.state);
this.setState(state);
}
componentWillReceiveProps(nextProps) {
// gather state, setState should be called only once here
const state = assign({}, this.state);
// wipe node states when showing different topology
if (nextProps.topologyId !== this.props.topologyId) {
// re-apply cached canvas zoom/pan to d3 behavior (or set the default values)
const defaultZoom = { scale: 1, panTranslateX: 0, panTranslateY: 0, hasZoomed: false };
const nextZoom = this.state.zoomCache[nextProps.topologyId] || defaultZoom;
if (nextZoom) {
this.setZoom(nextZoom);
}
// saving previous zoom state
const prevZoom = pick(this.state, ZOOM_CACHE_FIELDS);
const zoomCache = assign({}, this.state.zoomCache);
zoomCache[this.props.topologyId] = prevZoom;
// clear canvas and apply zoom state
assign(state, nextZoom, { zoomCache }, {
nodes: makeMap(),
edges: makeMap()
});
}
// reset layout dimensions only when forced
state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height);
state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width);
if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) {
assign(state, this.updateGraphState(nextProps, state));
}
if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
assign(state, this.restoreLayout(state));
}
if (nextProps.selectedNodeId) {
assign(state, centerSelectedNode(nextProps, state));
}
this.setState(state);
}
componentDidMount() {
// distinguish pan/zoom from click
this.isZooming = false;
this.zoom = zoom().on('zoom', this.zoomed);
this.zoom = zoom()
.scaleExtent([0.1, 2])
.on('zoom', this.zoomed);
this.svg = select('.nodes-chart svg');
this.svg.call(this.zoom);
@@ -73,40 +282,15 @@ class NodesChart extends React.Component {
.on('touchstart.zoom', null);
}
componentWillReceiveProps(nextProps) {
// Don't modify the original state, as we only want to call setState once at the end.
const state = assign({}, this.state);
// Reset layout dimensions only when forced (to prevent excessive rendering on resizing).
state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height);
state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width);
// Update the state with memoized graph layout information based on props nodes and edges.
assign(state, graphLayout(state, nextProps));
// Now that we have the graph layout information, we use it to create a default zoom
// settings for the current topology if we are rendering its layout for the first time, or
// otherwise we use the cached zoom information from local state for this topology layout.
assign(state, topologyZoomState(state, nextProps));
// Finally we update the layout state with the circular
// subgraph centered around the selected node (if there is one).
if (nextProps.selectedNodeId) {
assign(state, layoutWithSelectedNode(state, nextProps));
}
this.applyZoomState(state);
this.setState(state);
}
render() {
// Not passing transform into child components for perf reasons.
const { panTranslateX, panTranslateY, zoomScale } = this.state;
const transform = `translate(${panTranslateX}, ${panTranslateY}) scale(${zoomScale})`;
const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state;
// not passing translates into child components for perf reasons, use getTranslate instead
const translate = [panTranslateX, panTranslateY];
const transform = `translate(${translate}) scale(${scale})`;
const svgClassNames = this.props.isEmpty ? 'hide' : '';
const isAnimated = !this.isTopologyGraphComplex();
const layoutPrecision = getLayoutPrecision(nodes.size);
return (
<div className="nodes-chart">
<svg
@@ -116,11 +300,13 @@ class NodesChart extends React.Component {
<Logo />
</g>
<NodesChartElements
layoutNodes={this.state.layoutNodes}
layoutEdges={this.state.layoutEdges}
selectedScale={this.state.selectedScale}
layoutNodes={nodes}
layoutEdges={edges}
nodeScale={this.state.nodeScale}
scale={scale}
transform={transform}
isAnimated={isAnimated} />
selectedNodeScale={this.state.selectedNodeScale}
layoutPrecision={layoutPrecision} />
</svg>
</div>
);
@@ -134,39 +320,81 @@ class NodesChart extends React.Component {
}
}
isTopologyGraphComplex() {
return this.state.layoutNodes.size > GRAPH_COMPLEXITY_NODES_TRESHOLD;
restoreLayout(state) {
// undo any pan/zooming that might have happened
this.setZoom(state);
const nodes = state.nodes.map(node => node.merge({
x: node.get('px'),
y: node.get('py')
}));
const edges = state.edges.map((edge) => {
if (edge.has('ppoints')) {
return edge.set('points', edge.get('ppoints'));
}
return edge;
});
return { edges, nodes };
}
cacheZoomState(state) {
const zoomState = pick(state, ZOOM_CACHE_FIELDS);
const zoomCache = assign({}, state.zoomCache);
zoomCache[zoomCacheKey(this.props)] = zoomState;
return { zoomCache };
}
updateGraphState(props, state) {
if (props.nodes.size === 0) {
return {
nodes: makeMap(),
edges: makeMap()
};
}
applyZoomState({ zoomScale, minZoomScale, maxZoomScale, panTranslateX, panTranslateY }) {
this.zoom = this.zoom.scaleExtent([minZoomScale, maxZoomScale]);
this.svg.call(this.zoom.transform, zoomIdentity
.translate(panTranslateX, panTranslateY)
.scale(zoomScale));
const options = {
width: state.width,
height: state.height,
margins: props.margins,
forceRelayout: props.forceRelayout,
topologyId: props.topologyId,
topologyOptions: props.topologyOptions,
};
const { layoutNodes, layoutEdges, layoutWidth, layoutHeight } = updateLayout(
state.width, state.height, props.nodes, options);
//
// adjust layout based on viewport
const xFactor = (state.width - props.margins.left - props.margins.right) / layoutWidth;
const yFactor = state.height / layoutHeight;
const zoomFactor = Math.min(xFactor, yFactor);
let zoomScale = state.scale;
if (this.svg && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
zoomScale = zoomFactor;
}
return {
scale: zoomScale,
nodes: layoutNodes,
edges: layoutEdges,
nodeScale: getNodeScale(props.nodes.size, state.width, state.height),
};
}
zoomed() {
this.isZooming = true;
// don't pan while node is selected
// dont pan while node is selected
if (!this.props.selectedNodeId) {
let state = assign({}, this.state, {
this.setState({
hasZoomed: true,
panTranslateX: d3Event.transform.x,
panTranslateY: d3Event.transform.y,
zoomScale: d3Event.transform.k
scale: d3Event.transform.k
});
// Cache the zoom state as soon as it changes as it is cheap, and makes us
// be able to skip difficult conditions on when this caching should happen.
state = assign(state, this.cacheZoomState(state));
this.setState(state);
}
}
setZoom(newZoom) {
this.svg.call(this.zoom.transform, zoomIdentity
.translate(newZoom.panTranslateX, newZoom.panTranslateY)
.scale(newZoom.scale));
}
}
@@ -177,7 +405,7 @@ function mapStateToProps(state) {
forceRelayout: state.get('forceRelayout'),
selectedNodeId: state.get('selectedNodeId'),
topologyId: state.get('currentTopologyId'),
topologyOptions: getActiveTopologyOptions(state),
topologyOptions: getActiveTopologyOptions(state)
};
}

View File

@@ -2,7 +2,6 @@ import dagre from 'dagre';
import debug from 'debug';
import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable';
import { NODE_BASE_SIZE } from '../constants/styles';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { featureIsEnabledAny } from '../utils/feature-utils';
import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils';
@@ -13,9 +12,10 @@ const topologyCaches = {};
export const DEFAULT_WIDTH = 800;
export const DEFAULT_HEIGHT = DEFAULT_WIDTH / 2;
export const DEFAULT_MARGINS = {top: 0, left: 0};
const NODE_SIZE_FACTOR = NODE_BASE_SIZE;
const NODE_SEPARATION_FACTOR = 2 * NODE_BASE_SIZE;
const RANK_SEPARATION_FACTOR = 3 * NODE_BASE_SIZE;
const DEFAULT_SCALE = val => val * 2;
const NODE_SIZE_FACTOR = 1;
const NODE_SEPARATION_FACTOR = 2.0;
const RANK_SEPARATION_FACTOR = 3.0;
let layoutRuns = 0;
let layoutRunsTrivial = 0;
@@ -34,16 +34,19 @@ function fromGraphNodeId(encodedId) {
* @param {Object} graph dagre graph instance
* @param {Map} imNodes new node set
* @param {Map} imEdges new edge set
* @param {Object} opts dimensions, scales, etc.
* @return {Object} Layout with nodes, edges, dimensions
*/
function runLayoutEngine(graph, imNodes, imEdges) {
function runLayoutEngine(graph, imNodes, imEdges, opts) {
let nodes = imNodes;
let edges = imEdges;
const ranksep = RANK_SEPARATION_FACTOR;
const nodesep = NODE_SEPARATION_FACTOR;
const nodeWidth = NODE_SIZE_FACTOR;
const nodeHeight = NODE_SIZE_FACTOR;
const options = opts || {};
const scale = options.scale || DEFAULT_SCALE;
const ranksep = scale(RANK_SEPARATION_FACTOR);
const nodesep = scale(NODE_SEPARATION_FACTOR);
const nodeWidth = scale(NODE_SIZE_FACTOR);
const nodeHeight = scale(NODE_SIZE_FACTOR);
// configure node margins
graph.setGraph({
@@ -151,10 +154,12 @@ function setSimpleEdgePoints(edge, nodeCache) {
* @param {object} opts Options
* @return {object} new layout object
*/
export function doLayoutNewNodesOfExistingRank(layout, nodeCache) {
export function doLayoutNewNodesOfExistingRank(layout, nodeCache, opts) {
const result = Object.assign({}, layout);
const nodesep = NODE_SEPARATION_FACTOR;
const nodeWidth = NODE_SIZE_FACTOR;
const options = opts || {};
const scale = options.scale || DEFAULT_SCALE;
const nodesep = scale(NODE_SEPARATION_FACTOR);
const nodeWidth = scale(NODE_SIZE_FACTOR);
// determine new nodes
const oldNodes = ImmSet.fromKeys(nodeCache);
@@ -195,10 +200,11 @@ function layoutSingleNodes(layout, opts) {
const result = Object.assign({}, layout);
const options = opts || {};
const margins = options.margins || DEFAULT_MARGINS;
const ranksep = RANK_SEPARATION_FACTOR / 2; // dagre splits it in half
const nodesep = NODE_SEPARATION_FACTOR;
const nodeWidth = NODE_SIZE_FACTOR;
const nodeHeight = NODE_SIZE_FACTOR;
const scale = options.scale || DEFAULT_SCALE;
const ranksep = scale(RANK_SEPARATION_FACTOR) / 2; // dagre splits it in half
const nodesep = scale(NODE_SEPARATION_FACTOR);
const nodeWidth = scale(NODE_SIZE_FACTOR);
const nodeHeight = scale(NODE_SIZE_FACTOR);
const graphHeight = layout.graphHeight || layout.height;
const graphWidth = layout.graphWidth || layout.width;
const aspectRatio = graphHeight ? graphWidth / graphHeight : 1;
@@ -265,6 +271,50 @@ function layoutSingleNodes(layout, opts) {
return result;
}
/**
* Shifts all coordinates of node and edge points to make the layout more centered
* @param {Object} layout Layout
* @param {Object} opts Options with width and margins
* @return {Object} modified layout
*/
export function shiftLayoutToCenter(layout, opts) {
const result = Object.assign({}, layout);
const options = opts || {};
const margins = options.margins || DEFAULT_MARGINS;
const width = options.width || DEFAULT_WIDTH;
const height = options.height || DEFAULT_HEIGHT;
let offsetX = 0 + margins.left;
let offsetY = 0 + margins.top;
if (layout.width < width) {
const xMin = layout.nodes.minBy(n => n.get('x'));
const xMax = layout.nodes.maxBy(n => n.get('x'));
offsetX = ((width - (xMin.get('x') + xMax.get('x'))) / 2) + margins.left;
}
if (layout.height < height) {
const yMin = layout.nodes.minBy(n => n.get('y'));
const yMax = layout.nodes.maxBy(n => n.get('y'));
offsetY = ((height - (yMin.get('y') + yMax.get('y'))) / 2) + margins.top;
}
if (offsetX || offsetY) {
result.nodes = layout.nodes.map(node => node.merge({
x: node.get('x') + offsetX,
y: node.get('y') + offsetY
}));
result.edges = layout.edges.map(edge => edge.update('points',
points => points.map(point => point.merge({
x: point.get('x') + offsetX,
y: point.get('y') + offsetY
}))
));
}
return result;
}
/**
* Determine if nodes were added between node sets
* @param {Map} nodes new Map of nodes
@@ -428,16 +478,17 @@ export function doLayout(immNodes, immEdges, opts) {
log('skip layout, used rank-based insertion');
layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges);
layout = copyLayoutProperties(layout, nodeCache, edgeCache);
layout = doLayoutNewNodesOfExistingRank(layout, nodeCache);
layout = doLayoutNewNodesOfExistingRank(layout, nodeCache, opts);
} else {
const graph = cache.graph;
layout = runLayoutEngine(graph, nodesWithDegrees, immEdges);
layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts);
if (!layout) {
return layout;
}
}
layout = layoutSingleNodes(layout, opts);
layout = shiftLayoutToCenter(layout, opts);
}
// cache results

View File

@@ -6,6 +6,7 @@ import { line, curveLinear } from 'd3-shape';
import { scaleLinear } from 'd3-scale';
import { formatMetricSvg } from '../utils/string-utils';
import { round } from '../utils/math-utils';
export default class Sparkline extends React.Component {
@@ -63,7 +64,7 @@ export default class Sparkline extends React.Component {
const min = formatMetricSvg(d3Min(data, d => d.value), this.props);
const max = formatMetricSvg(d3Max(data, d => d.value), this.props);
const mean = formatMetricSvg(d3Mean(data, d => d.value), this.props);
const title = `Last ${Math.round((lastDate - firstDate) / 1000)} seconds, ` +
const title = `Last ${round((lastDate - firstDate) / 1000)} seconds, ` +
`${data.length} samples, min: ${min}, max: ${max}, mean: ${mean}`;
return {title, lastX, lastY, data};

View File

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

View File

@@ -18,18 +18,14 @@ export const CANVAS_MARGINS = {
bottom: 100,
};
// Node shapes
export const NODE_SHAPE_HIGHLIGHT_RADIUS = 0.7;
export const NODE_SHAPE_BORDER_RADIUS = 0.5;
export const NODE_SHAPE_SHADOW_RADIUS = 0.45;
export const NODE_SHAPE_DOT_RADIUS = 0.125;
export const NODE_BLUR_OPACITY = 0.2;
// NOTE: Modifying this value shouldn't actually change much in the way
// nodes are rendered, as long as its kept >> 1. The idea was to draw all
// the nodes in a unit scale and control their size just through scaling
// transform, but the problem is that dagre only works with integer coordinates,
// so this constant basically serves as a precision factor for dagre.
export const NODE_BASE_SIZE = 100;
//
// The base size the shapes were defined at matches nicely w/ a 14px font.
//
export const BASE_NODE_SIZE = 64;
export const MIN_NODE_SIZE = 24;
export const MAX_NODE_SIZE = 96;
export const BASE_NODE_LABEL_SIZE = 14;
export const MIN_NODE_LABEL_SIZE = 12;
// Node details table constants
export const NODE_DETAILS_TABLE_CW = {

View File

@@ -2,6 +2,8 @@ import React from 'react';
import { isoParse as parseDate } from 'd3-time-format';
import { OrderedMap } from 'immutable';
import { round } from '../utils/math-utils';
const makeOrderedMap = OrderedMap;
const sortDate = (v, d) => d;
const DEFAULT_TICK_INTERVAL = 1000; // DEFAULT_TICK_INTERVAL + renderTime < 1000ms
@@ -102,7 +104,7 @@ export default ComposedComponent => class extends React.Component {
let lastIndex = bufferKeys.indexOf(movingLast);
// speed up the window if it falls behind
const step = lastIndex > 0 ? Math.round(buffer.size / lastIndex) : 1;
const step = lastIndex > 0 ? round(buffer.size / lastIndex) : 1;
// only move first if we have enough values in window
const windowLength = lastIndex - firstIndex;

View File

@@ -7,15 +7,8 @@ import ActionTypes from '../constants/action-types';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { applyPinnedSearches, updateNodeMatches } from '../utils/search-utils';
import { getNetworkNodes, getAvailableNetworks } from '../utils/network-view-utils';
import {
findTopologyById,
getAdjacentNodes,
setTopologyUrlsById,
updateTopologyIds,
filterHiddenTopologies,
addTopologyFullname,
getDefaultTopology,
graphExceedsComplexityThresh
import { findTopologyById, getAdjacentNodes, setTopologyUrlsById, updateTopologyIds,
filterHiddenTopologies, addTopologyFullname, getDefaultTopology, graphExceedsComplexityThresh
} from '../utils/topology-utils';
const log = debug('scope:app-store');

View File

@@ -1,149 +0,0 @@
import { includes, without } from 'lodash';
import { fromJS } from 'immutable';
import { createSelector } from 'reselect';
import { scaleThreshold } from 'd3-scale';
import { NODE_BASE_SIZE, DETAILS_PANEL_WIDTH } from '../constants/styles';
const circularOffsetAngle = Math.PI / 4;
// make sure circular layouts a bit denser with 3-6 nodes
const radiusDensity = scaleThreshold()
.domain([3, 6])
.range([2.5, 3.5, 3]);
const layoutNodesSelector = state => state.layoutNodes;
const layoutEdgesSelector = state => state.layoutEdges;
const stateWidthSelector = state => state.width;
const stateHeightSelector = state => state.height;
const stateScaleSelector = state => state.zoomScale;
const stateTranslateXSelector = state => state.panTranslateX;
const stateTranslateYSelector = state => state.panTranslateY;
const propsSelectedNodeIdSelector = (_, props) => props.selectedNodeId;
const propsAdjacentNodesSelector = (_, props) => props.adjacentNodes;
const propsMarginsSelector = (_, props) => props.margins;
// The narrower dimension of the viewport, used for scaling.
const viewportExpanseSelector = createSelector(
[
stateWidthSelector,
stateHeightSelector,
],
(width, height) => Math.min(width, height)
);
// Coordinates of the viewport center (when the details
// panel is open), used for focusing the selected node.
const viewportCenterSelector = createSelector(
[
stateWidthSelector,
stateHeightSelector,
stateTranslateXSelector,
stateTranslateYSelector,
stateScaleSelector,
propsMarginsSelector,
],
(width, height, translateX, translateY, scale, margins) => {
const viewportHalfWidth = ((width + margins.left) - DETAILS_PANEL_WIDTH) / 2;
const viewportHalfHeight = (height + margins.top) / 2;
return {
x: (-translateX + viewportHalfWidth) / scale,
y: (-translateY + viewportHalfHeight) / scale,
};
}
);
// List of all the adjacent nodes to the selected
// one, excluding itself (in case of loops).
const selectedNodeNeighborsIdsSelector = createSelector(
[
propsSelectedNodeIdSelector,
propsAdjacentNodesSelector,
],
(selectedNodeId, adjacentNodes) => without(adjacentNodes.toArray(), selectedNodeId)
);
const selectedNodesLayoutSettingsSelector = createSelector(
[
selectedNodeNeighborsIdsSelector,
viewportExpanseSelector,
stateScaleSelector,
],
(circularNodesIds, viewportExpanse, scale) => {
const circularNodesCount = circularNodesIds.length;
// Here we calculate the zoom factor of the nodes that get selected into focus.
// The factor is a somewhat arbitrary function (based on what looks good) of the
// viewport dimensions and the number of nodes in the circular layout. The idea
// is that the node should never be zoomed more than to cover 1/3 of the viewport
// (`maxScale`) and then the factor gets decresed asymptotically to the inverse
// square of the number of circular nodes, with a little constant push to make
// the layout more stable for a small number of nodes. Finally, the zoom factor is
// divided by the zoom factor applied to the whole topology layout to cancel it out.
const maxScale = viewportExpanse / NODE_BASE_SIZE / 3;
const shrinkFactor = Math.sqrt(circularNodesCount + 10);
const selectedScale = maxScale / shrinkFactor / scale;
// Following a similar logic as above, we set the radius of the circular
// layout based on the viewport dimensions and the number of circular nodes.
const circularRadius = viewportExpanse / radiusDensity(circularNodesCount) / scale;
const circularInnerAngle = (2 * Math.PI) / circularNodesCount;
return { selectedScale, circularRadius, circularInnerAngle };
}
);
export const layoutWithSelectedNode = createSelector(
[
layoutNodesSelector,
layoutEdgesSelector,
viewportCenterSelector,
propsSelectedNodeIdSelector,
selectedNodeNeighborsIdsSelector,
selectedNodesLayoutSettingsSelector,
],
(layoutNodes, layoutEdges, viewportCenter, selectedNodeId, neighborsIds, layoutSettings) => {
// Do nothing if the layout doesn't contain the selected node anymore.
if (!layoutNodes.has(selectedNodeId)) {
return {};
}
const { selectedScale, circularRadius, circularInnerAngle } = layoutSettings;
// Fix the selected node in the viewport center.
layoutNodes = layoutNodes.mergeIn([selectedNodeId], viewportCenter);
// Put the nodes that are adjacent to the selected one in a circular layout around it.
layoutNodes = layoutNodes.map((node, nodeId) => {
const index = neighborsIds.indexOf(nodeId);
if (index > -1) {
const angle = circularOffsetAngle + (index * circularInnerAngle);
return node.merge({
x: viewportCenter.x + (circularRadius * Math.sin(angle)),
y: viewportCenter.y + (circularRadius * Math.cos(angle))
});
}
return node;
});
// Update the edges in the circular layout to link the nodes in a straight line.
layoutEdges = layoutEdges.map((edge) => {
if (edge.get('source') === selectedNodeId
|| edge.get('target') === selectedNodeId
|| includes(neighborsIds, edge.get('source'))
|| includes(neighborsIds, edge.get('target'))) {
const source = layoutNodes.get(edge.get('source'));
const target = layoutNodes.get(edge.get('target'));
return edge.set('points', fromJS([
{x: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')}
]));
}
return edge;
});
return { layoutNodes, layoutEdges, selectedScale };
}
);

View File

@@ -1,94 +0,0 @@
import debug from 'debug';
import { createSelector } from 'reselect';
import { Map as makeMap } from 'immutable';
import timely from 'timely';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { doLayout } from '../charts/nodes-layout';
const log = debug('scope:nodes-chart');
const stateWidthSelector = state => state.width;
const stateHeightSelector = state => state.height;
const inputNodesSelector = (_, props) => props.nodes;
const propsMarginsSelector = (_, props) => props.margins;
const forceRelayoutSelector = (_, props) => props.forceRelayout;
const topologyIdSelector = (_, props) => props.topologyId;
const topologyOptionsSelector = (_, props) => props.topologyOptions;
function initEdgesFromNodes(nodes) {
let edges = makeMap();
nodes.forEach((node, nodeId) => {
const adjacency = node.get('adjacency');
if (adjacency) {
adjacency.forEach((adjacent) => {
const edge = [nodeId, adjacent];
const edgeId = edge.join(EDGE_ID_SEPARATOR);
if (!edges.has(edgeId)) {
const source = edge[0];
const target = edge[1];
if (nodes.has(source) && nodes.has(target)) {
edges = edges.set(edgeId, makeMap({
id: edgeId,
value: 1,
source,
target
}));
}
}
});
}
});
return edges;
}
const layoutOptionsSelector = createSelector(
[
stateWidthSelector,
stateHeightSelector,
propsMarginsSelector,
forceRelayoutSelector,
topologyIdSelector,
topologyOptionsSelector,
],
(width, height, margins, forceRelayout, topologyId, topologyOptions) => (
{ width, height, margins, forceRelayout, topologyId, topologyOptions }
)
);
export const graphLayout = createSelector(
[
inputNodesSelector,
layoutOptionsSelector,
],
(nodes, options) => {
// If the graph is empty, skip computing the layout.
if (nodes.size === 0) {
return {
layoutNodes: makeMap(),
layoutEdges: makeMap(),
};
}
const edges = initEdgesFromNodes(nodes);
const timedLayouter = timely(doLayout);
const graph = timedLayouter(nodes, edges, options);
// NOTE: We probably shouldn't log anything in a
// computed property, but this is still useful.
log(`graph layout calculation took ${timedLayouter.time}ms`);
const layoutEdges = graph.edges;
const layoutNodes = graph.nodes.map(node => makeMap({
x: node.get('x'),
y: node.get('y'),
}));
return { layoutNodes, layoutEdges };
}
);

View File

@@ -1,74 +0,0 @@
import { createSelector } from 'reselect';
import { NODE_BASE_SIZE } from '../constants/styles';
import { zoomCacheKey } from '../utils/topology-utils';
const layoutNodesSelector = state => state.layoutNodes;
const stateWidthSelector = state => state.width;
const stateHeightSelector = state => state.height;
const propsMarginsSelector = (_, props) => props.margins;
const cachedZoomStateSelector = (state, props) => state.zoomCache[zoomCacheKey(props)];
const viewportWidthSelector = createSelector(
[
stateWidthSelector,
propsMarginsSelector,
],
(width, margins) => width - margins.left - margins.right
);
const viewportHeightSelector = createSelector(
[
stateHeightSelector,
propsMarginsSelector,
],
(height, margins) => height - margins.top
);
// Compute the default zoom settings for the given graph layout.
const defaultZoomSelector = createSelector(
[
layoutNodesSelector,
viewportWidthSelector,
viewportHeightSelector,
propsMarginsSelector,
],
(layoutNodes, width, height, margins) => {
if (layoutNodes.size === 0) {
return {};
}
const xMin = layoutNodes.minBy(n => n.get('x')).get('x');
const xMax = layoutNodes.maxBy(n => n.get('x')).get('x');
const yMin = layoutNodes.minBy(n => n.get('y')).get('y');
const yMax = layoutNodes.maxBy(n => n.get('y')).get('y');
const xFactor = width / (xMax - xMin);
const yFactor = height / (yMax - yMin);
// Maximal allowed zoom will always be such that a node covers 1/5 of the viewport.
const maxZoomScale = Math.min(width, height) / NODE_BASE_SIZE / 5;
// Initial zoom is such that the graph covers 90% of either
// the viewport, respecting the maximal zoom constraint.
const zoomScale = Math.min(xFactor, yFactor, maxZoomScale) * 0.9;
// Finally, we always allow zooming out exactly 5x compared to the initial zoom.
const minZoomScale = zoomScale / 5;
// This translation puts the graph in the center of the viewport, respecting the margins.
const panTranslateX = ((width - ((xMax + xMin) * zoomScale)) / 2) + margins.left;
const panTranslateY = ((height - ((yMax + yMin) * zoomScale)) / 2) + margins.top;
return { zoomScale, minZoomScale, maxZoomScale, panTranslateX, panTranslateY };
}
);
// Use the cache to get the last zoom state for the selected topology,
// otherwise use the default zoom options computed from the graph layout.
export const topologyZoomState = createSelector(
[
cachedZoomStateSelector,
defaultZoomSelector,
],
(cachedZoomState, defaultZoomState) => cachedZoomState || defaultZoomState
);

View File

@@ -19,4 +19,21 @@ describe('MathUtils', () => {
expect(f(-5, 5)).toBe(0);
});
});
describe('round', () => {
const f = MathUtils.round;
it('it should round the decimal number to given precision', () => {
expect(f(-173.6499023, -2)).toBe(-200);
expect(f(-173.6499023, -1)).toBe(-170);
expect(f(-173.6499023, 0)).toBe(-174);
expect(f(-173.6499023)).toBe(-174);
expect(f(-173.6499023, 1)).toBe(-173.6);
expect(f(-173.6499023, 2)).toBe(-173.65);
expect(f(0.0013, 2)).toBe(0);
expect(f(0.0013, 3)).toBe(0.001);
expect(f(0.0013, 4)).toBe(0.0013);
expect(f(0.0013, 5)).toBe(0.0013);
});
});
});

View File

@@ -18,3 +18,10 @@
export function modulo(i, n) {
return ((i % n) + n) % n;
}
// Does the same that the deprecated d3.round was doing.
// Possibly imprecise: This https://github.com/d3/d3/issues/210
export function round(value, decimals = 0) {
const p = Math.pow(10, decimals);
return Math.round(value * p) / p;
}

View File

@@ -2,30 +2,32 @@ import { includes } from 'lodash';
import { scaleLog } from 'd3-scale';
import React from 'react';
import { NODE_SHAPE_DOT_RADIUS } from '../constants/styles';
import { formatMetricSvg } from './string-utils';
import { colors } from './color-utils';
export function getClipPathDefinition(clipId, height) {
export function getClipPathDefinition(clipId, size, height,
x = -size * 0.5, y = (size * 0.5) - height) {
return (
<defs>
<clipPath id={clipId}>
<rect width={1} height={1} x={-0.5} y={0.5 - height} />
<rect
width={size}
height={size}
x={x}
y={y}
/>
</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) {
export function getMetricValue(metric, size) {
if (!metric) {
return {height: 0, value: null, formattedValue: 'n/a'};
}
@@ -46,9 +48,10 @@ export function getMetricValue(metric) {
} else if (displayedValue >= m.max && displayedValue > 0) {
displayedValue = 1;
}
const height = size * displayedValue;
return {
height: displayedValue,
height,
hasMetric: value !== null,
formattedValue: formatMetricSvg(value, m)
};

View File

@@ -1,12 +0,0 @@
import { line, curveCardinalClosed } from 'd3-shape';
import range from 'lodash/range';
const shapeSpline = line().curve(curveCardinalClosed.tension(0.65));
export function nodeShapePolygon(radius, n) {
const innerAngle = (2 * Math.PI) / n;
return shapeSpline(range(0, n).map(k => [
radius * Math.sin(k * innerAngle),
-radius * Math.cos(k * innerAngle)
]));
}

View File

@@ -182,7 +182,3 @@ export function graphExceedsComplexityThresh(stats) {
// Check to see if complexity is high. Used to trigger table view on page load.
return (stats.get('node_count') + (2 * stats.get('edge_count'))) > 500;
}
export function zoomCacheKey(props) {
return `${props.topologyId}-${JSON.stringify(props.topologyOptions)}`;
}

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,14 +316,6 @@
color: $text-color;
}
.node-labels-container {
transform: scale($node-text-scale);
pointer-events: none;
height: 5em;
x: -0.5 * $node-labels-max-width;
width: $node-labels-max-width;
}
.node-label-wrapper {
//
// Base line height doesn't hop across foreignObject =/
@@ -344,9 +336,6 @@
vertical-align: top;
cursor: pointer;
pointer-events: all;
font-size: 12px;
width: 100%;
}
.node-sublabel {
@@ -355,6 +344,7 @@
}
.node-label, .node-sublabel {
span {
border-radius: 2px;
}
@@ -421,13 +411,15 @@
}
.link {
fill: none;
stroke: $text-secondary-color;
stroke-width: $edge-link-stroke-width;
fill: none;
stroke-opacity: $edge-opacity;
}
.shadow {
fill: none;
stroke: $weave-blue;
stroke-width: 10px;
fill: none;
stroke-opacity: 0;
}
&.highlighted {
@@ -441,7 +433,7 @@
display: none;
}
.stack .highlight .shape {
.stack .onlyHighlight .shape {
.border { display: none; }
.shadow { display: none; }
.node { display: none; }
@@ -456,7 +448,8 @@
transform: scale(1);
cursor: pointer;
.border {
/* cloud paths have stroke-width set dynamically */
&:not(.shape-cloud) .border {
stroke-width: $node-border-stroke-width;
fill: $background-color;
transition: stroke-opacity 0.333s $base-ease, fill 0.333s $base-ease;
@@ -482,12 +475,11 @@
.node {
fill: $text-color;
stroke: $background-lighter-color;
stroke-width: 0.05;
stroke-width: 2px;
}
text {
transform: scale($node-text-scale);
font-size: 10px;
font-size: 12px;
dominant-baseline: middle;
text-anchor: middle;
}
@@ -502,7 +494,7 @@
}
.stack .shape .border {
stroke-width: $node-border-stroke-width * 0.8;
stroke-width: $node-border-stroke-width - 0.5;
}
}

View File

@@ -14,12 +14,13 @@ $white: white;
$node-opacity-blurred: 0.6;
$node-highlight-fill-opacity: 0.3;
$node-highlight-stroke-opacity: 0.5;
$node-highlight-stroke-width: 0.06;
$node-border-stroke-width: 0.1;
$node-highlight-stroke-width: 3px;
$node-border-stroke-width: 5px;
$node-pseudo-opacity: 1;
$edge-highlight-opacity: 0.3;
$edge-opacity-blurred: 0;
$edge-opacity: 0.5;
$edge-link-stroke-width: 3px;
$btn-opacity-default: 1;
$btn-opacity-hover: 1;

View File

@@ -33,14 +33,13 @@ $terminal-header-height: 44px;
$node-opacity-blurred: 0.25;
$node-highlight-fill-opacity: 0.1;
$node-highlight-stroke-opacity: 0.4;
$node-highlight-stroke-width: 0.02;
$node-border-stroke-width: 0.06;
$node-highlight-stroke-width: 1px;
$node-border-stroke-width: 2.5px;
$node-pseudo-opacity: 0.8;
$node-text-scale: 0.02;
$node-labels-max-width: 120px;
$edge-highlight-opacity: 0.1;
$edge-opacity-blurred: 0.2;
$edge-opacity: 0.5;
$edge-link-stroke-width: 1px;
$btn-opacity-default: 0.7;
$btn-opacity-hover: 1;