mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-03 02:00:43 +00:00
Revert "Graph layout optimizations"
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
export const NODES_SPRING_ANIMATION_CONFIG = { stiffness: 80, damping: 20, precision: 0.1 };
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
]));
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user