Files
weave-scope/client/app/scripts/charts/nodes-chart.js
David Kaltschmidt 6b445466ef Move JS to ES2015
Refactored mixins into utils

ES2015 module exports

ES2015-style imports

WIP Fixing tests

Fixes tests after es2015 code migrations.

We we're require()ing an ES2015 module[1]. Have to make sure you account
for the .default in this case.

[1] We had to use ES5 `require` in Jest:
(https://github.com/babel/babel-jest/issues/16)
2015-12-01 14:35:22 +01:00

475 lines
14 KiB
JavaScript

import _ from 'lodash';
import d3 from 'd3';
import debug from 'debug';
import React from 'react';
import { Map as makeMap } from 'immutable';
import timely from 'timely';
import { clickCloseDetails } from '../actions/app-actions';
import AppStore from '../stores/app-store';
import Edge from './edge';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { doLayout } from './nodes-layout';
import Node from './node';
import NodesError from './nodes-error';
const log = debug('scope:nodes-chart');
const MARGINS = {
top: 130,
left: 40,
right: 40,
bottom: 0
};
// 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]);
export default 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 = {
nodes: makeMap(),
edges: makeMap(),
panTranslate: [0, 0],
scale: 1,
nodeScale: d3.scale.linear(),
selectedNodeScale: d3.scale.linear(),
hasZoomed: false,
maxNodesExceeded: false
};
}
componentWillMount() {
const state = this.updateGraphState(this.props, this.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);
}
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) {
_.assign(state, {
nodes: makeMap(),
edges: makeMap()
});
}
// FIXME add PureRenderMixin, Immutables, and move the following functions to render()
if (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);
}
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);
}
renderGraphNodes(nodes, nodeScale) {
const hasSelectedNode = this.props.selectedNodeId && this.props.nodes.has(this.props.selectedNodeId);
const adjacency = hasSelectedNode ? AppStore.getAdjacentNodes(this.props.selectedNodeId) : null;
const onNodeClick = this.props.onNodeClick;
const zoomScale = this.state.scale;
const selectedNodeScale = this.state.selectedNodeScale;
// highlighter functions
const setHighlighted = node => {
const highlighted = _.includes(this.props.highlightedNodeIds, node.get('id'))
|| this.props.selectedNodeId === node.get('id');
return node.set('highlighted', highlighted);
};
const setFocused = node => {
const focused = hasSelectedNode
&& (this.props.selectedNodeId === node.get('id') || adjacency.includes(node.get('id')));
return node.set('focused', focused);
};
const setBlurred = node => {
return node.set('blurred', hasSelectedNode && !node.get('focused'));
};
// make sure blurred nodes are in the background
const sortNodes = node => {
if (node.get('blurred')) {
return 0;
}
if (node.get('highlighted')) {
return 2;
}
return 1;
};
return nodes
.toIndexedSeq()
.map(setHighlighted)
.map(setFocused)
.map(setBlurred)
.sortBy(sortNodes)
.map(node => {
return (<Node
blurred={node.get('blurred')}
focused={node.get('focused')}
highlighted={node.get('highlighted')}
onClick={onNodeClick}
key={node.get('id')}
id={node.get('id')}
label={node.get('label')}
pseudo={node.get('pseudo')}
subLabel={node.get('subLabel')}
rank={node.get('rank')}
selectedNodeScale={selectedNodeScale}
nodeScale={nodeScale}
zoomScale={zoomScale}
dx={node.get('x')}
dy={node.get('y')}
/>
);
});
}
renderGraphEdges(edges) {
const selectedNodeId = this.props.selectedNodeId;
const hasSelectedNode = selectedNodeId && this.props.nodes.has(selectedNodeId);
const setHighlighted = edge => {
return edge.set('highlighted', _.includes(this.props.highlightedEdgeIds, edge.get('id')));
};
const setBlurred = edge => {
return (edge.set('blurred', hasSelectedNode
&& edge.get('source') !== selectedNodeId
&& edge.get('target') !== selectedNodeId));
};
return edges
.toIndexedSeq()
.map(setHighlighted)
.map(setBlurred)
.map(edge => {
return (
<Edge key={edge.get('id')} id={edge.get('id')} points={edge.get('points')}
blurred={edge.get('blurred')} highlighted={edge.get('highlighted')} />
);
});
}
renderMaxNodesError(show) {
const errorHint = 'We\u0027re working on it, but for now, try a different view?';
return (
<NodesError faIconClass="fa-ban" hidden={!show}>
<div className="centered">Too many nodes to show in the browser.<br />{errorHint}</div>
</NodesError>
);
}
renderEmptyTopologyError(show) {
return (
<NodesError faIconClass="fa-circle-thin" hidden={!show}>
<div className="heading">Nothing to show. This can have any of these reasons:</div>
<ul>
<li>We haven't received any reports from probes recently. Are the probes properly configured?</li>
<li>There are nodes, but they're currently hidden. Check the view options in the bottom-left if they allow for showing hidden nodes.</li>
<li>Containers view only: you're not running Docker, or you don't have any containers.</li>
</ul>
</NodesError>
);
}
render() {
const nodeElements = this.renderGraphNodes(this.state.nodes, this.state.nodeScale);
const edgeElements = this.renderGraphEdges(this.state.edges, this.state.nodeScale);
const scale = this.state.scale;
const translate = this.state.panTranslate;
const transform = 'translate(' + translate + ') scale(' + scale + ')';
const svgClassNames = this.state.maxNodesExceeded || nodeElements.size === 0 ? 'hide' : '';
const errorEmpty = this.renderEmptyTopologyError(AppStore.isTopologyEmpty());
const errorMaxNodesExceeded = this.renderMaxNodesError(this.state.maxNodesExceeded);
return (
<div className="nodes-chart">
{errorEmpty}
{errorMaxNodesExceeded}
<svg width="100%" height="100%" className={svgClassNames} onClick={this.handleMouseClick}>
<g className="canvas" transform={transform}>
<g className="edges">
{edgeElements}
</g>
<g className="nodes">
{nodeElements}
</g>
</g>
</svg>
</div>
);
}
initNodes(topology) {
return topology.map((node, id) => {
// copy relevant fields to state nodes
return makeMap({
id: id,
label: node.get('label_major'),
pseudo: node.get('pseudo'),
subLabel: node.get('label_minor'),
rank: node.get('rank'),
x: 0,
y: 0
});
});
}
initEdges(topology, stateNodes) {
let edges = makeMap();
topology.forEach(function(node, nodeId) {
const adjacency = node.get('adjacency');
if (adjacency) {
adjacency.forEach(function(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)) {
log('Missing edge node', edge[0], edge[1]);
}
edges = edges.set(edgeId, makeMap({
id: edgeId,
value: 1,
source: source,
target: target
}));
}
});
}
});
return edges;
}
centerSelectedNode(props, state) {
let stateNodes = state.nodes;
let stateEdges = state.edges;
const selectedLayoutNode = stateNodes.get(props.selectedNodeId);
if (!selectedLayoutNode) {
return {};
}
const adjacency = AppStore.getAdjacentNodes(props.selectedNodeId);
const adjacentLayoutNodeIds = [];
adjacency.forEach(function(adjacentId) {
// filter loopback
if (adjacentId !== props.selectedNodeId) {
adjacentLayoutNodeIds.push(adjacentId);
}
});
// move origin node to center of viewport
const zoomScale = state.scale;
const detailsWidth = 420;
const translate = state.panTranslate;
const centerX = (-translate[0] + (props.width + MARGINS.left - detailsWidth) / 2) / zoomScale;
const centerY = (-translate[1] + (props.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(props.width, props.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', [
{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(props);
return {
selectedNodeScale,
edges: stateEdges,
nodes: stateNodes
};
}
handleMouseClick() {
if (!this.isZooming) {
clickCloseDetails();
} else {
this.isZooming = false;
}
}
restoreLayout(state) {
// undo any pan/zooming that might have happened
this.zoom.scale(state.scale);
this.zoom.translate(state.panTranslate);
const nodes = state.nodes.map(node => {
return 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()
};
}
let stateNodes = this.initNodes(props.nodes, state.nodes);
let stateEdges = this.initEdges(props.nodes, stateNodes);
const nodeScale = this.getNodeScale(props);
const options = {
width: props.width,
height: props.height,
scale: nodeScale,
margins: MARGINS,
topologyId: this.props.topologyId
};
const timedLayouter = timely(doLayout);
const graph = timedLayouter(stateNodes, stateEdges, options);
log('graph layout took ' + timedLayouter.time + 'ms');
// layout was aborted
if (!graph) {
return {maxNodesExceeded: true};
}
stateNodes = graph.nodes;
stateEdges = graph.edges;
// save coordinates for restore
stateNodes = stateNodes.map(node => {
return node.merge({
px: node.get('x'),
py: node.get('y')
});
});
stateEdges = stateEdges.map(edge => {
return edge.set('ppoints', edge.get('points'));
});
// adjust layout based on viewport
const xFactor = (props.width - MARGINS.left - MARGINS.right) / graph.width;
const yFactor = props.height / graph.height;
const zoomFactor = Math.min(xFactor, yFactor);
let zoomScale = this.state.scale;
if (this.zoom && !this.state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
zoomScale = zoomFactor;
// saving in d3's behavior cache
this.zoom.scale(zoomFactor);
}
return {
nodes: stateNodes,
edges: stateEdges,
scale: zoomScale,
nodeScale: nodeScale,
maxNodesExceeded: false
};
}
getNodeScale(props) {
const expanse = Math.min(props.height, props.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(props.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,
panTranslate: d3.event.translate.slice(),
scale: d3.event.scale
});
}
}
}