Files
weave-scope/client/app/scripts/charts/nodes-chart.js
2016-11-08 13:24:49 -08:00

410 lines
12 KiB
JavaScript

import _ from 'lodash';
import d3 from 'd3';
import debug from 'debug';
import React from 'react';
import { connect } from 'react-redux';
import { Map as makeMap, fromJS } from 'immutable';
import timely from 'timely';
import { nodeAdjacenciesSelector, adjacentNodesSelector } from '../selectors/chartSelectors';
import { clickBackground } from '../actions/app-actions';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { MIN_NODE_SIZE, DETAILS_PANEL_WIDTH, MAX_NODE_SIZE } from '../constants/styles';
import Logo from '../components/logo';
import { doLayout } from './nodes-layout';
import NodesChartElements from './nodes-chart-elements';
import { getActiveTopologyOptions } from '../utils/topology-utils';
const log = debug('scope:nodes-chart');
const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY'];
// make sure circular layouts a bit denser with 3-6 nodes
const radiusDensity = d3.scale.threshold()
.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;
}
function initEdges(nodes) {
let edges = makeMap();
nodes.forEach((node, nodeId) => {
const adjacency = node.get('adjacency');
if (adjacency) {
adjacency.forEach(adjacent => {
const edge = [nodeId, adjacent];
const edgeId = edge.join(EDGE_ID_SEPARATOR);
if (!edges.has(edgeId)) {
const source = edge[0];
const target = edge[1];
if (nodes.has(source) && nodes.has(target)) {
edges = edges.set(edgeId, makeMap({
id: edgeId,
value: 1,
source,
target
}));
}
}
});
}
});
return edges;
}
function getNodeScale(nodesCount, width, height) {
const expanse = Math.min(height, width);
const nodeSize = expanse / 3; // single node should fill a third of the screen
const maxNodeSize = Math.min(MAX_NODE_SIZE, expanse / 10);
const normalizedNodeSize = Math.max(MIN_NODE_SIZE,
Math.min(nodeSize / Math.sqrt(nodesCount), maxNodeSize));
return d3.scale.linear().range([0, normalizedNodeSize]);
}
function updateLayout(width, height, nodes, baseOptions) {
const nodeScale = getNodeScale(nodes.size, width, height);
const edges = initEdges(nodes);
const options = Object.assign({}, baseOptions, {
scale: nodeScale,
});
const timedLayouter = timely(doLayout);
const graph = timedLayouter(nodes, edges, options);
log(`graph layout took ${timedLayouter.time}ms`);
const layoutNodes = graph.nodes.map(node => makeMap({
x: node.get('x'),
y: node.get('y'),
// extract coords and save for restore
px: node.get('x'),
py: node.get('y')
}));
const layoutEdges = graph.edges
.map(edge => edge.set('ppoints', edge.get('points')));
return { layoutNodes, layoutEdges, layoutWidth: graph.width, layoutHeight: graph.height };
}
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: d3.scale.linear(),
panTranslateX: 0,
panTranslateY: 0,
scale: 1,
selectedNodeScale: d3.scale.linear(),
hasZoomed: false,
height: props.height || 0,
width: props.width || 0,
zoomCache: {},
};
}
componentWillMount() {
const state = this.updateGraphState(this.props, this.state);
this.setState(state);
}
componentWillReceiveProps(nextProps) {
// gather state, setState should be called only once here
const state = _.assign({}, this.state);
// wipe node states when showing different topology
if (nextProps.topologyId !== this.props.topologyId) {
// re-apply cached canvas zoom/pan to d3 behavior (or set defaul values)
const defaultZoom = { scale: 1, panTranslateX: 0, panTranslateY: 0, hasZoomed: false };
const nextZoom = this.state.zoomCache[nextProps.topologyId] || defaultZoom;
if (nextZoom) {
this.zoom.scale(nextZoom.scale);
this.zoom.translate([nextZoom.panTranslateX, nextZoom.panTranslateY]);
}
// saving previous zoom state
const prevZoom = _.pick(this.state, ZOOM_CACHE_FIELDS);
const zoomCache = _.assign({}, this.state.zoomCache);
zoomCache[this.props.topologyId] = prevZoom;
// clear canvas and apply zoom state
_.assign(state, nextZoom, { zoomCache }, {
nodes: makeMap(),
edges: makeMap()
});
}
// reset layout dimensions only when forced
state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height);
state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width);
if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) {
_.assign(state, this.updateGraphState(nextProps, state));
}
if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
_.assign(state, this.restoreLayout(state));
}
if (nextProps.selectedNodeId) {
_.assign(state, this.centerSelectedNode(nextProps, state));
}
this.setState(state);
}
componentDidMount() {
// distinguish pan/zoom from click
this.isZooming = false;
this.zoom = d3.behavior.zoom()
.scaleExtent([0.1, 2])
.on('zoom', this.zoomed);
d3.select('.nodes-chart svg')
.call(this.zoom);
}
componentWillUnmount() {
// undoing .call(zoom)
d3.select('.nodes-chart svg')
.on('mousedown.zoom', null)
.on('onwheel', null)
.on('onmousewheel', null)
.on('dblclick.zoom', null)
.on('touchstart.zoom', null);
}
render() {
const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state;
// not passing translates into child components for perf reasons, use getTranslate instead
const translate = [panTranslateX, panTranslateY];
const transform = `translate(${translate}) scale(${scale})`;
const svgClassNames = this.props.isEmpty ? 'hide' : '';
const layoutPrecision = getLayoutPrecision(nodes.size);
return (
<div className="nodes-chart">
<svg width="100%" height="100%" id="nodes-chart-canvas"
className={svgClassNames} onClick={this.handleMouseClick}>
<g transform="translate(24,24) scale(0.25)">
<Logo />
</g>
<NodesChartElements
layoutNodes={nodes}
layoutEdges={edges}
nodeScale={this.state.nodeScale}
scale={scale}
transform={transform}
selectedNodeScale={this.state.selectedNodeScale}
layoutPrecision={layoutPrecision} />
</svg>
</div>
);
}
handleMouseClick() {
if (!this.isZooming || this.props.selectedNodeId) {
this.props.clickBackground();
} else {
this.isZooming = false;
}
}
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 centerX = (-translate[0] + (state.width + props.margins.left
- DETAILS_PANEL_WIDTH) / 2) / zoomScale;
const centerY = (-translate[1] + (state.height + props.margins.top) / 2) / zoomScale;
stateNodes = stateNodes.mergeIn([props.selectedNodeId], {
x: centerX,
y: centerY
});
// circle layout for adjacent nodes
const adjacentCount = adjacentLayoutNodeIds.length;
const density = radiusDensity(adjacentCount);
const radius = Math.min(state.width, state.height) / density / zoomScale;
const offsetAngle = Math.PI / 4;
stateNodes = stateNodes.map((node, nodeId) => {
const index = adjacentLayoutNodeIds.indexOf(nodeId);
if (index > -1) {
const angle = offsetAngle + Math.PI * 2 * index / adjacentCount;
return node.merge({
x: centerX + radius * Math.sin(angle),
y: centerY + radius * Math.cos(angle)
});
}
return node;
});
// fix all edges for circular nodes
stateEdges = stateEdges.map(edge => {
if (edge.get('source') === props.selectedNodeId
|| edge.get('target') === props.selectedNodeId
|| _.includes(adjacentLayoutNodeIds, edge.get('source'))
|| _.includes(adjacentLayoutNodeIds, edge.get('target'))) {
const source = stateNodes.get(edge.get('source'));
const target = stateNodes.get(edge.get('target'));
return edge.set('points', fromJS([
{x: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')}
]));
}
return edge;
});
// auto-scale node size for selected nodes
const selectedNodeScale = getNodeScale(adjacentNodes.size, state.width, state.height);
return {
selectedNodeScale,
edges: stateEdges,
nodes: stateNodes
};
}
restoreLayout(state) {
// undo any pan/zooming that might have happened
this.zoom.scale(state.scale);
this.zoom.translate([state.panTranslateX, state.panTranslateY]);
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()
};
}
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.zoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
zoomScale = zoomFactor;
// saving in d3's behavior cache
this.zoom.scale(zoomFactor);
}
return {
scale: zoomScale,
nodes: layoutNodes,
edges: layoutEdges,
nodeScale: getNodeScale(props.nodes.size, state.width, state.height),
};
}
zoomed() {
// debug('zoomed', d3.event.scale, d3.event.translate);
this.isZooming = true;
// dont pan while node is selected
if (!this.props.selectedNodeId) {
this.setState({
hasZoomed: true,
panTranslateX: d3.event.translate[0],
panTranslateY: d3.event.translate[1],
scale: d3.event.scale
});
}
}
}
function mapStateToProps(state) {
return {
nodes: nodeAdjacenciesSelector(state),
adjacentNodes: adjacentNodesSelector(state),
forceRelayout: state.get('forceRelayout'),
selectedNodeId: state.get('selectedNodeId'),
topologyId: state.get('currentTopologyId'),
topologyOptions: getActiveTopologyOptions(state)
};
}
export default connect(
mapStateToProps,
{ clickBackground }
)(NodesChart);