Split the zooming and layout logic in nodes-chart.js between a bunch of selectors

This commit is contained in:
Filip Barl
2017-02-01 16:27:23 +01:00
parent 632e3756c4
commit 2a6308bf6c
13 changed files with 421 additions and 381 deletions
-1
View File
@@ -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,
+12 -6
View File
@@ -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>
);
}
+1 -1
View File
@@ -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')}
/>)}
+76 -315
View File
@@ -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));
}
}
+6 -1
View File
@@ -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
);
+1 -1
View File
@@ -422,7 +422,7 @@
.link {
fill: none;
stroke-width: $edge-link-stroke-width;
stroke: $text-secondary-color;
stroke-opacity: $edge-opacity;
}
.shadow {