import React from 'react'; import { connect } from 'react-redux'; import { assign, pick } from 'lodash'; import { Map as makeMap } from 'immutable'; import { event as d3Event, select } from 'd3-selection'; import { zoom, zoomIdentity } from 'd3-zoom'; import { shownNodesSelector } from '../selectors/node-filters'; import { clickBackground } from '../actions/app-actions'; import Logo from '../components/logo'; import NodesChartElements from './nodes-chart-elements'; import { getActiveTopologyOptions, zoomCacheKey } from '../utils/topology-utils'; import { topologyZoomState } from '../selectors/nodes-chart-zoom'; import { layoutWithSelectedNode } from '../selectors/nodes-chart-focus'; import { graphLayout } from '../selectors/nodes-chart-layout'; 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 = { 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: {}, }; this.handleMouseClick = this.handleMouseClick.bind(this); this.zoomed = this.zoomed.bind(this); } componentWillMount() { this.setState(graphLayout(this.state, this.props)); } componentDidMount() { // distinguish pan/zoom from click this.isZooming = false; this.zoom = zoom().on('zoom', this.zoomed); this.svg = select('.nodes-chart svg'); this.svg.call(this.zoom); } componentWillUnmount() { // undoing .call(zoom) this.svg .on('mousedown.zoom', null) .on('onwheel', null) .on('onmousewheel', null) .on('dblclick.zoom', null) .on('touchstart.zoom', null); } 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() { // Not passing transform into child components for perf reasons. const { panTranslateX, panTranslateY, zoomScale } = this.state; const transform = `translate(${panTranslateX}, ${panTranslateY}) scale(${zoomScale})`; const svgClassNames = this.props.isEmpty ? 'hide' : ''; const isAnimated = !this.isTopologyGraphComplex(); return (
); } handleMouseClick() { if (!this.isZooming || this.props.selectedNodeId) { this.props.clickBackground(); } else { this.isZooming = false; } } isTopologyGraphComplex() { return this.state.layoutNodes.size > GRAPH_COMPLEXITY_NODES_TRESHOLD; } cacheZoomState(state) { const zoomState = pick(state, ZOOM_CACHE_FIELDS); const zoomCache = assign({}, state.zoomCache); zoomCache[zoomCacheKey(this.props)] = 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) { let state = assign({}, this.state, { panTranslateX: d3Event.transform.x, panTranslateY: d3Event.transform.y, 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); } } } function mapStateToProps(state) { return { nodes: shownNodesSelector(state), forceRelayout: state.get('forceRelayout'), selectedNodeId: state.get('selectedNodeId'), topologyId: state.get('currentTopologyId'), topologyOptions: getActiveTopologyOptions(state), }; } export default connect( mapStateToProps, { clickBackground } )(NodesChart);