mirror of
https://github.com/weaveworks/scope.git
synced 2026-08-18 11:56:39 +00:00
Split the zooming and layout logic in nodes-chart.js between a bunch of selectors
This commit is contained in:
@@ -36,6 +36,5 @@
|
||||
"react/prefer-stateless-function": 0,
|
||||
"react/sort-comp": 0,
|
||||
"react/prop-types": 0,
|
||||
"no-unused-vars": 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,54 +167,6 @@ 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,
|
||||
|
||||
@@ -3,6 +3,8 @@ 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 {
|
||||
|
||||
@@ -13,15 +15,19 @@ class Edge extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { id, path, highlighted, blurred, focused } = this.props;
|
||||
const className = classNames('edge', {highlighted, blurred, focused});
|
||||
const { id, path, highlighted, blurred, focused, scale } = this.props;
|
||||
const className = classNames('edge', { highlighted, blurred, focused });
|
||||
const thickness = scale * (isContrastMode() ? 0.015 : 0.0075) * NODE_BASE_SIZE;
|
||||
|
||||
// Draws the edge so that its thickness reflects the zoom scale.
|
||||
// Edge shadow is always made 10x thicker than the edge itself.
|
||||
return (
|
||||
<g
|
||||
className={className} onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave} id={id}>
|
||||
<path d={path} className="shadow" />
|
||||
<path d={path} className="link" />
|
||||
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 }} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class Node extends React.Component {
|
||||
|
||||
const color = getNodeColor(rank, label, pseudo);
|
||||
const truncate = !focused && !hovered;
|
||||
const labelOffsetY = (showingNetworks && networks) ? 40 : 30;
|
||||
const labelOffsetY = (showingNetworks && networks) ? 40 : 28;
|
||||
const networkOffset = 0.67;
|
||||
|
||||
const nodeClassName = classnames('node', {
|
||||
|
||||
@@ -7,9 +7,9 @@ import EdgeContainer from './edge-container';
|
||||
|
||||
class NodesChartEdges extends React.Component {
|
||||
render() {
|
||||
const { hasSelectedNode, highlightedEdgeIds, layoutEdges,
|
||||
searchNodeMatches = makeMap(), searchQuery, isAnimated,
|
||||
selectedNodeId, selectedNetwork, selectedNetworkNodes } = this.props;
|
||||
const { hasSelectedNode, highlightedEdgeIds, layoutEdges, searchQuery,
|
||||
isAnimated, selectedScale, selectedNodeId, selectedNetwork, selectedNetworkNodes,
|
||||
searchNodeMatches = makeMap() } = this.props;
|
||||
|
||||
return (
|
||||
<g className="nodes-chart-edges">
|
||||
@@ -36,6 +36,7 @@ class NodesChartEdges extends React.Component {
|
||||
source={edge.get('source')}
|
||||
target={edge.get('target')}
|
||||
waypoints={edge.get('points')}
|
||||
scale={focused ? selectedScale : 1}
|
||||
isAnimated={isAnimated}
|
||||
blurred={blurred}
|
||||
focused={focused}
|
||||
|
||||
@@ -12,10 +12,10 @@ class NodesChartElements extends React.Component {
|
||||
<g className="nodes-chart-elements" transform={props.transform}>
|
||||
<NodesChartEdges
|
||||
layoutEdges={props.layoutEdges}
|
||||
selectedScale={props.selectedScale}
|
||||
isAnimated={props.isAnimated} />
|
||||
<NodesChartNodes
|
||||
layoutNodes={props.completeNodes}
|
||||
scale={props.scale}
|
||||
selectedScale={props.selectedScale}
|
||||
isAnimated={props.isAnimated} />
|
||||
</g>
|
||||
|
||||
@@ -7,9 +7,9 @@ import NodeContainer from './node-container';
|
||||
|
||||
class NodesChartNodes extends React.Component {
|
||||
render() {
|
||||
const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId, scale,
|
||||
selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId, topCardNode,
|
||||
searchNodeMatches = makeMap() } = this.props;
|
||||
const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId,
|
||||
selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId,
|
||||
topCardNode, searchNodeMatches = makeMap() } = this.props;
|
||||
|
||||
// highlighter functions
|
||||
const setHighlighted = node => node.set('highlighted',
|
||||
@@ -71,7 +71,7 @@ class NodesChartNodes extends React.Component {
|
||||
metric={metric(node)}
|
||||
rank={node.get('rank')}
|
||||
isAnimated={isAnimated}
|
||||
magnified={node.get('focused') ? selectedScale / scale : 1}
|
||||
magnified={node.get('focused') ? selectedScale : 1}
|
||||
dx={node.get('x')}
|
||||
dy={node.get('y')}
|
||||
/>)}
|
||||
|
||||
@@ -1,329 +1,66 @@
|
||||
import debug from 'debug';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { assign, pick, includes } from 'lodash';
|
||||
import { Map as makeMap, fromJS } from 'immutable';
|
||||
import timely from 'timely';
|
||||
import { assign, pick } from 'lodash';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
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 { 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';
|
||||
import { getActiveTopologyOptions } from '../utils/topology-utils';
|
||||
|
||||
const log = debug('scope:nodes-chart');
|
||||
|
||||
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]);
|
||||
|
||||
const emptyLayoutState = {
|
||||
nodes: makeMap(),
|
||||
edges: makeMap(),
|
||||
};
|
||||
import { topologyZoomState } from '../selectors/nodes-chart-zoom';
|
||||
import { layoutWithSelectedNode } from '../selectors/nodes-chart-focus';
|
||||
import { graphLayout } from '../selectors/nodes-chart-layout';
|
||||
|
||||
|
||||
// EDGES
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
// 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');
|
||||
|
||||
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 edges = initEdges(nodes);
|
||||
const options = Object.assign({}, baseOptions);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
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 {
|
||||
selectedScale: 1,
|
||||
edges: stateEdges,
|
||||
nodes: stateNodes
|
||||
};
|
||||
}
|
||||
const GRAPH_COMPLEXITY_NODES_TRESHOLD = 100;
|
||||
const ZOOM_CACHE_FIELDS = [
|
||||
'panTranslateX', 'panTranslateY',
|
||||
'zoomScale', 'minZoomScale', 'maxZoomScale'
|
||||
];
|
||||
|
||||
|
||||
class NodesChart extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = Object.assign({
|
||||
scale: 1,
|
||||
minScale: 1,
|
||||
maxScale: 1,
|
||||
|
||||
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: {},
|
||||
}, emptyLayoutState);
|
||||
};
|
||||
|
||||
this.handleMouseClick = this.handleMouseClick.bind(this);
|
||||
this.zoomed = this.zoomed.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const state = updatedGraphState(this.props, this.state);
|
||||
// debugger;
|
||||
// assign(state, this.restoreZoomState(this.props, Object.assign(this.state, state)));
|
||||
this.setState(state);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// 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 (topologyChanged) {
|
||||
assign(state, emptyLayoutState);
|
||||
}
|
||||
|
||||
// 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, updatedGraphState(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);
|
||||
this.setState(graphLayout(this.state, this.props));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// distinguish pan/zoom from click
|
||||
this.isZooming = false;
|
||||
// debugger;
|
||||
|
||||
this.zoom = zoom()
|
||||
.scaleExtent([this.state.minScale, this.state.maxScale])
|
||||
.on('zoom', this.zoomed);
|
||||
this.zoom = zoom().on('zoom', this.zoomed);
|
||||
|
||||
this.svg = select('.nodes-chart svg');
|
||||
this.svg.call(this.zoom);
|
||||
// this.setZoom(this.state);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -336,18 +73,39 @@ class NodesChart extends React.Component {
|
||||
.on('touchstart.zoom', null);
|
||||
}
|
||||
|
||||
isSmallTopology() {
|
||||
return this.state.nodes.size < 100;
|
||||
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() {
|
||||
const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state;
|
||||
console.log(`Render ${nodes.size}`);
|
||||
// Not passing transform into child components for perf reasons.
|
||||
const { panTranslateX, panTranslateY, zoomScale } = this.state;
|
||||
const transform = `translate(${panTranslateX}, ${panTranslateY}) scale(${zoomScale})`;
|
||||
|
||||
// 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();
|
||||
|
||||
return (
|
||||
<div className="nodes-chart">
|
||||
@@ -358,12 +116,11 @@ class NodesChart extends React.Component {
|
||||
<Logo />
|
||||
</g>
|
||||
<NodesChartElements
|
||||
layoutNodes={nodes}
|
||||
layoutEdges={edges}
|
||||
scale={scale}
|
||||
layoutNodes={this.state.layoutNodes}
|
||||
layoutEdges={this.state.layoutEdges}
|
||||
selectedScale={this.state.selectedScale}
|
||||
transform={transform}
|
||||
isAnimated={this.isSmallTopology()} />
|
||||
isAnimated={isAnimated} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@@ -377,34 +134,38 @@ class NodesChart extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
isTopologyGraphComplex() {
|
||||
return this.state.layoutNodes.size > GRAPH_COMPLEXITY_NODES_TRESHOLD;
|
||||
}
|
||||
|
||||
return nextZoom;
|
||||
cacheZoomState(state) {
|
||||
const zoomState = pick(state, ZOOM_CACHE_FIELDS);
|
||||
const zoomCache = assign({}, state.zoomCache);
|
||||
zoomCache[this.props.topologyId] = zoomState;
|
||||
return { zoomCache };
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
zoomed() {
|
||||
this.isZooming = true;
|
||||
// don't pan while node is selected
|
||||
if (!this.props.selectedNodeId) {
|
||||
this.setState({
|
||||
let state = assign({}, this.state, {
|
||||
panTranslateX: d3Event.transform.x,
|
||||
panTranslateY: d3Event.transform.y,
|
||||
scale: d3Event.transform.k
|
||||
zoomScale: 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);
|
||||
}
|
||||
// console.log(d3Event.transform);
|
||||
}
|
||||
|
||||
setZoom(newZoom) {
|
||||
this.svg.call(this.zoom.transform, zoomIdentity
|
||||
.translate(newZoom.panTranslateX, newZoom.panTranslateY)
|
||||
.scale(newZoom.scale));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,12 @@ 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;
|
||||
// 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;
|
||||
|
||||
// Node details table constants
|
||||
export const NODE_DETAILS_TABLE_CW = {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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 };
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
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 };
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { NODE_BASE_SIZE } from '../constants/styles';
|
||||
|
||||
|
||||
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[props.topologyId];
|
||||
|
||||
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
|
||||
);
|
||||
@@ -422,7 +422,7 @@
|
||||
|
||||
.link {
|
||||
fill: none;
|
||||
stroke-width: $edge-link-stroke-width;
|
||||
stroke: $text-secondary-color;
|
||||
stroke-opacity: $edge-opacity;
|
||||
}
|
||||
.shadow {
|
||||
|
||||
Reference in New Issue
Block a user