mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-05 19:21:46 +00:00
Needs a bit of de-dup / customization oops, bad typo More fleshing out the structure for network-view onHover netview-legend: highlight relevant nodes. And the bool rolls on. Handle nodes w/ no networks better Corrects deselect-node when used w/ new network-view behaviour Net view details "node" can be open when with no nodes selected. Hitting "esc" from: - card 0: network-a - card 1: node-a was not deselecting node-a Deselect selectedNetwork correctly onEsc Ooops, trailing ws breaks linting. Adds NodeNetworksOverlay stub Expands on NodeNetworksOverlay stub and adds arcs and colors Expand and collapse networks legend Open arc for network circle, shift for stack Show our base hue range in the debug bar too.. Was trying to smooth out our hue selector but turned out to be tricky.. Uniquify random data generator!
416 lines
12 KiB
JavaScript
416 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, 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 (
|
|
<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={this.props.layoutPrecision} />
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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);
|