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, is as isDeepEqual } from 'immutable';
import timely from 'timely';
import { clickBackground } from '../actions/app-actions';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { DETAILS_PANEL_WIDTH } from '../constants/styles';
import Logo from '../components/logo';
import { doLayout } from './nodes-layout';
import NodesChartElements from './nodes-chart-elements';
import { getActiveTopologyOptions, getAdjacentNodes,
isSameTopology } from '../utils/topology-utils';
const log = debug('scope:nodes-chart');
const MARGINS = {
top: 130,
left: 40,
right: 40,
bottom: 0
};
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]);
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: 0,
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);
// _.assign(state, this.updateGraphState(nextProps, state));
if (nextProps.forceRelayout || !isSameTopology(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' : '';
return (
);
}
handleMouseClick() {
if (!this.isZooming) {
this.props.clickBackground();
} else {
this.isZooming = false;
}
}
initNodes(topology, stateNodes) {
let nextStateNodes = stateNodes;
// remove nodes that have disappeared
stateNodes.forEach((node, id) => {
if (!topology.has(id)) {
nextStateNodes = nextStateNodes.delete(id);
}
});
// copy relevant fields to state nodes
topology.forEach((node, id) => {
nextStateNodes = nextStateNodes.mergeIn([id], makeMap({
id,
label: node.get('label'),
pseudo: node.get('pseudo'),
subLabel: node.get('label_minor'),
nodeCount: node.get('node_count'),
metrics: node.get('metrics'),
rank: node.get('rank'),
shape: node.get('shape'),
stack: node.get('stack'),
networks: node.get('networks'),
}));
});
return nextStateNodes;
}
initEdges(topology, stateNodes) {
let edges = makeMap();
topology.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 (stateNodes.has(source) && stateNodes.has(target)) {
edges = edges.set(edgeId, makeMap({
id: edgeId,
value: 1,
source,
target
}));
}
}
});
}
});
return edges;
}
centerSelectedNode(props, state) {
let stateNodes = state.nodes;
let stateEdges = state.edges;
const selectedLayoutNode = stateNodes.get(props.selectedNodeId);
if (!selectedLayoutNode) {
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 + MARGINS.left
- DETAILS_PANEL_WIDTH) / 2) / zoomScale;
const centerY = (-translate[1] + (state.height + 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) => {
const index = adjacentLayoutNodeIds.indexOf(node.get('id'));
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') === selectedLayoutNode.get('id')
|| edge.get('target') === selectedLayoutNode.get('id')
|| _.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 = this.getNodeScale(adjacentNodes, 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) {
const n = props.nodes.size;
if (n === 0) {
return {
nodes: makeMap(),
edges: makeMap()
};
}
const stateNodes = this.initNodes(props.nodes, state.nodes);
const stateEdges = this.initEdges(props.nodes, stateNodes);
const nodeScale = this.getNodeScale(props.nodes, state.width, state.height);
const nextState = { nodeScale };
const options = {
width: state.width,
height: state.height,
scale: nodeScale,
margins: MARGINS,
forceRelayout: props.forceRelayout,
topologyId: this.props.topologyId,
topologyOptions: this.props.topologyOptions
};
const timedLayouter = timely(doLayout);
const graph = timedLayouter(stateNodes, stateEdges, options);
log(`graph layout took ${timedLayouter.time}ms`);
// extract coords and save for restore
const graphNodes = graph.nodes.map(node => makeMap({
x: node.get('x'),
px: node.get('x'),
y: node.get('y'),
py: node.get('y')
}));
const layoutNodes = stateNodes.mergeDeep(graphNodes);
const layoutEdges = graph.edges
.map(edge => edge.set('ppoints', edge.get('points')));
// adjust layout based on viewport
const xFactor = (state.width - MARGINS.left - MARGINS.right) / graph.width;
const yFactor = state.height / graph.height;
const zoomFactor = Math.min(xFactor, yFactor);
let zoomScale = this.state.scale;
if (!state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
zoomScale = zoomFactor;
// saving in d3's behavior cache
this.zoom.scale(zoomFactor);
}
nextState.scale = zoomScale;
if (!isDeepEqual(layoutNodes, state.nodes)) {
nextState.nodes = layoutNodes;
}
if (!isDeepEqual(layoutEdges, state.edges)) {
nextState.edges = layoutEdges;
}
return nextState;
}
getNodeScale(nodes, width, height) {
const expanse = Math.min(height, width);
const nodeSize = expanse / 3; // single node should fill a third of the screen
const maxNodeSize = expanse / 10;
const normalizedNodeSize = Math.min(nodeSize / Math.sqrt(nodes.size), maxNodeSize);
return this.state.nodeScale.copy().range([0, normalizedNodeSize]);
}
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 {
adjacentNodes: getAdjacentNodes(state),
forceRelayout: state.get('forceRelayout'),
nodes: state.get('nodes').filter(node => !node.get('filtered')),
selectedNodeId: state.get('selectedNodeId'),
topologyId: state.get('currentTopologyId'),
topologyOptions: getActiveTopologyOptions(state)
};
}
export default connect(
mapStateToProps,
{ clickBackground }
)(NodesChart);