mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-03 02:00:43 +00:00
Optimized rendering of graph layout and zooming events
This commit is contained in:
@@ -36,5 +36,6 @@
|
||||
"react/prefer-stateless-function": 0,
|
||||
"react/sort-comp": 0,
|
||||
"react/prop-types": 0,
|
||||
"no-unused-vars": 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,106 +5,102 @@ import { Map as makeMap } from 'immutable';
|
||||
import { line, curveBasis } from 'd3-shape';
|
||||
import { each, omit, times, constant } from 'lodash';
|
||||
|
||||
import { NODES_SPRING_ANIMATION_CONFIG } from '../constants/animation';
|
||||
import { uniformSelect } from '../utils/array-utils';
|
||||
import { round } from '../utils/math-utils';
|
||||
import Edge from './edge';
|
||||
|
||||
// Spring stiffness & damping respectively
|
||||
const ANIMATION_CONFIG = [80, 20];
|
||||
// Tweak this value for the number of control
|
||||
// points along the edge curve, e.g. values:
|
||||
// * 2 -> edges are simply straight lines
|
||||
// * 4 -> minimal value for loops to look ok
|
||||
const WAYPOINTS_CAP = 8;
|
||||
const WAYPOINTS_COUNT = 8;
|
||||
|
||||
const spline = line()
|
||||
.curve(curveBasis)
|
||||
.x(d => d.x)
|
||||
.y(d => d.y);
|
||||
|
||||
const buildPath = (points, layoutPrecision) => {
|
||||
const extracted = [];
|
||||
each(points, (value, key) => {
|
||||
const axis = key[0];
|
||||
const index = key.slice(1);
|
||||
if (!extracted[index]) {
|
||||
extracted[index] = {};
|
||||
}
|
||||
extracted[index][axis] = round(value, layoutPrecision);
|
||||
const transformedEdge = (props, path) => (
|
||||
<Edge {...props} path={spline(path)} />
|
||||
);
|
||||
|
||||
// Converts a waypoints map of the format {x0: 11, y0: 22, x1: 33, y1: 44}
|
||||
// that is used by Motion to an array of waypoints in the format
|
||||
// [{x: 11, y: 22}, {x: 33, y: 44}] that can be used by D3.
|
||||
const waypointsMapToArray = (waypointsMap) => {
|
||||
const waypointsArray = times(WAYPOINTS_COUNT, () => ({}));
|
||||
each(waypointsMap, (value, key) => {
|
||||
const [axis, index] = [key[0], key.slice(1)];
|
||||
waypointsArray[index][axis] = value;
|
||||
});
|
||||
return extracted;
|
||||
return waypointsArray;
|
||||
};
|
||||
|
||||
class EdgeContainer extends React.Component {
|
||||
|
||||
class EdgeContainer extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
pointsMap: makeMap()
|
||||
};
|
||||
this.state = { waypointsMap: makeMap() };
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.preparePoints(this.props.points);
|
||||
if (this.props.isAnimated) {
|
||||
this.prepareWaypointsForMotion(this.props.waypoints);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// immutablejs allows us to `===`! \o/
|
||||
if (nextProps.points !== this.props.points) {
|
||||
this.preparePoints(nextProps.points);
|
||||
if (this.props.isAnimated && nextProps.waypoints !== this.props.waypoints) {
|
||||
this.prepareWaypointsForMotion(nextProps.waypoints);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { layoutPrecision, points } = this.props;
|
||||
const other = omit(this.props, 'points');
|
||||
const { isAnimated, waypoints } = this.props;
|
||||
const forwardedProps = omit(this.props, 'isAnimated', 'waypoints');
|
||||
|
||||
if (layoutPrecision === 0) {
|
||||
const path = spline(points.toJS());
|
||||
return <Edge {...other} path={path} />;
|
||||
if (!isAnimated) {
|
||||
return transformedEdge(forwardedProps, waypoints.toJS());
|
||||
}
|
||||
|
||||
return (
|
||||
<Motion style={this.state.pointsMap.toJS()}>
|
||||
{(interpolated) => {
|
||||
// convert points to path string, because that lends itself to
|
||||
// JS-equality checks in the child component
|
||||
const path = spline(buildPath(interpolated, layoutPrecision));
|
||||
return <Edge {...other} path={path} />;
|
||||
}}
|
||||
// For the Motion interpolation to work, the waypoints need to be in a map format like
|
||||
// {x0: 11, y0: 22, x1: 33, y1: 44} that we convert to the array format when rendering.
|
||||
<Motion style={this.state.waypointsMap.toJS()}>
|
||||
{interpolated => transformedEdge(forwardedProps, waypointsMapToArray(interpolated))}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
preparePoints(nextPoints) {
|
||||
nextPoints = nextPoints.toJS();
|
||||
prepareWaypointsForMotion(nextWaypoints) {
|
||||
nextWaypoints = nextWaypoints.toJS();
|
||||
|
||||
// Motion requires a constant number of waypoints along the path of each edge
|
||||
// for the animation to work correctly, but dagre might be changing their number
|
||||
// depending on the dynamic topology reconfiguration. Here we are transforming
|
||||
// the waypoints array given by dagre to the fixed size of `WAYPOINTS_CAP` that
|
||||
// the waypoints array given by dagre to the fixed size of `WAYPOINTS_COUNT` that
|
||||
// Motion could take over.
|
||||
const pointsMissing = WAYPOINTS_CAP - nextPoints.length;
|
||||
if (pointsMissing > 0) {
|
||||
const waypointsMissing = WAYPOINTS_COUNT - nextWaypoints.length;
|
||||
if (waypointsMissing > 0) {
|
||||
// Whenever there are some waypoints missing, we simply populate the beginning of the
|
||||
// array with the first element, as this leaves the curve interpolation unchanged.
|
||||
nextPoints = times(pointsMissing, constant(nextPoints[0])).concat(nextPoints);
|
||||
} else if (pointsMissing < 0) {
|
||||
nextWaypoints = times(waypointsMissing, constant(nextWaypoints[0])).concat(nextWaypoints);
|
||||
} else if (waypointsMissing < 0) {
|
||||
// If there are 'too many' waypoints given by dagre, we select a sub-array of
|
||||
// uniformly distributed indices. Note that it is very important to keep the first
|
||||
// and the last endpoints in the array as they are the ones connecting the nodes.
|
||||
nextPoints = uniformSelect(nextPoints, WAYPOINTS_CAP);
|
||||
nextWaypoints = uniformSelect(nextWaypoints, WAYPOINTS_COUNT);
|
||||
}
|
||||
|
||||
let { pointsMap } = this.state;
|
||||
nextPoints.forEach((point, index) => {
|
||||
pointsMap = pointsMap.set(`x${index}`, spring(point.x, ANIMATION_CONFIG));
|
||||
pointsMap = pointsMap.set(`y${index}`, spring(point.y, ANIMATION_CONFIG));
|
||||
let { waypointsMap } = this.state;
|
||||
nextWaypoints.forEach((point, index) => {
|
||||
waypointsMap = waypointsMap.set(`x${index}`, spring(point.x, NODES_SPRING_ANIMATION_CONFIG));
|
||||
waypointsMap = waypointsMap.set(`y${index}`, spring(point.y, NODES_SPRING_ANIMATION_CONFIG));
|
||||
});
|
||||
|
||||
this.setState({ pointsMap });
|
||||
this.setState({ waypointsMap });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect()(EdgeContainer);
|
||||
|
||||
@@ -3,29 +3,40 @@ import { omit } from 'lodash';
|
||||
import { connect } from 'react-redux';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
|
||||
import { round } from '../utils/math-utils';
|
||||
import { NODES_SPRING_ANIMATION_CONFIG } from '../constants/animation';
|
||||
import { NODE_BASE_SIZE, NODE_BLUR_OPACITY } from '../constants/styles';
|
||||
import Node from './node';
|
||||
|
||||
const transformedNode = (otherProps, { x, y, k }) => (
|
||||
<Node transform={`translate(${x},${y}) scale(${k})`} {...otherProps} />
|
||||
);
|
||||
|
||||
class NodeContainer extends React.Component {
|
||||
render() {
|
||||
const { dx, dy, focused, layoutPrecision, zoomScale } = this.props;
|
||||
const animConfig = [80, 20]; // stiffness, damping
|
||||
const scaleFactor = focused ? (1 / zoomScale) : 1;
|
||||
const other = omit(this.props, 'dx', 'dy');
|
||||
const { dx, dy, isAnimated, magnified, blurred } = this.props;
|
||||
const forwardedProps = omit(this.props, 'dx', 'dy', 'isAnimated', 'magnified', 'blurred');
|
||||
const opacity = blurred ? NODE_BLUR_OPACITY : 1;
|
||||
const scale = magnified * NODE_BASE_SIZE;
|
||||
|
||||
// NOTE: Controlling blurring from here seems to re-render faster
|
||||
// than adding a CSS class and controlling it from there.
|
||||
return (
|
||||
<Motion
|
||||
style={{
|
||||
x: spring(dx, animConfig),
|
||||
y: spring(dy, animConfig),
|
||||
f: spring(scaleFactor, animConfig)
|
||||
}}>
|
||||
{(interpolated) => {
|
||||
const transform = `translate(${round(interpolated.x, layoutPrecision)},`
|
||||
+ `${round(interpolated.y, layoutPrecision)})`;
|
||||
return <Node {...other} transform={transform} scaleFactor={interpolated.f} />;
|
||||
}}
|
||||
</Motion>
|
||||
<g className="node-container" style={{opacity}}>
|
||||
{!isAnimated ?
|
||||
|
||||
// Show static node for optimized rendering
|
||||
transformedNode(forwardedProps, { x: dx, y: dy, k: scale }) :
|
||||
|
||||
// Animate the node if the layout is sufficiently small
|
||||
<Motion
|
||||
style={{
|
||||
x: spring(dx, NODES_SPRING_ANIMATION_CONFIG),
|
||||
y: spring(dy, NODES_SPRING_ANIMATION_CONFIG),
|
||||
k: spring(scale, NODES_SPRING_ANIMATION_CONFIG)
|
||||
}}>
|
||||
{interpolated => transformedNode(forwardedProps, interpolated)}
|
||||
</Motion>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,50 +4,40 @@ import { List as makeList } from 'immutable';
|
||||
import { getNetworkColor } from '../utils/color-utils';
|
||||
import { isContrastMode } from '../utils/contrast-utils';
|
||||
|
||||
|
||||
// Gap size between bar segments.
|
||||
const minBarHeight = 3;
|
||||
const padding = 0.05;
|
||||
const rx = 1;
|
||||
const ry = rx;
|
||||
// Min size is about a quarter of the width, feels about right.
|
||||
const minBarWidth = 0.25;
|
||||
const barHeight = 0.08;
|
||||
const innerPadding = 0.04;
|
||||
const borderRadius = 0.01;
|
||||
const x = scaleBand();
|
||||
|
||||
function NodeNetworksOverlay({offset, size, stack, networks = makeList()}) {
|
||||
// Min size is about a quarter of the width, feels about right.
|
||||
const minBarWidth = (size / 4);
|
||||
const barWidth = Math.max(size, minBarWidth * networks.size);
|
||||
const barHeight = Math.max(size * 0.085, minBarHeight);
|
||||
function NodeNetworksOverlay({offset, stack, networks = makeList()}) {
|
||||
const barWidth = Math.max(1, minBarWidth * networks.size);
|
||||
const yPosition = offset - (barHeight * 0.5);
|
||||
|
||||
// Update singleton scale.
|
||||
x.domain(networks.map((n, i) => i).toJS());
|
||||
x.range([barWidth * -0.5, barWidth * 0.5]);
|
||||
x.paddingInner(padding);
|
||||
x.paddingInner(innerPadding);
|
||||
|
||||
const bandwidth = x.bandwidth();
|
||||
const bars = networks.map((n, i) => (
|
||||
<rect
|
||||
x={x(i)}
|
||||
y={offset - (barHeight * 0.5)}
|
||||
width={x.bandwidth()}
|
||||
height={barHeight}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
className="node-network"
|
||||
style={{
|
||||
fill: getNetworkColor(n.get('colorKey', n.get('id')))
|
||||
}}
|
||||
key={n.get('id')}
|
||||
x={x(i)}
|
||||
y={yPosition}
|
||||
width={bandwidth}
|
||||
height={barHeight}
|
||||
rx={borderRadius}
|
||||
ry={borderRadius}
|
||||
style={{ fill: getNetworkColor(n.get('colorKey', n.get('id'))) }}
|
||||
/>
|
||||
));
|
||||
|
||||
let transform = '';
|
||||
if (stack) {
|
||||
const contrastMode = isContrastMode();
|
||||
const [dx, dy] = contrastMode ? [0, 8] : [0, 0];
|
||||
transform = `translate(${dx}, ${dy * -1.5})`;
|
||||
}
|
||||
|
||||
const translateY = stack && isContrastMode() ? 0.15 : 0;
|
||||
return (
|
||||
<g transform={transform}>
|
||||
<g transform={`translate(0, ${translateY})`}>
|
||||
{bars.toJS()}
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils';
|
||||
import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles';
|
||||
|
||||
import {
|
||||
getMetricValue,
|
||||
getMetricColor,
|
||||
getClipPathDefinition,
|
||||
renderMetricValue,
|
||||
} from '../utils/metric-utils';
|
||||
import {
|
||||
NODE_SHAPE_HIGHLIGHT_RADIUS,
|
||||
NODE_SHAPE_BORDER_RADIUS,
|
||||
NODE_SHAPE_SHADOW_RADIUS,
|
||||
} from '../constants/styles';
|
||||
|
||||
|
||||
export default function NodeShapeCircle({id, highlighted, size, color, metric}) {
|
||||
const clipId = `mask-${id}`;
|
||||
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
|
||||
export default function NodeShapeCircle({id, highlighted, color, metric}) {
|
||||
const { height, hasMetric, formattedValue } = getMetricValue(metric);
|
||||
const metricStyle = { fill: getMetricColor(metric) };
|
||||
const className = classNames('shape', { metrics: hasMetric });
|
||||
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
|
||||
|
||||
const className = classNames('shape', 'shape-circle', { metrics: hasMetric });
|
||||
const clipId = `mask-${id}`;
|
||||
|
||||
return (
|
||||
<g className={className}>
|
||||
{hasMetric && getClipPathDefinition(clipId, size, height)}
|
||||
{highlighted && <circle r={size * 0.7} className="highlighted" />}
|
||||
<circle r={size * 0.5} className="border" stroke={color} />
|
||||
<circle r={size * 0.45} className="shadow" />
|
||||
{hasMetric && getClipPathDefinition(clipId, height)}
|
||||
{highlighted && <circle className="highlighted" r={NODE_SHAPE_HIGHLIGHT_RADIUS} />}
|
||||
<circle className="border" stroke={color} r={NODE_SHAPE_BORDER_RADIUS} />
|
||||
<circle className="shadow" r={NODE_SHAPE_SHADOW_RADIUS} />
|
||||
{hasMetric && <circle
|
||||
r={size * 0.45}
|
||||
className="metric-fill"
|
||||
style={metricStyle}
|
||||
clipPath={`url(#${clipId})`}
|
||||
style={metricStyle}
|
||||
r={NODE_SHAPE_SHADOW_RADIUS}
|
||||
/>}
|
||||
{highlighted && hasMetric ?
|
||||
<text style={{fontSize}}>{formattedValue}</text> :
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />}
|
||||
{renderMetricValue(formattedValue, highlighted && hasMetric)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,27 @@
|
||||
import React from 'react';
|
||||
import { extent } from 'd3-array';
|
||||
import {
|
||||
NODE_SHAPE_HIGHLIGHT_RADIUS,
|
||||
NODE_SHAPE_BORDER_RADIUS,
|
||||
NODE_SHAPE_SHADOW_RADIUS,
|
||||
NODE_SHAPE_DOT_RADIUS,
|
||||
} from '../constants/styles';
|
||||
|
||||
import { isContrastMode } from '../utils/contrast-utils';
|
||||
// This path is already normalized so no rescaling is needed.
|
||||
const CLOUD_PATH = 'M-1.25 0.233Q-1.25 0.44-1.104 0.587-0.957 0.733-0.75 0.733H0.667Q0.908 '
|
||||
+ '0.733 1.079 0.562 1.25 0.391 1.25 0.15 1.25-0.022 1.158-0.164 1.065-0.307 0.914-0.377q'
|
||||
+ '0.003-0.036 0.003-0.056 0-0.276-0.196-0.472-0.195-0.195-0.471-0.195-0.206 0-0.373 0.115'
|
||||
+ '-0.167 0.115-0.244 0.299-0.091-0.081-0.216-0.081-0.138 0-0.236 0.098-0.098 0.098-0.098 '
|
||||
+ '0.236 0 0.098 0.054 0.179-0.168 0.039-0.278 0.175-0.109 0.136-0.109 0.312z';
|
||||
|
||||
const CLOUD_PATH = 'M 1920,384 Q 1920,225 1807.5,112.5 1695,0 1536,0 H 448 '
|
||||
+ 'Q 263,0 131.5,131.5 0,263 0,448 0,580 71,689.5 142,799 258,853 '
|
||||
+ 'q -2,28 -2,43 0,212 150,362 150,150 362,150 158,0 286.5,-88 128.5,-88 '
|
||||
+ '187.5,-230 70,62 166,62 106,0 181,-75 75,-75 75,-181 0,-75 -41,-138 '
|
||||
+ '129,-30 213,-134.5 84,-104.5 84,-239.5 z';
|
||||
|
||||
function toPoint(stringPair) {
|
||||
return stringPair.split(',').map(p => parseFloat(p, 10));
|
||||
}
|
||||
|
||||
function getExtents(svgPath) {
|
||||
const points = svgPath.split(' ').filter(s => s.length > 1).map(toPoint);
|
||||
return [extent(points, p => p[0]), extent(points, p => p[1])];
|
||||
}
|
||||
|
||||
export default function NodeShapeCloud({highlighted, size, color}) {
|
||||
const [[minx, maxx], [miny, maxy]] = getExtents(CLOUD_PATH);
|
||||
const width = (maxx - minx);
|
||||
const height = (maxy - miny);
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const pathSize = (width + height) / 2;
|
||||
const baseScale = (size * 2) / pathSize;
|
||||
const strokeWidth = isContrastMode() ? 6 / baseScale : 4 / baseScale;
|
||||
|
||||
const pathProps = v => ({
|
||||
d: CLOUD_PATH,
|
||||
fill: 'none',
|
||||
transform: `scale(-${v * baseScale}) translate(-${cx},-${cy})`,
|
||||
strokeWidth
|
||||
});
|
||||
export default function NodeShapeCloud({highlighted, color}) {
|
||||
const pathProps = r => ({ d: CLOUD_PATH, transform: `scale(${r})` });
|
||||
|
||||
return (
|
||||
<g className="shape shape-cloud">
|
||||
{highlighted && <path className="highlighted" {...pathProps(0.7)} />}
|
||||
<path className="border" stroke={color} {...pathProps(0.5)} />
|
||||
<path className="shadow" {...pathProps(0.45)} />
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />
|
||||
{highlighted && <path className="highlighted" {...pathProps(NODE_SHAPE_HIGHLIGHT_RADIUS)} />}
|
||||
<path className="border" stroke={color} {...pathProps(NODE_SHAPE_BORDER_RADIUS)} />
|
||||
<path className="shadow" {...pathProps(NODE_SHAPE_SHADOW_RADIUS)} />
|
||||
<circle className="node" r={NODE_SHAPE_DOT_RADIUS} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,41 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { line, curveCardinalClosed } from 'd3-shape';
|
||||
import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils';
|
||||
import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles';
|
||||
|
||||
import { nodeShapePolygon } from '../utils/node-shape-utils';
|
||||
import {
|
||||
getMetricValue,
|
||||
getMetricColor,
|
||||
getClipPathDefinition,
|
||||
renderMetricValue,
|
||||
} from '../utils/metric-utils';
|
||||
import {
|
||||
NODE_SHAPE_HIGHLIGHT_RADIUS,
|
||||
NODE_SHAPE_BORDER_RADIUS,
|
||||
NODE_SHAPE_SHADOW_RADIUS,
|
||||
} from '../constants/styles';
|
||||
|
||||
|
||||
const spline = line()
|
||||
.curve(curveCardinalClosed.tension(0.65));
|
||||
|
||||
|
||||
function polygon(r, sides) {
|
||||
const a = (Math.PI * 2) / sides;
|
||||
const points = [];
|
||||
for (let i = 0; i < sides; i += 1) {
|
||||
points.push([r * Math.sin(a * i), -r * Math.cos(a * i)]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
|
||||
export default function NodeShapeHeptagon({id, highlighted, size, color, metric}) {
|
||||
const scaledSize = size * 1.0;
|
||||
const pathProps = v => ({
|
||||
d: spline(polygon(scaledSize * v, 7))
|
||||
});
|
||||
|
||||
const clipId = `mask-${id}`;
|
||||
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
|
||||
export default function NodeShapeHeptagon({ id, highlighted, color, metric }) {
|
||||
const { height, hasMetric, formattedValue } = getMetricValue(metric);
|
||||
const metricStyle = { fill: getMetricColor(metric) };
|
||||
const className = classNames('shape', { metrics: hasMetric });
|
||||
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
|
||||
const halfSize = size * 0.5;
|
||||
|
||||
const className = classNames('shape', 'shape-heptagon', { metrics: hasMetric });
|
||||
const pathProps = r => ({ d: nodeShapePolygon(r, 7) });
|
||||
const clipId = `mask-${id}`;
|
||||
|
||||
return (
|
||||
<g className={className}>
|
||||
{hasMetric && getClipPathDefinition(clipId, size, height, -halfSize, halfSize - height)}
|
||||
{highlighted && <path className="highlighted" {...pathProps(0.7)} />}
|
||||
<path className="border" stroke={color} {...pathProps(0.5)} />
|
||||
<path className="shadow" {...pathProps(0.45)} />
|
||||
{hasMetric && getClipPathDefinition(clipId, height)}
|
||||
{highlighted && <path className="highlighted" {...pathProps(NODE_SHAPE_HIGHLIGHT_RADIUS)} />}
|
||||
<path className="border" stroke={color} {...pathProps(NODE_SHAPE_BORDER_RADIUS)} />
|
||||
<path className="shadow" {...pathProps(NODE_SHAPE_SHADOW_RADIUS)} />
|
||||
{hasMetric && <path
|
||||
className="metric-fill"
|
||||
clipPath={`url(#${clipId})`}
|
||||
style={metricStyle}
|
||||
{...pathProps(0.45)}
|
||||
{...pathProps(NODE_SHAPE_SHADOW_RADIUS)}
|
||||
/>}
|
||||
{highlighted && hasMetric ?
|
||||
<text style={{fontSize}}>{formattedValue}</text> :
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />}
|
||||
{renderMetricValue(formattedValue, highlighted && hasMetric)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,41 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { line, curveCardinalClosed } from 'd3-shape';
|
||||
import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils';
|
||||
import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles';
|
||||
|
||||
import { nodeShapePolygon } from '../utils/node-shape-utils';
|
||||
import {
|
||||
getMetricValue,
|
||||
getMetricColor,
|
||||
getClipPathDefinition,
|
||||
renderMetricValue,
|
||||
} from '../utils/metric-utils';
|
||||
import {
|
||||
NODE_SHAPE_HIGHLIGHT_RADIUS,
|
||||
NODE_SHAPE_BORDER_RADIUS,
|
||||
NODE_SHAPE_SHADOW_RADIUS,
|
||||
} from '../constants/styles';
|
||||
|
||||
|
||||
const spline = line()
|
||||
.curve(curveCardinalClosed.tension(0.65));
|
||||
|
||||
|
||||
function getWidth(h) {
|
||||
return (Math.sqrt(3) / 2) * h;
|
||||
}
|
||||
|
||||
|
||||
function getPoints(h) {
|
||||
const w = getWidth(h);
|
||||
const points = [
|
||||
[w * 0.5, 0],
|
||||
[w, 0.25 * h],
|
||||
[w, 0.75 * h],
|
||||
[w * 0.5, h],
|
||||
[0, 0.75 * h],
|
||||
[0, 0.25 * h]
|
||||
];
|
||||
|
||||
return spline(points);
|
||||
}
|
||||
|
||||
|
||||
export default function NodeShapeHexagon({id, highlighted, size, color, metric}) {
|
||||
const pathProps = v => ({
|
||||
d: getPoints(size * v * 2),
|
||||
transform: `translate(-${size * getWidth(v)}, -${size * v})`
|
||||
});
|
||||
|
||||
const shadowSize = 0.45;
|
||||
|
||||
const clipId = `mask-${id}`;
|
||||
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
|
||||
export default function NodeShapeHexagon({ id, highlighted, color, metric }) {
|
||||
const { height, hasMetric, formattedValue } = getMetricValue(metric);
|
||||
const metricStyle = { fill: getMetricColor(metric) };
|
||||
const className = classNames('shape', { metrics: hasMetric });
|
||||
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
|
||||
// how much the hex curve line interpolator curves outside the original shape definition in
|
||||
// percent (very roughly)
|
||||
const hexCurve = 0.05;
|
||||
|
||||
const className = classNames('shape', 'shape-hexagon', { metrics: hasMetric });
|
||||
const pathProps = r => ({ d: nodeShapePolygon(r, 6) });
|
||||
const clipId = `mask-${id}`;
|
||||
|
||||
return (
|
||||
<g className={className}>
|
||||
{hasMetric && getClipPathDefinition(
|
||||
clipId,
|
||||
size * (1 + (hexCurve * 2)),
|
||||
height,
|
||||
-(size * hexCurve),
|
||||
(size - height) * (shadowSize * 2)
|
||||
)}
|
||||
{highlighted && <path className="highlighted" {...pathProps(0.7)} />}
|
||||
<path className="border" stroke={color} {...pathProps(0.5)} />
|
||||
<path className="shadow" {...pathProps(shadowSize)} />
|
||||
{hasMetric && getClipPathDefinition(clipId, height)}
|
||||
{highlighted && <path className="highlighted" {...pathProps(NODE_SHAPE_HIGHLIGHT_RADIUS)} />}
|
||||
<path className="border" stroke={color} {...pathProps(NODE_SHAPE_BORDER_RADIUS)} />
|
||||
<path className="shadow" {...pathProps(NODE_SHAPE_SHADOW_RADIUS)} />
|
||||
{hasMetric && <path
|
||||
className="metric-fill"
|
||||
style={metricStyle}
|
||||
clipPath={`url(#${clipId})`}
|
||||
{...pathProps(shadowSize)}
|
||||
style={metricStyle}
|
||||
{...pathProps(NODE_SHAPE_SHADOW_RADIUS)}
|
||||
/>}
|
||||
{highlighted && hasMetric ?
|
||||
<text style={{fontSize}}>
|
||||
{formattedValue}
|
||||
</text> :
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />}
|
||||
{renderMetricValue(formattedValue, highlighted && hasMetric)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,47 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { getMetricValue, getMetricColor, getClipPathDefinition } from '../utils/metric-utils';
|
||||
import { CANVAS_METRIC_FONT_SIZE } from '../constants/styles';
|
||||
|
||||
import {
|
||||
getMetricValue,
|
||||
getMetricColor,
|
||||
getClipPathDefinition,
|
||||
renderMetricValue,
|
||||
} from '../utils/metric-utils';
|
||||
import {
|
||||
NODE_SHAPE_HIGHLIGHT_RADIUS,
|
||||
NODE_SHAPE_BORDER_RADIUS,
|
||||
NODE_SHAPE_SHADOW_RADIUS,
|
||||
} from '../constants/styles';
|
||||
|
||||
|
||||
export default function NodeShapeSquare({
|
||||
id, highlighted, size, color, rx = 0, ry = 0, metric
|
||||
}) {
|
||||
const rectProps = (scale, radiusScale) => ({
|
||||
width: scale * size * 2,
|
||||
height: scale * size * 2,
|
||||
rx: (radiusScale || scale) * size * rx,
|
||||
ry: (radiusScale || scale) * size * ry,
|
||||
x: -size * scale,
|
||||
y: -size * scale
|
||||
});
|
||||
|
||||
const clipId = `mask-${id}`;
|
||||
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
|
||||
export default function NodeShapeSquare({ id, highlighted, color, rx = 0, ry = 0, metric }) {
|
||||
const { height, hasMetric, formattedValue } = getMetricValue(metric);
|
||||
const metricStyle = { fill: getMetricColor(metric) };
|
||||
const className = classNames('shape', { metrics: hasMetric });
|
||||
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
|
||||
|
||||
const className = classNames('shape', 'shape-square', { metrics: hasMetric });
|
||||
const rectProps = (scale, borderRadiusAdjustmentFactor = 1) => ({
|
||||
width: scale * 2,
|
||||
height: scale * 2,
|
||||
rx: scale * rx * borderRadiusAdjustmentFactor,
|
||||
ry: scale * ry * borderRadiusAdjustmentFactor,
|
||||
x: -scale,
|
||||
y: -scale
|
||||
});
|
||||
const clipId = `mask-${id}`;
|
||||
|
||||
return (
|
||||
<g className={className}>
|
||||
{hasMetric && getClipPathDefinition(clipId, size, height)}
|
||||
{highlighted && <rect className="highlighted" {...rectProps(0.7)} />}
|
||||
<rect className="border" stroke={color} {...rectProps(0.5, 0.5)} />
|
||||
<rect className="shadow" {...rectProps(0.45, 0.39)} />
|
||||
{hasMetric && getClipPathDefinition(clipId, height)}
|
||||
{highlighted && <rect className="highlighted" {...rectProps(NODE_SHAPE_HIGHLIGHT_RADIUS)} />}
|
||||
<rect className="border" stroke={color} {...rectProps(NODE_SHAPE_BORDER_RADIUS)} />
|
||||
<rect className="shadow" {...rectProps(NODE_SHAPE_SHADOW_RADIUS)} />
|
||||
{hasMetric && <rect
|
||||
className="metric-fill" style={metricStyle}
|
||||
className="metric-fill"
|
||||
clipPath={`url(#${clipId})`}
|
||||
{...rectProps(0.45, 0.39)}
|
||||
style={metricStyle}
|
||||
{...rectProps(NODE_SHAPE_SHADOW_RADIUS, 0.85)}
|
||||
/>}
|
||||
{highlighted && hasMetric ?
|
||||
<text style={{fontSize}}>
|
||||
{formattedValue}
|
||||
</text> :
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />}
|
||||
{renderMetricValue(formattedValue, highlighted && hasMetric)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,22 @@ import React from 'react';
|
||||
import { isContrastMode } from '../utils/contrast-utils';
|
||||
|
||||
export default function NodeShapeStack(props) {
|
||||
const contrastMode = isContrastMode();
|
||||
const Shape = props.shape;
|
||||
const [dx, dy] = contrastMode ? [0, 8] : [0, 5];
|
||||
const dsx = (props.size + dx) / props.size;
|
||||
const dsy = (props.size + dy) / props.size;
|
||||
const hls = [dsx, dsy];
|
||||
const dy = isContrastMode() ? 0.15 : 0.1;
|
||||
const highlightScale = [1, 1 + dy];
|
||||
|
||||
const Shape = props.shape;
|
||||
return (
|
||||
<g transform={`translate(${dx * -1}, ${dy * -2.5})`} className="stack">
|
||||
<g transform={`scale(${hls})translate(${dx}, ${dy})`} className="onlyHighlight">
|
||||
<g transform={`translate(0, ${dy * -2.5})`} className="stack">
|
||||
<g transform={`scale(${highlightScale}) translate(0, ${dy})`} className="highlight">
|
||||
<Shape {...props} />
|
||||
</g>
|
||||
<g transform={`translate(${dx * 2}, ${dy * 2})`}>
|
||||
<g transform={`translate(0, ${dy * 2})`}>
|
||||
<Shape {...props} />
|
||||
</g>
|
||||
<g transform={`translate(${dx * 1}, ${dy * 1})`}>
|
||||
<g transform={`translate(0, ${dy * 1})`}>
|
||||
<Shape {...props} />
|
||||
</g>
|
||||
<g className="onlyMetrics">
|
||||
<g className="only-metrics">
|
||||
<Shape {...props} />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
@@ -15,13 +15,8 @@ import NodeShapeHexagon from './node-shape-hexagon';
|
||||
import NodeShapeHeptagon from './node-shape-heptagon';
|
||||
import NodeShapeCloud from './node-shape-cloud';
|
||||
import NodeNetworksOverlay from './node-networks-overlay';
|
||||
import { MIN_NODE_LABEL_SIZE, BASE_NODE_LABEL_SIZE, BASE_NODE_SIZE } from '../constants/styles';
|
||||
|
||||
|
||||
function labelFontSize(nodeSize) {
|
||||
return Math.max(MIN_NODE_LABEL_SIZE, (BASE_NODE_LABEL_SIZE / BASE_NODE_SIZE) * nodeSize);
|
||||
}
|
||||
|
||||
function stackedShape(Shape) {
|
||||
const factory = React.createFactory(NodeShapeStack);
|
||||
return props => factory(Object.assign({}, props, {shape: Shape}));
|
||||
@@ -43,58 +38,72 @@ function getNodeShape({ shape, stack }) {
|
||||
return stack ? stackedShape(nodeShape) : nodeShape;
|
||||
}
|
||||
|
||||
function svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) {
|
||||
return (
|
||||
<g className="node-label-svg">
|
||||
<text className={labelClassName} y={labelOffsetY + 18} textAnchor="middle">{label}</text>
|
||||
<text className={subLabelClassName} y={labelOffsetY + 35} textAnchor="middle">
|
||||
{subLabel}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
class Node extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.handleMouseClick = this.handleMouseClick.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseLeave = this.handleMouseLeave.bind(this);
|
||||
this.saveShapeRef = this.saveShapeRef.bind(this);
|
||||
this.state = {
|
||||
hovered: false,
|
||||
matched: false
|
||||
};
|
||||
|
||||
this.handleMouseClick = this.handleMouseClick.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseLeave = this.handleMouseLeave.bind(this);
|
||||
this.saveShapeRef = this.saveShapeRef.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// marks as matched only when search query changes
|
||||
if (nextProps.searchQuery !== this.props.searchQuery) {
|
||||
this.setState({
|
||||
matched: nextProps.matched
|
||||
});
|
||||
this.setState({ matched: nextProps.matched });
|
||||
} else {
|
||||
this.setState({
|
||||
matched: false
|
||||
});
|
||||
this.setState({ matched: false });
|
||||
}
|
||||
}
|
||||
|
||||
renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) {
|
||||
const { label, subLabel } = this.props;
|
||||
return (
|
||||
<g className="node-labels-container" y={labelOffsetY}>
|
||||
<text className={labelClassName} y={13} textAnchor="middle">{label}</text>
|
||||
<text className={subLabelClassName} y={30} textAnchor="middle">
|
||||
{subLabel}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents) {
|
||||
const { label, subLabel, blurred, matches = makeMap() } = this.props;
|
||||
const matchedMetadata = matches.get('metadata', makeList());
|
||||
const matchedParents = matches.get('parents', makeList());
|
||||
const matchedNodeDetails = matchedMetadata.concat(matchedParents);
|
||||
|
||||
return (
|
||||
<foreignObject className="node-labels-container" y={labelOffsetY}>
|
||||
<div className="node-label-wrapper" {...mouseEvents}>
|
||||
<div className={labelClassName}>
|
||||
<MatchedText text={label} match={matches.get('label')} />
|
||||
</div>
|
||||
<div className={subLabelClassName}>
|
||||
<MatchedText text={subLabel} match={matches.get('sublabel')} />
|
||||
</div>
|
||||
{!blurred && <MatchedResults matches={matchedNodeDetails} />}
|
||||
</div>
|
||||
</foreignObject>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { blurred, focused, highlighted, label, matches = makeMap(), networks,
|
||||
pseudo, rank, subLabel, scaleFactor, transform, exportingGraph,
|
||||
showingNetworks, stack } = this.props;
|
||||
const { blurred, focused, highlighted, networks, pseudo, rank, label,
|
||||
transform, exportingGraph, showingNetworks, stack } = this.props;
|
||||
const { hovered, matched } = this.state;
|
||||
const nodeScale = focused ? this.props.selectedNodeScale : this.props.nodeScale;
|
||||
|
||||
const color = getNodeColor(rank, label, pseudo);
|
||||
const truncate = !focused && !hovered;
|
||||
const labelWidth = nodeScale(scaleFactor * 3);
|
||||
const labelOffsetX = -labelWidth / 2;
|
||||
const labelDy = (showingNetworks && networks) ? 0.70 : 0.55;
|
||||
const labelOffsetY = nodeScale(labelDy * scaleFactor);
|
||||
const networkOffset = nodeScale(scaleFactor * 0.67);
|
||||
const labelOffsetY = (showingNetworks && networks) ? 40 : 30;
|
||||
const networkOffset = 0.67;
|
||||
|
||||
const nodeClassName = classnames('node', {
|
||||
highlighted,
|
||||
@@ -109,51 +118,25 @@ class Node extends React.Component {
|
||||
|
||||
const NodeShapeType = getNodeShape(this.props);
|
||||
const useSvgLabels = exportingGraph;
|
||||
const size = nodeScale(scaleFactor);
|
||||
const fontSize = labelFontSize(size);
|
||||
const mouseEvents = {
|
||||
onClick: this.handleMouseClick,
|
||||
onMouseEnter: this.handleMouseEnter,
|
||||
onMouseLeave: this.handleMouseLeave,
|
||||
};
|
||||
const matchedNodeDetails = matches.get('metadata', makeList())
|
||||
.concat(matches.get('parents', makeList()));
|
||||
|
||||
return (
|
||||
<g className={nodeClassName} transform={transform}>
|
||||
|
||||
{useSvgLabels ?
|
||||
|
||||
svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) :
|
||||
|
||||
<foreignObject
|
||||
style={{pointerEvents: 'none'}}
|
||||
x={labelOffsetX} y={labelOffsetY}
|
||||
width={labelWidth} height="100em">
|
||||
<div
|
||||
className="node-label-wrapper"
|
||||
style={{pointerEvents: 'all', fontSize, maxWidth: labelWidth}}
|
||||
{...mouseEvents}>
|
||||
<div className={labelClassName}>
|
||||
<MatchedText text={label} match={matches.get('label')} />
|
||||
</div>
|
||||
<div className={subLabelClassName}>
|
||||
<MatchedText text={subLabel} match={matches.get('sublabel')} />
|
||||
</div>
|
||||
{!blurred && <MatchedResults matches={matchedNodeDetails} />}
|
||||
</div>
|
||||
</foreignObject>}
|
||||
{useSvgLabels || false ?
|
||||
this.renderSvgLabels(labelClassName, subLabelClassName, labelOffsetY) :
|
||||
this.renderStandardLabels(labelClassName, subLabelClassName, labelOffsetY, mouseEvents)}
|
||||
|
||||
<g {...mouseEvents} ref={this.saveShapeRef}>
|
||||
<NodeShapeType
|
||||
size={size}
|
||||
color={color}
|
||||
{...this.props} />
|
||||
<NodeShapeType color={color} {...this.props} />
|
||||
</g>
|
||||
|
||||
{showingNetworks && <NodeNetworksOverlay
|
||||
offset={networkOffset}
|
||||
size={size} networks={networks}
|
||||
networks={networks}
|
||||
stack={stack}
|
||||
/>}
|
||||
</g>
|
||||
|
||||
@@ -8,7 +8,7 @@ import EdgeContainer from './edge-container';
|
||||
class NodesChartEdges extends React.Component {
|
||||
render() {
|
||||
const { hasSelectedNode, highlightedEdgeIds, layoutEdges,
|
||||
layoutPrecision, searchNodeMatches = makeMap(), searchQuery,
|
||||
searchNodeMatches = makeMap(), searchQuery, isAnimated,
|
||||
selectedNodeId, selectedNetwork, selectedNetworkNodes } = this.props;
|
||||
|
||||
return (
|
||||
@@ -35,10 +35,10 @@ class NodesChartEdges extends React.Component {
|
||||
id={edge.get('id')}
|
||||
source={edge.get('source')}
|
||||
target={edge.get('target')}
|
||||
points={edge.get('points')}
|
||||
waypoints={edge.get('points')}
|
||||
isAnimated={isAnimated}
|
||||
blurred={blurred}
|
||||
focused={focused}
|
||||
layoutPrecision={layoutPrecision}
|
||||
highlighted={highlighted}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,13 +12,12 @@ class NodesChartElements extends React.Component {
|
||||
<g className="nodes-chart-elements" transform={props.transform}>
|
||||
<NodesChartEdges
|
||||
layoutEdges={props.layoutEdges}
|
||||
layoutPrecision={props.layoutPrecision} />
|
||||
isAnimated={props.isAnimated} />
|
||||
<NodesChartNodes
|
||||
layoutNodes={props.completeNodes}
|
||||
nodeScale={props.nodeScale}
|
||||
scale={props.scale}
|
||||
selectedNodeScale={props.selectedNodeScale}
|
||||
layoutPrecision={props.layoutPrecision} />
|
||||
selectedScale={props.selectedScale}
|
||||
isAnimated={props.isAnimated} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,9 @@ import NodeContainer from './node-container';
|
||||
|
||||
class NodesChartNodes extends React.Component {
|
||||
render() {
|
||||
const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision,
|
||||
mouseOverNodeId, nodeScale, scale, searchNodeMatches = makeMap(),
|
||||
searchQuery, selectedMetric, selectedNetwork, selectedNodeScale, selectedNodeId,
|
||||
topCardNode } = this.props;
|
||||
|
||||
const zoomScale = scale;
|
||||
const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId, scale,
|
||||
selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId, topCardNode,
|
||||
searchNodeMatches = makeMap() } = this.props;
|
||||
|
||||
// highlighter functions
|
||||
const setHighlighted = node => node.set('highlighted',
|
||||
@@ -73,12 +70,11 @@ class NodesChartNodes extends React.Component {
|
||||
subLabel={node.get('subLabel')}
|
||||
metric={metric(node)}
|
||||
rank={node.get('rank')}
|
||||
layoutPrecision={layoutPrecision}
|
||||
selectedNodeScale={selectedNodeScale}
|
||||
nodeScale={nodeScale}
|
||||
zoomScale={zoomScale}
|
||||
isAnimated={isAnimated}
|
||||
magnified={node.get('focused') ? selectedScale / scale : 1}
|
||||
dx={node.get('x')}
|
||||
dy={node.get('y')} />)}
|
||||
dy={node.get('y')}
|
||||
/>)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@ import { assign, pick, includes } from 'lodash';
|
||||
import { Map as makeMap, fromJS } from 'immutable';
|
||||
import timely from 'timely';
|
||||
|
||||
import { scaleThreshold, scaleLinear } from 'd3-scale';
|
||||
import { scaleThreshold } from 'd3-scale';
|
||||
import { event as d3Event, select } from 'd3-selection';
|
||||
import { zoom, zoomIdentity } from 'd3-zoom';
|
||||
|
||||
import { nodeAdjacenciesSelector, adjacentNodesSelector } from '../selectors/chartSelectors';
|
||||
import { clickBackground } from '../actions/app-actions';
|
||||
import { EDGE_ID_SEPARATOR } from '../constants/naming';
|
||||
import { MIN_NODE_SIZE, DETAILS_PANEL_WIDTH, MAX_NODE_SIZE } from '../constants/styles';
|
||||
import { DETAILS_PANEL_WIDTH, NODE_BASE_SIZE } from '../constants/styles';
|
||||
import Logo from '../components/logo';
|
||||
import { doLayout } from './nodes-layout';
|
||||
import NodesChartElements from './nodes-chart-elements';
|
||||
@@ -20,32 +20,20 @@ import { getActiveTopologyOptions } from '../utils/topology-utils';
|
||||
|
||||
const log = debug('scope:nodes-chart');
|
||||
|
||||
const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY'];
|
||||
const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY', 'minScale', 'maxScale'];
|
||||
|
||||
// make sure circular layouts a bit denser with 3-6 nodes
|
||||
const radiusDensity = scaleThreshold()
|
||||
.domain([3, 6])
|
||||
.range([2.5, 3.5, 3]);
|
||||
|
||||
/**
|
||||
* dynamic coords precision based on topology size
|
||||
*/
|
||||
function getLayoutPrecision(nodesCount) {
|
||||
let precision;
|
||||
if (nodesCount >= 50) {
|
||||
precision = 0;
|
||||
} else if (nodesCount > 20) {
|
||||
precision = 1;
|
||||
} else if (nodesCount > 10) {
|
||||
precision = 2;
|
||||
} else {
|
||||
precision = 3;
|
||||
}
|
||||
|
||||
return precision;
|
||||
}
|
||||
const emptyLayoutState = {
|
||||
nodes: makeMap(),
|
||||
edges: makeMap(),
|
||||
};
|
||||
|
||||
|
||||
// EDGES
|
||||
function initEdges(nodes) {
|
||||
let edges = makeMap();
|
||||
|
||||
@@ -76,24 +64,45 @@ function initEdges(nodes) {
|
||||
}
|
||||
|
||||
|
||||
function getNodeScale(nodesCount, width, height) {
|
||||
const expanse = Math.min(height, width);
|
||||
const nodeSize = expanse / 3; // single node should fill a third of the screen
|
||||
const maxNodeSize = Math.min(MAX_NODE_SIZE, expanse / 10);
|
||||
const normalizedNodeSize = Math.max(MIN_NODE_SIZE,
|
||||
Math.min(nodeSize / Math.sqrt(nodesCount), maxNodeSize));
|
||||
// ZOOM STATE
|
||||
function getLayoutDefaultZoom(layoutNodes, width, height) {
|
||||
const xMin = layoutNodes.minBy(n => n.get('x')).get('x');
|
||||
const xMax = layoutNodes.maxBy(n => n.get('x')).get('x');
|
||||
const yMin = layoutNodes.minBy(n => n.get('y')).get('y');
|
||||
const yMax = layoutNodes.maxBy(n => n.get('y')).get('y');
|
||||
|
||||
return scaleLinear().range([0, normalizedNodeSize]);
|
||||
const xFactor = width / (xMax - xMin);
|
||||
const yFactor = height / (yMax - yMin);
|
||||
const scale = Math.min(xFactor, yFactor);
|
||||
|
||||
return {
|
||||
translateX: (width - ((xMax + xMin) * scale)) / 2,
|
||||
translateY: (height - ((yMax + yMin) * scale)) / 2,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultZoomState(props, state) {
|
||||
// adjust layout based on viewport
|
||||
const width = state.width - props.margins.left - props.margins.right;
|
||||
const height = state.height - props.margins.top - props.margins.bottom;
|
||||
|
||||
const { translateX, translateY, scale } = getLayoutDefaultZoom(state.nodes, width, height);
|
||||
|
||||
return {
|
||||
scale,
|
||||
minScale: scale / 5,
|
||||
maxScale: Math.min(width, height) / NODE_BASE_SIZE / 3,
|
||||
panTranslateX: translateX + props.margins.left,
|
||||
panTranslateY: translateY + props.margins.top,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// LAYOUT STATE
|
||||
function updateLayout(width, height, nodes, baseOptions) {
|
||||
const nodeScale = getNodeScale(nodes.size, width, height);
|
||||
const edges = initEdges(nodes);
|
||||
|
||||
const options = Object.assign({}, baseOptions, {
|
||||
scale: nodeScale,
|
||||
});
|
||||
const options = Object.assign({}, baseOptions);
|
||||
|
||||
const timedLayouter = timely(doLayout);
|
||||
const graph = timedLayouter(nodes, edges, options);
|
||||
@@ -108,13 +117,52 @@ function updateLayout(width, height, nodes, baseOptions) {
|
||||
py: node.get('y')
|
||||
}));
|
||||
|
||||
const layoutEdges = graph.edges
|
||||
.map(edge => edge.set('ppoints', edge.get('points')));
|
||||
const layoutEdges = graph.edges.map(edge => edge.set('ppoints', edge.get('points')));
|
||||
|
||||
return { layoutNodes, layoutEdges, layoutWidth: graph.width, layoutHeight: graph.height };
|
||||
return { layoutNodes, layoutEdges };
|
||||
}
|
||||
|
||||
function updatedGraphState(props, state) {
|
||||
if (props.nodes.size === 0) {
|
||||
return emptyLayoutState;
|
||||
}
|
||||
|
||||
const options = {
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
margins: props.margins,
|
||||
forceRelayout: props.forceRelayout,
|
||||
topologyId: props.topologyId,
|
||||
topologyOptions: props.topologyOptions,
|
||||
};
|
||||
|
||||
const { layoutNodes, layoutEdges } =
|
||||
updateLayout(state.width, state.height, props.nodes, options);
|
||||
|
||||
return {
|
||||
nodes: layoutNodes,
|
||||
edges: layoutEdges,
|
||||
};
|
||||
}
|
||||
|
||||
function restoredLayout(state) {
|
||||
const restoredNodes = state.nodes.map(node => node.merge({
|
||||
x: node.get('px'),
|
||||
y: node.get('py')
|
||||
}));
|
||||
|
||||
const restoredEdges = state.edges.map(edge => (
|
||||
edge.has('ppoints') ? edge.set('points', edge.get('ppoints')) : edge
|
||||
));
|
||||
|
||||
return {
|
||||
nodes: restoredNodes,
|
||||
edges: restoredEdges,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// SELECTED NODE
|
||||
function centerSelectedNode(props, state) {
|
||||
let stateNodes = state.nodes;
|
||||
let stateEdges = state.edges;
|
||||
@@ -179,10 +227,10 @@ function centerSelectedNode(props, state) {
|
||||
});
|
||||
|
||||
// auto-scale node size for selected nodes
|
||||
const selectedNodeScale = getNodeScale(adjacentNodes.size, state.width, state.height);
|
||||
// const selectedNodeScale = getNodeScale(adjacentNodes.size, state.width, state.height);
|
||||
|
||||
return {
|
||||
selectedNodeScale,
|
||||
selectedScale: 1,
|
||||
edges: stateEdges,
|
||||
nodes: stateNodes
|
||||
};
|
||||
@@ -193,27 +241,26 @@ class NodesChart extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.handleMouseClick = this.handleMouseClick.bind(this);
|
||||
this.zoomed = this.zoomed.bind(this);
|
||||
|
||||
this.state = {
|
||||
edges: makeMap(),
|
||||
nodes: makeMap(),
|
||||
nodeScale: scaleLinear(),
|
||||
this.state = Object.assign({
|
||||
scale: 1,
|
||||
minScale: 1,
|
||||
maxScale: 1,
|
||||
panTranslateX: 0,
|
||||
panTranslateY: 0,
|
||||
scale: 1,
|
||||
selectedNodeScale: scaleLinear(),
|
||||
hasZoomed: false,
|
||||
selectedScale: 1,
|
||||
height: props.height || 0,
|
||||
width: props.width || 0,
|
||||
zoomCache: {},
|
||||
};
|
||||
}, emptyLayoutState);
|
||||
|
||||
this.handleMouseClick = this.handleMouseClick.bind(this);
|
||||
this.zoomed = this.zoomed.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const state = this.updateGraphState(this.props, this.state);
|
||||
const state = updatedGraphState(this.props, this.state);
|
||||
// debugger;
|
||||
// assign(state, this.restoreZoomState(this.props, Object.assign(this.state, state)));
|
||||
this.setState(state);
|
||||
}
|
||||
|
||||
@@ -221,25 +268,11 @@ class NodesChart extends React.Component {
|
||||
// gather state, setState should be called only once here
|
||||
const state = assign({}, this.state);
|
||||
|
||||
const topologyChanged = nextProps.topologyId !== this.props.topologyId;
|
||||
|
||||
// wipe node states when showing different topology
|
||||
if (nextProps.topologyId !== this.props.topologyId) {
|
||||
// re-apply cached canvas zoom/pan to d3 behavior (or set the default values)
|
||||
const defaultZoom = { scale: 1, panTranslateX: 0, panTranslateY: 0, hasZoomed: false };
|
||||
const nextZoom = this.state.zoomCache[nextProps.topologyId] || defaultZoom;
|
||||
if (nextZoom) {
|
||||
this.setZoom(nextZoom);
|
||||
}
|
||||
|
||||
// saving previous zoom state
|
||||
const prevZoom = pick(this.state, ZOOM_CACHE_FIELDS);
|
||||
const zoomCache = assign({}, this.state.zoomCache);
|
||||
zoomCache[this.props.topologyId] = prevZoom;
|
||||
|
||||
// clear canvas and apply zoom state
|
||||
assign(state, nextZoom, { zoomCache }, {
|
||||
nodes: makeMap(),
|
||||
edges: makeMap()
|
||||
});
|
||||
if (topologyChanged) {
|
||||
assign(state, emptyLayoutState);
|
||||
}
|
||||
|
||||
// reset layout dimensions only when forced
|
||||
@@ -247,29 +280,50 @@ class NodesChart extends React.Component {
|
||||
state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width);
|
||||
|
||||
if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) {
|
||||
assign(state, this.updateGraphState(nextProps, state));
|
||||
assign(state, updatedGraphState(nextProps, state));
|
||||
}
|
||||
|
||||
if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
|
||||
assign(state, this.restoreLayout(state));
|
||||
}
|
||||
if (nextProps.selectedNodeId) {
|
||||
assign(state, centerSelectedNode(nextProps, state));
|
||||
console.log(`Prepare ${nextProps.nodes.size}`);
|
||||
if (nextProps.nodes.size > 0) {
|
||||
console.log(state.zoomCache);
|
||||
assign(state, this.restoreZoomState(nextProps, state));
|
||||
}
|
||||
|
||||
// if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
|
||||
// // undo any pan/zooming that might have happened
|
||||
// this.setZoom(state);
|
||||
// assign(state, restoredLayout(state));
|
||||
// }
|
||||
//
|
||||
// if (nextProps.selectedNodeId) {
|
||||
// assign(state, centerSelectedNode(nextProps, state));
|
||||
// }
|
||||
|
||||
if (topologyChanged) {
|
||||
// saving previous zoom state
|
||||
const prevZoom = pick(this.state, ZOOM_CACHE_FIELDS);
|
||||
const zoomCache = assign({}, this.state.zoomCache);
|
||||
zoomCache[this.props.topologyId] = prevZoom;
|
||||
assign(state, { zoomCache });
|
||||
}
|
||||
|
||||
// console.log(topologyChanged);
|
||||
// console.log(state);
|
||||
this.setState(state);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// distinguish pan/zoom from click
|
||||
this.isZooming = false;
|
||||
// debugger;
|
||||
|
||||
this.zoom = zoom()
|
||||
.scaleExtent([0.1, 2])
|
||||
.scaleExtent([this.state.minScale, this.state.maxScale])
|
||||
.on('zoom', this.zoomed);
|
||||
|
||||
this.svg = select('.nodes-chart svg');
|
||||
this.svg.call(this.zoom);
|
||||
// this.setZoom(this.state);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -282,15 +336,19 @@ class NodesChart extends React.Component {
|
||||
.on('touchstart.zoom', null);
|
||||
}
|
||||
|
||||
isSmallTopology() {
|
||||
return this.state.nodes.size < 100;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state;
|
||||
console.log(`Render ${nodes.size}`);
|
||||
|
||||
// not passing translates into child components for perf reasons, use getTranslate instead
|
||||
const translate = [panTranslateX, panTranslateY];
|
||||
const transform = `translate(${translate}) scale(${scale})`;
|
||||
const svgClassNames = this.props.isEmpty ? 'hide' : '';
|
||||
|
||||
const layoutPrecision = getLayoutPrecision(nodes.size);
|
||||
return (
|
||||
<div className="nodes-chart">
|
||||
<svg
|
||||
@@ -302,11 +360,10 @@ class NodesChart extends React.Component {
|
||||
<NodesChartElements
|
||||
layoutNodes={nodes}
|
||||
layoutEdges={edges}
|
||||
nodeScale={this.state.nodeScale}
|
||||
scale={scale}
|
||||
selectedScale={this.state.selectedScale}
|
||||
transform={transform}
|
||||
selectedNodeScale={this.state.selectedNodeScale}
|
||||
layoutPrecision={layoutPrecision} />
|
||||
isAnimated={this.isSmallTopology()} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@@ -320,74 +377,28 @@ class NodesChart extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
restoreLayout(state) {
|
||||
// undo any pan/zooming that might have happened
|
||||
this.setZoom(state);
|
||||
|
||||
const nodes = state.nodes.map(node => node.merge({
|
||||
x: node.get('px'),
|
||||
y: node.get('py')
|
||||
}));
|
||||
|
||||
const edges = state.edges.map((edge) => {
|
||||
if (edge.has('ppoints')) {
|
||||
return edge.set('points', edge.get('ppoints'));
|
||||
}
|
||||
return edge;
|
||||
});
|
||||
|
||||
return { edges, nodes };
|
||||
}
|
||||
|
||||
updateGraphState(props, state) {
|
||||
if (props.nodes.size === 0) {
|
||||
return {
|
||||
nodes: makeMap(),
|
||||
edges: makeMap()
|
||||
};
|
||||
restoreZoomState(props, state) {
|
||||
// re-apply cached canvas zoom/pan to d3 behavior (or set the default values)
|
||||
const nextZoom = state.zoomCache[props.topologyId] || defaultZoomState(props, state);
|
||||
if (this.zoom) {
|
||||
this.zoom = this.zoom.scaleExtent([nextZoom.minScale, nextZoom.maxScale]);
|
||||
this.setZoom(nextZoom);
|
||||
}
|
||||
|
||||
const options = {
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
margins: props.margins,
|
||||
forceRelayout: props.forceRelayout,
|
||||
topologyId: props.topologyId,
|
||||
topologyOptions: props.topologyOptions,
|
||||
};
|
||||
|
||||
const { layoutNodes, layoutEdges, layoutWidth, layoutHeight } = updateLayout(
|
||||
state.width, state.height, props.nodes, options);
|
||||
//
|
||||
// adjust layout based on viewport
|
||||
const xFactor = (state.width - props.margins.left - props.margins.right) / layoutWidth;
|
||||
const yFactor = state.height / layoutHeight;
|
||||
const zoomFactor = Math.min(xFactor, yFactor);
|
||||
let zoomScale = state.scale;
|
||||
|
||||
if (this.svg && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
|
||||
zoomScale = zoomFactor;
|
||||
}
|
||||
|
||||
return {
|
||||
scale: zoomScale,
|
||||
nodes: layoutNodes,
|
||||
edges: layoutEdges,
|
||||
nodeScale: getNodeScale(props.nodes.size, state.width, state.height),
|
||||
};
|
||||
return nextZoom;
|
||||
}
|
||||
|
||||
zoomed() {
|
||||
this.isZooming = true;
|
||||
// dont pan while node is selected
|
||||
// don't pan while node is selected
|
||||
if (!this.props.selectedNodeId) {
|
||||
this.setState({
|
||||
hasZoomed: true,
|
||||
panTranslateX: d3Event.transform.x,
|
||||
panTranslateY: d3Event.transform.y,
|
||||
scale: d3Event.transform.k
|
||||
});
|
||||
}
|
||||
// console.log(d3Event.transform);
|
||||
}
|
||||
|
||||
setZoom(newZoom) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import dagre from 'dagre';
|
||||
import debug from 'debug';
|
||||
import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable';
|
||||
|
||||
import { NODE_BASE_SIZE } from '../constants/styles';
|
||||
import { EDGE_ID_SEPARATOR } from '../constants/naming';
|
||||
import { featureIsEnabledAny } from '../utils/feature-utils';
|
||||
import { buildTopologyCacheId, updateNodeDegrees } from '../utils/topology-utils';
|
||||
@@ -12,10 +13,9 @@ const topologyCaches = {};
|
||||
export const DEFAULT_WIDTH = 800;
|
||||
export const DEFAULT_HEIGHT = DEFAULT_WIDTH / 2;
|
||||
export const DEFAULT_MARGINS = {top: 0, left: 0};
|
||||
const DEFAULT_SCALE = val => val * 2;
|
||||
const NODE_SIZE_FACTOR = 1;
|
||||
const NODE_SEPARATION_FACTOR = 2.0;
|
||||
const RANK_SEPARATION_FACTOR = 3.0;
|
||||
const NODE_SIZE_FACTOR = NODE_BASE_SIZE;
|
||||
const NODE_SEPARATION_FACTOR = 2 * NODE_BASE_SIZE;
|
||||
const RANK_SEPARATION_FACTOR = 3 * NODE_BASE_SIZE;
|
||||
let layoutRuns = 0;
|
||||
let layoutRunsTrivial = 0;
|
||||
|
||||
@@ -34,19 +34,16 @@ function fromGraphNodeId(encodedId) {
|
||||
* @param {Object} graph dagre graph instance
|
||||
* @param {Map} imNodes new node set
|
||||
* @param {Map} imEdges new edge set
|
||||
* @param {Object} opts dimensions, scales, etc.
|
||||
* @return {Object} Layout with nodes, edges, dimensions
|
||||
*/
|
||||
function runLayoutEngine(graph, imNodes, imEdges, opts) {
|
||||
function runLayoutEngine(graph, imNodes, imEdges) {
|
||||
let nodes = imNodes;
|
||||
let edges = imEdges;
|
||||
|
||||
const options = opts || {};
|
||||
const scale = options.scale || DEFAULT_SCALE;
|
||||
const ranksep = scale(RANK_SEPARATION_FACTOR);
|
||||
const nodesep = scale(NODE_SEPARATION_FACTOR);
|
||||
const nodeWidth = scale(NODE_SIZE_FACTOR);
|
||||
const nodeHeight = scale(NODE_SIZE_FACTOR);
|
||||
const ranksep = RANK_SEPARATION_FACTOR;
|
||||
const nodesep = NODE_SEPARATION_FACTOR;
|
||||
const nodeWidth = NODE_SIZE_FACTOR;
|
||||
const nodeHeight = NODE_SIZE_FACTOR;
|
||||
|
||||
// configure node margins
|
||||
graph.setGraph({
|
||||
@@ -154,12 +151,10 @@ function setSimpleEdgePoints(edge, nodeCache) {
|
||||
* @param {object} opts Options
|
||||
* @return {object} new layout object
|
||||
*/
|
||||
export function doLayoutNewNodesOfExistingRank(layout, nodeCache, opts) {
|
||||
export function doLayoutNewNodesOfExistingRank(layout, nodeCache) {
|
||||
const result = Object.assign({}, layout);
|
||||
const options = opts || {};
|
||||
const scale = options.scale || DEFAULT_SCALE;
|
||||
const nodesep = scale(NODE_SEPARATION_FACTOR);
|
||||
const nodeWidth = scale(NODE_SIZE_FACTOR);
|
||||
const nodesep = NODE_SEPARATION_FACTOR;
|
||||
const nodeWidth = NODE_SIZE_FACTOR;
|
||||
|
||||
// determine new nodes
|
||||
const oldNodes = ImmSet.fromKeys(nodeCache);
|
||||
@@ -200,11 +195,10 @@ function layoutSingleNodes(layout, opts) {
|
||||
const result = Object.assign({}, layout);
|
||||
const options = opts || {};
|
||||
const margins = options.margins || DEFAULT_MARGINS;
|
||||
const scale = options.scale || DEFAULT_SCALE;
|
||||
const ranksep = scale(RANK_SEPARATION_FACTOR) / 2; // dagre splits it in half
|
||||
const nodesep = scale(NODE_SEPARATION_FACTOR);
|
||||
const nodeWidth = scale(NODE_SIZE_FACTOR);
|
||||
const nodeHeight = scale(NODE_SIZE_FACTOR);
|
||||
const ranksep = RANK_SEPARATION_FACTOR / 2; // dagre splits it in half
|
||||
const nodesep = NODE_SEPARATION_FACTOR;
|
||||
const nodeWidth = NODE_SIZE_FACTOR;
|
||||
const nodeHeight = NODE_SIZE_FACTOR;
|
||||
const graphHeight = layout.graphHeight || layout.height;
|
||||
const graphWidth = layout.graphWidth || layout.width;
|
||||
const aspectRatio = graphHeight ? graphWidth / graphHeight : 1;
|
||||
@@ -271,50 +265,6 @@ function layoutSingleNodes(layout, opts) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shifts all coordinates of node and edge points to make the layout more centered
|
||||
* @param {Object} layout Layout
|
||||
* @param {Object} opts Options with width and margins
|
||||
* @return {Object} modified layout
|
||||
*/
|
||||
export function shiftLayoutToCenter(layout, opts) {
|
||||
const result = Object.assign({}, layout);
|
||||
const options = opts || {};
|
||||
const margins = options.margins || DEFAULT_MARGINS;
|
||||
const width = options.width || DEFAULT_WIDTH;
|
||||
const height = options.height || DEFAULT_HEIGHT;
|
||||
|
||||
let offsetX = 0 + margins.left;
|
||||
let offsetY = 0 + margins.top;
|
||||
|
||||
if (layout.width < width) {
|
||||
const xMin = layout.nodes.minBy(n => n.get('x'));
|
||||
const xMax = layout.nodes.maxBy(n => n.get('x'));
|
||||
offsetX = ((width - (xMin.get('x') + xMax.get('x'))) / 2) + margins.left;
|
||||
}
|
||||
if (layout.height < height) {
|
||||
const yMin = layout.nodes.minBy(n => n.get('y'));
|
||||
const yMax = layout.nodes.maxBy(n => n.get('y'));
|
||||
offsetY = ((height - (yMin.get('y') + yMax.get('y'))) / 2) + margins.top;
|
||||
}
|
||||
|
||||
if (offsetX || offsetY) {
|
||||
result.nodes = layout.nodes.map(node => node.merge({
|
||||
x: node.get('x') + offsetX,
|
||||
y: node.get('y') + offsetY
|
||||
}));
|
||||
|
||||
result.edges = layout.edges.map(edge => edge.update('points',
|
||||
points => points.map(point => point.merge({
|
||||
x: point.get('x') + offsetX,
|
||||
y: point.get('y') + offsetY
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if nodes were added between node sets
|
||||
* @param {Map} nodes new Map of nodes
|
||||
@@ -478,17 +428,16 @@ export function doLayout(immNodes, immEdges, opts) {
|
||||
log('skip layout, used rank-based insertion');
|
||||
layout = cloneLayout(cachedLayout, nodesWithDegrees, immEdges);
|
||||
layout = copyLayoutProperties(layout, nodeCache, edgeCache);
|
||||
layout = doLayoutNewNodesOfExistingRank(layout, nodeCache, opts);
|
||||
layout = doLayoutNewNodesOfExistingRank(layout, nodeCache);
|
||||
} else {
|
||||
const graph = cache.graph;
|
||||
layout = runLayoutEngine(graph, nodesWithDegrees, immEdges, opts);
|
||||
layout = runLayoutEngine(graph, nodesWithDegrees, immEdges);
|
||||
if (!layout) {
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
|
||||
layout = layoutSingleNodes(layout, opts);
|
||||
layout = shiftLayoutToCenter(layout, opts);
|
||||
}
|
||||
|
||||
// cache results
|
||||
|
||||
@@ -6,7 +6,6 @@ import { line, curveLinear } from 'd3-shape';
|
||||
import { scaleLinear } from 'd3-scale';
|
||||
|
||||
import { formatMetricSvg } from '../utils/string-utils';
|
||||
import { round } from '../utils/math-utils';
|
||||
|
||||
|
||||
export default class Sparkline extends React.Component {
|
||||
@@ -64,7 +63,7 @@ export default class Sparkline extends React.Component {
|
||||
const min = formatMetricSvg(d3Min(data, d => d.value), this.props);
|
||||
const max = formatMetricSvg(d3Max(data, d => d.value), this.props);
|
||||
const mean = formatMetricSvg(d3Mean(data, d => d.value), this.props);
|
||||
const title = `Last ${round((lastDate - firstDate) / 1000)} seconds, ` +
|
||||
const title = `Last ${Math.round((lastDate - firstDate) / 1000)} seconds, ` +
|
||||
`${data.length} samples, min: ${min}, max: ${max}, mean: ${mean}`;
|
||||
|
||||
return {title, lastX, lastY, data};
|
||||
|
||||
2
client/app/scripts/constants/animation.js
Normal file
2
client/app/scripts/constants/animation.js
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
export const NODES_SPRING_ANIMATION_CONFIG = { stiffness: 80, damping: 20, precision: 0.1 };
|
||||
@@ -18,14 +18,13 @@ export const CANVAS_MARGINS = {
|
||||
bottom: 100,
|
||||
};
|
||||
|
||||
//
|
||||
// The base size the shapes were defined at matches nicely w/ a 14px font.
|
||||
//
|
||||
export const BASE_NODE_SIZE = 64;
|
||||
export const MIN_NODE_SIZE = 24;
|
||||
export const MAX_NODE_SIZE = 96;
|
||||
export const BASE_NODE_LABEL_SIZE = 14;
|
||||
export const MIN_NODE_LABEL_SIZE = 12;
|
||||
// Node shapes
|
||||
export const NODE_SHAPE_HIGHLIGHT_RADIUS = 0.7;
|
||||
export const NODE_SHAPE_BORDER_RADIUS = 0.5;
|
||||
export const NODE_SHAPE_SHADOW_RADIUS = 0.45;
|
||||
export const NODE_SHAPE_DOT_RADIUS = 0.125;
|
||||
export const NODE_BLUR_OPACITY = 0.2;
|
||||
export const NODE_BASE_SIZE = 50;
|
||||
|
||||
// Node details table constants
|
||||
export const NODE_DETAILS_TABLE_CW = {
|
||||
|
||||
@@ -2,8 +2,6 @@ import React from 'react';
|
||||
import { isoParse as parseDate } from 'd3-time-format';
|
||||
import { OrderedMap } from 'immutable';
|
||||
|
||||
import { round } from '../utils/math-utils';
|
||||
|
||||
const makeOrderedMap = OrderedMap;
|
||||
const sortDate = (v, d) => d;
|
||||
const DEFAULT_TICK_INTERVAL = 1000; // DEFAULT_TICK_INTERVAL + renderTime < 1000ms
|
||||
@@ -104,7 +102,7 @@ export default ComposedComponent => class extends React.Component {
|
||||
let lastIndex = bufferKeys.indexOf(movingLast);
|
||||
|
||||
// speed up the window if it falls behind
|
||||
const step = lastIndex > 0 ? round(buffer.size / lastIndex) : 1;
|
||||
const step = lastIndex > 0 ? Math.round(buffer.size / lastIndex) : 1;
|
||||
|
||||
// only move first if we have enough values in window
|
||||
const windowLength = lastIndex - firstIndex;
|
||||
|
||||
@@ -19,21 +19,4 @@ describe('MathUtils', () => {
|
||||
expect(f(-5, 5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round', () => {
|
||||
const f = MathUtils.round;
|
||||
|
||||
it('it should round the decimal number to given precision', () => {
|
||||
expect(f(-173.6499023, -2)).toBe(-200);
|
||||
expect(f(-173.6499023, -1)).toBe(-170);
|
||||
expect(f(-173.6499023, 0)).toBe(-174);
|
||||
expect(f(-173.6499023)).toBe(-174);
|
||||
expect(f(-173.6499023, 1)).toBe(-173.6);
|
||||
expect(f(-173.6499023, 2)).toBe(-173.65);
|
||||
expect(f(0.0013, 2)).toBe(0);
|
||||
expect(f(0.0013, 3)).toBe(0.001);
|
||||
expect(f(0.0013, 4)).toBe(0.0013);
|
||||
expect(f(0.0013, 5)).toBe(0.0013);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,10 +18,3 @@
|
||||
export function modulo(i, n) {
|
||||
return ((i % n) + n) % n;
|
||||
}
|
||||
|
||||
// Does the same that the deprecated d3.round was doing.
|
||||
// Possibly imprecise: This https://github.com/d3/d3/issues/210
|
||||
export function round(value, decimals = 0) {
|
||||
const p = Math.pow(10, decimals);
|
||||
return Math.round(value * p) / p;
|
||||
}
|
||||
|
||||
@@ -2,32 +2,30 @@ import { includes } from 'lodash';
|
||||
import { scaleLog } from 'd3-scale';
|
||||
import React from 'react';
|
||||
|
||||
import { NODE_SHAPE_DOT_RADIUS } from '../constants/styles';
|
||||
import { formatMetricSvg } from './string-utils';
|
||||
import { colors } from './color-utils';
|
||||
|
||||
export function getClipPathDefinition(clipId, size, height,
|
||||
x = -size * 0.5, y = (size * 0.5) - height) {
|
||||
export function getClipPathDefinition(clipId, height) {
|
||||
return (
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<rect
|
||||
width={size}
|
||||
height={size}
|
||||
x={x}
|
||||
y={y}
|
||||
/>
|
||||
<rect width={1} height={1} x={-0.5} y={0.5 - height} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderMetricValue(value, condition) {
|
||||
return condition ? <text>{value}</text> : <circle className="node" r={NODE_SHAPE_DOT_RADIUS} />;
|
||||
}
|
||||
|
||||
//
|
||||
// loadScale(1) == 0.5; E.g. a nicely balanced system :).
|
||||
const loadScale = scaleLog().domain([0.01, 100]).range([0, 1]);
|
||||
|
||||
|
||||
export function getMetricValue(metric, size) {
|
||||
export function getMetricValue(metric) {
|
||||
if (!metric) {
|
||||
return {height: 0, value: null, formattedValue: 'n/a'};
|
||||
}
|
||||
@@ -48,10 +46,9 @@ export function getMetricValue(metric, size) {
|
||||
} else if (displayedValue >= m.max && displayedValue > 0) {
|
||||
displayedValue = 1;
|
||||
}
|
||||
const height = size * displayedValue;
|
||||
|
||||
return {
|
||||
height,
|
||||
height: displayedValue,
|
||||
hasMetric: value !== null,
|
||||
formattedValue: formatMetricSvg(value, m)
|
||||
};
|
||||
|
||||
12
client/app/scripts/utils/node-shape-utils.js
Normal file
12
client/app/scripts/utils/node-shape-utils.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { line, curveCardinalClosed } from 'd3-shape';
|
||||
import range from 'lodash/range';
|
||||
|
||||
const shapeSpline = line().curve(curveCardinalClosed.tension(0.65));
|
||||
|
||||
export function nodeShapePolygon(radius, n) {
|
||||
const innerAngle = (2 * Math.PI) / n;
|
||||
return shapeSpline(range(0, n).map(k => [
|
||||
radius * Math.sin(k * innerAngle),
|
||||
-radius * Math.cos(k * innerAngle)
|
||||
]));
|
||||
}
|
||||
@@ -299,7 +299,7 @@
|
||||
fill: $text-secondary-color;
|
||||
}
|
||||
|
||||
.nodes-chart-nodes > .node {
|
||||
.nodes-chart-nodes .node {
|
||||
transition: opacity .5s $base-ease;
|
||||
text-align: center;
|
||||
|
||||
@@ -316,6 +316,14 @@
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.node-labels-container {
|
||||
transform: scale($node-text-scale);
|
||||
pointer-events: none;
|
||||
height: 5em;
|
||||
x: -0.5 * $node-labels-max-width;
|
||||
width: $node-labels-max-width;
|
||||
}
|
||||
|
||||
.node-label-wrapper {
|
||||
//
|
||||
// Base line height doesn't hop across foreignObject =/
|
||||
@@ -336,6 +344,9 @@
|
||||
vertical-align: top;
|
||||
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.node-sublabel {
|
||||
@@ -344,7 +355,6 @@
|
||||
}
|
||||
|
||||
.node-label, .node-sublabel {
|
||||
|
||||
span {
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -411,15 +421,13 @@
|
||||
}
|
||||
|
||||
.link {
|
||||
stroke: $text-secondary-color;
|
||||
stroke-width: $edge-link-stroke-width;
|
||||
fill: none;
|
||||
stroke-width: $edge-link-stroke-width;
|
||||
stroke-opacity: $edge-opacity;
|
||||
}
|
||||
.shadow {
|
||||
stroke: $weave-blue;
|
||||
stroke-width: 10px;
|
||||
fill: none;
|
||||
stroke: $weave-blue;
|
||||
stroke-opacity: 0;
|
||||
}
|
||||
&.highlighted {
|
||||
@@ -433,7 +441,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .onlyHighlight .shape {
|
||||
.stack .highlight .shape {
|
||||
.border { display: none; }
|
||||
.shadow { display: none; }
|
||||
.node { display: none; }
|
||||
@@ -448,8 +456,7 @@
|
||||
transform: scale(1);
|
||||
cursor: pointer;
|
||||
|
||||
/* cloud paths have stroke-width set dynamically */
|
||||
&:not(.shape-cloud) .border {
|
||||
.border {
|
||||
stroke-width: $node-border-stroke-width;
|
||||
fill: $background-color;
|
||||
transition: stroke-opacity 0.333s $base-ease, fill 0.333s $base-ease;
|
||||
@@ -475,10 +482,11 @@
|
||||
.node {
|
||||
fill: $text-color;
|
||||
stroke: $background-lighter-color;
|
||||
stroke-width: 2px;
|
||||
stroke-width: 0.05;
|
||||
}
|
||||
|
||||
text {
|
||||
transform: scale($node-text-scale);
|
||||
font-size: 12px;
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
@@ -494,7 +502,7 @@
|
||||
}
|
||||
|
||||
.stack .shape .border {
|
||||
stroke-width: $node-border-stroke-width - 0.5;
|
||||
stroke-width: $node-border-stroke-width * 0.8;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -14,13 +14,12 @@ $white: white;
|
||||
$node-opacity-blurred: 0.6;
|
||||
$node-highlight-fill-opacity: 0.3;
|
||||
$node-highlight-stroke-opacity: 0.5;
|
||||
$node-highlight-stroke-width: 3px;
|
||||
$node-border-stroke-width: 5px;
|
||||
$node-highlight-stroke-width: 0.06;
|
||||
$node-border-stroke-width: 0.1;
|
||||
$node-pseudo-opacity: 1;
|
||||
$edge-highlight-opacity: 0.3;
|
||||
$edge-opacity-blurred: 0;
|
||||
$edge-opacity: 0.5;
|
||||
$edge-link-stroke-width: 3px;
|
||||
|
||||
$btn-opacity-default: 1;
|
||||
$btn-opacity-hover: 1;
|
||||
|
||||
@@ -33,13 +33,14 @@ $terminal-header-height: 44px;
|
||||
$node-opacity-blurred: 0.25;
|
||||
$node-highlight-fill-opacity: 0.1;
|
||||
$node-highlight-stroke-opacity: 0.4;
|
||||
$node-highlight-stroke-width: 1px;
|
||||
$node-border-stroke-width: 2.5px;
|
||||
$node-highlight-stroke-width: 0.02;
|
||||
$node-border-stroke-width: 0.06;
|
||||
$node-pseudo-opacity: 0.8;
|
||||
$node-text-scale: 0.02;
|
||||
$node-labels-max-width: 120px;
|
||||
$edge-highlight-opacity: 0.1;
|
||||
$edge-opacity-blurred: 0.2;
|
||||
$edge-opacity: 0.5;
|
||||
$edge-link-stroke-width: 1px;
|
||||
|
||||
$btn-opacity-default: 0.7;
|
||||
$btn-opacity-hover: 1;
|
||||
|
||||
Reference in New Issue
Block a user