diff --git a/client/.eslintrc b/client/.eslintrc index c67801e0a0..c6ed99ee78 100644 --- a/client/.eslintrc +++ b/client/.eslintrc @@ -36,6 +36,5 @@ "react/prefer-stateless-function": 0, "react/sort-comp": 0, "react/prop-types": 0, - "no-unused-vars": 0, } } diff --git a/client/app/scripts/charts/__tests__/node-layout-test.js b/client/app/scripts/charts/__tests__/node-layout-test.js index ec06b61066..a9fcb99e09 100644 --- a/client/app/scripts/charts/__tests__/node-layout-test.js +++ b/client/app/scripts/charts/__tests__/node-layout-test.js @@ -167,54 +167,6 @@ describe('NodesLayout', () => { expect(hasUnseen).toBeTruthy(); }); - it('shifts layouts to center', () => { - let xMin; - let xMax; - let yMin; - let yMax; - let xCenter; - let yCenter; - - // make sure initial layout is centered - const original = NodesLayout.doLayout( - nodeSets.initial4.nodes, - nodeSets.initial4.edges - ); - xMin = original.nodes.minBy(n => n.get('x')); - xMax = original.nodes.maxBy(n => n.get('x')); - yMin = original.nodes.minBy(n => n.get('y')); - yMax = original.nodes.maxBy(n => n.get('y')); - xCenter = (xMin.get('x') + xMax.get('x')) / 2; - yCenter = (yMin.get('y') + yMax.get('y')) / 2; - expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2); - expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2); - - // make sure re-running is idempotent - const rerun = NodesLayout.shiftLayoutToCenter(original); - xMin = rerun.nodes.minBy(n => n.get('x')); - xMax = rerun.nodes.maxBy(n => n.get('x')); - yMin = rerun.nodes.minBy(n => n.get('y')); - yMax = rerun.nodes.maxBy(n => n.get('y')); - xCenter = (xMin.get('x') + xMax.get('x')) / 2; - yCenter = (yMin.get('y') + yMax.get('y')) / 2; - expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2); - expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2); - - // shift after window was resized - const shifted = NodesLayout.shiftLayoutToCenter(original, { - width: 128, - height: 256 - }); - xMin = shifted.nodes.minBy(n => n.get('x')); - xMax = shifted.nodes.maxBy(n => n.get('x')); - yMin = shifted.nodes.minBy(n => n.get('y')); - yMax = shifted.nodes.maxBy(n => n.get('y')); - xCenter = (xMin.get('x') + xMax.get('x')) / 2; - yCenter = (yMin.get('y') + yMax.get('y')) / 2; - expect(xCenter).toEqual(128 / 2); - expect(yCenter).toEqual(256 / 2); - }); - it('lays out initial nodeset in a rectangle', () => { const result = NodesLayout.doLayout( nodeSets.initial4.nodes, diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index ef33a5f1a2..c18eb74c27 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -3,6 +3,8 @@ import { connect } from 'react-redux'; import classNames from 'classnames'; import { enterEdge, leaveEdge } from '../actions/app-actions'; +import { isContrastMode } from '../utils/contrast-utils'; +import { NODE_BASE_SIZE } from '../constants/styles'; class Edge extends React.Component { @@ -13,15 +15,19 @@ class Edge extends React.Component { } render() { - const { id, path, highlighted, blurred, focused } = this.props; - const className = classNames('edge', {highlighted, blurred, focused}); + const { id, path, highlighted, blurred, focused, scale } = this.props; + const className = classNames('edge', { highlighted, blurred, focused }); + const thickness = scale * (isContrastMode() ? 0.015 : 0.0075) * NODE_BASE_SIZE; + // Draws the edge so that its thickness reflects the zoom scale. + // Edge shadow is always made 10x thicker than the edge itself. return ( - - + id={id} className={className} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave}> + + ); } diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index 5e5eb1e073..dabed89e6f 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -102,7 +102,7 @@ class Node extends React.Component { const color = getNodeColor(rank, label, pseudo); const truncate = !focused && !hovered; - const labelOffsetY = (showingNetworks && networks) ? 40 : 30; + const labelOffsetY = (showingNetworks && networks) ? 40 : 28; const networkOffset = 0.67; const nodeClassName = classnames('node', { diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index 350e5e02d8..402cf0b1b5 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -7,9 +7,9 @@ import EdgeContainer from './edge-container'; class NodesChartEdges extends React.Component { render() { - const { hasSelectedNode, highlightedEdgeIds, layoutEdges, - searchNodeMatches = makeMap(), searchQuery, isAnimated, - selectedNodeId, selectedNetwork, selectedNetworkNodes } = this.props; + const { hasSelectedNode, highlightedEdgeIds, layoutEdges, searchQuery, + isAnimated, selectedScale, selectedNodeId, selectedNetwork, selectedNetworkNodes, + searchNodeMatches = makeMap() } = this.props; return ( @@ -36,6 +36,7 @@ class NodesChartEdges extends React.Component { source={edge.get('source')} target={edge.get('target')} waypoints={edge.get('points')} + scale={focused ? selectedScale : 1} isAnimated={isAnimated} blurred={blurred} focused={focused} diff --git a/client/app/scripts/charts/nodes-chart-elements.js b/client/app/scripts/charts/nodes-chart-elements.js index df193d9fec..dc16e0b73f 100644 --- a/client/app/scripts/charts/nodes-chart-elements.js +++ b/client/app/scripts/charts/nodes-chart-elements.js @@ -12,10 +12,10 @@ class NodesChartElements extends React.Component { diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index 0477d0ae47..eecfce22ce 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -7,9 +7,9 @@ import NodeContainer from './node-container'; class NodesChartNodes extends React.Component { render() { - const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId, scale, - selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId, topCardNode, - searchNodeMatches = makeMap() } = this.props; + const { adjacentNodes, highlightedNodeIds, layoutNodes, isAnimated, mouseOverNodeId, + selectedScale, searchQuery, selectedMetric, selectedNetwork, selectedNodeId, + topCardNode, searchNodeMatches = makeMap() } = this.props; // highlighter functions const setHighlighted = node => node.set('highlighted', @@ -71,7 +71,7 @@ class NodesChartNodes extends React.Component { metric={metric(node)} rank={node.get('rank')} isAnimated={isAnimated} - magnified={node.get('focused') ? selectedScale / scale : 1} + magnified={node.get('focused') ? selectedScale : 1} dx={node.get('x')} dy={node.get('y')} />)} diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 1d8f48a749..2b42bfa1dc 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -1,329 +1,66 @@ -import debug from 'debug'; import React from 'react'; import { connect } from 'react-redux'; -import { assign, pick, includes } from 'lodash'; -import { Map as makeMap, fromJS } from 'immutable'; -import timely from 'timely'; +import { assign, pick } from 'lodash'; +import { Map as makeMap } from 'immutable'; -import { scaleThreshold } from 'd3-scale'; import { event as d3Event, select } from 'd3-selection'; import { zoom, zoomIdentity } from 'd3-zoom'; import { nodeAdjacenciesSelector, adjacentNodesSelector } from '../selectors/chartSelectors'; import { clickBackground } from '../actions/app-actions'; -import { EDGE_ID_SEPARATOR } from '../constants/naming'; -import { DETAILS_PANEL_WIDTH, NODE_BASE_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'); +import { topologyZoomState } from '../selectors/nodes-chart-zoom'; +import { layoutWithSelectedNode } from '../selectors/nodes-chart-focus'; +import { graphLayout } from '../selectors/nodes-chart-layout'; -const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY', 'minScale', 'maxScale']; -// make sure circular layouts a bit denser with 3-6 nodes -const radiusDensity = scaleThreshold() - .domain([3, 6]) - .range([2.5, 3.5, 3]); - -const emptyLayoutState = { - nodes: makeMap(), - edges: makeMap(), -}; - - -// EDGES -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; -} - - -// ZOOM STATE -function getLayoutDefaultZoom(layoutNodes, width, height) { - const xMin = layoutNodes.minBy(n => n.get('x')).get('x'); - const xMax = layoutNodes.maxBy(n => n.get('x')).get('x'); - const yMin = layoutNodes.minBy(n => n.get('y')).get('y'); - const yMax = layoutNodes.maxBy(n => n.get('y')).get('y'); - - const xFactor = width / (xMax - xMin); - const yFactor = height / (yMax - yMin); - const scale = Math.min(xFactor, yFactor); - - return { - translateX: (width - ((xMax + xMin) * scale)) / 2, - translateY: (height - ((yMax + yMin) * scale)) / 2, - scale, - }; -} - -function defaultZoomState(props, state) { - // adjust layout based on viewport - const width = state.width - props.margins.left - props.margins.right; - const height = state.height - props.margins.top - props.margins.bottom; - - const { translateX, translateY, scale } = getLayoutDefaultZoom(state.nodes, width, height); - - return { - scale, - minScale: scale / 5, - maxScale: Math.min(width, height) / NODE_BASE_SIZE / 3, - panTranslateX: translateX + props.margins.left, - panTranslateY: translateY + props.margins.top, - }; -} - - -// LAYOUT STATE -function updateLayout(width, height, nodes, baseOptions) { - const edges = initEdges(nodes); - const options = Object.assign({}, baseOptions); - - 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 }; -} - -function updatedGraphState(props, state) { - if (props.nodes.size === 0) { - return emptyLayoutState; - } - - const options = { - width: state.width, - height: state.height, - margins: props.margins, - forceRelayout: props.forceRelayout, - topologyId: props.topologyId, - topologyOptions: props.topologyOptions, - }; - - const { layoutNodes, layoutEdges } = - updateLayout(state.width, state.height, props.nodes, options); - - return { - nodes: layoutNodes, - edges: layoutEdges, - }; -} - -function restoredLayout(state) { - const restoredNodes = state.nodes.map(node => node.merge({ - x: node.get('px'), - y: node.get('py') - })); - - const restoredEdges = state.edges.map(edge => ( - edge.has('ppoints') ? edge.set('points', edge.get('ppoints')) : edge - )); - - return { - nodes: restoredNodes, - edges: restoredEdges, - }; -} - - -// SELECTED NODE -function 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 viewportHalfWidth = ((state.width + props.margins.left) - DETAILS_PANEL_WIDTH) / 2; - const viewportHalfHeight = (state.height + props.margins.top) / 2; - const centerX = (-translate[0] + viewportHalfWidth) / zoomScale; - const centerY = (-translate[1] + viewportHalfHeight) / 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 { - selectedScale: 1, - edges: stateEdges, - nodes: stateNodes - }; -} +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 = Object.assign({ - scale: 1, - minScale: 1, - maxScale: 1, + + 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: {}, - }, emptyLayoutState); + }; this.handleMouseClick = this.handleMouseClick.bind(this); this.zoomed = this.zoomed.bind(this); } componentWillMount() { - const state = updatedGraphState(this.props, this.state); - // debugger; - // assign(state, this.restoreZoomState(this.props, Object.assign(this.state, state))); - this.setState(state); - } - - componentWillReceiveProps(nextProps) { - // gather state, setState should be called only once here - const state = assign({}, this.state); - - const topologyChanged = nextProps.topologyId !== this.props.topologyId; - - // wipe node states when showing different topology - if (topologyChanged) { - assign(state, emptyLayoutState); - } - - // 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, updatedGraphState(nextProps, state)); - } - - console.log(`Prepare ${nextProps.nodes.size}`); - if (nextProps.nodes.size > 0) { - console.log(state.zoomCache); - assign(state, this.restoreZoomState(nextProps, state)); - } - - // if (this.props.selectedNodeId !== nextProps.selectedNodeId) { - // // undo any pan/zooming that might have happened - // this.setZoom(state); - // assign(state, restoredLayout(state)); - // } - // - // if (nextProps.selectedNodeId) { - // assign(state, centerSelectedNode(nextProps, state)); - // } - - if (topologyChanged) { - // saving previous zoom state - const prevZoom = pick(this.state, ZOOM_CACHE_FIELDS); - const zoomCache = assign({}, this.state.zoomCache); - zoomCache[this.props.topologyId] = prevZoom; - assign(state, { zoomCache }); - } - - // console.log(topologyChanged); - // console.log(state); - this.setState(state); + this.setState(graphLayout(this.state, this.props)); } componentDidMount() { // distinguish pan/zoom from click this.isZooming = false; - // debugger; - - this.zoom = zoom() - .scaleExtent([this.state.minScale, this.state.maxScale]) - .on('zoom', this.zoomed); + this.zoom = zoom().on('zoom', this.zoomed); this.svg = select('.nodes-chart svg'); this.svg.call(this.zoom); - // this.setZoom(this.state); } componentWillUnmount() { @@ -336,18 +73,39 @@ class NodesChart extends React.Component { .on('touchstart.zoom', null); } - isSmallTopology() { - return this.state.nodes.size < 100; + 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() { - const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state; - console.log(`Render ${nodes.size}`); + // Not passing transform into child components for perf reasons. + const { panTranslateX, panTranslateY, zoomScale } = this.state; + const transform = `translate(${panTranslateX}, ${panTranslateY}) scale(${zoomScale})`; - // 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 isAnimated = !this.isTopologyGraphComplex(); return (
@@ -358,12 +116,11 @@ class NodesChart extends React.Component { + isAnimated={isAnimated} />
); @@ -377,34 +134,38 @@ class NodesChart extends React.Component { } } - restoreZoomState(props, state) { - // re-apply cached canvas zoom/pan to d3 behavior (or set the default values) - const nextZoom = state.zoomCache[props.topologyId] || defaultZoomState(props, state); - if (this.zoom) { - this.zoom = this.zoom.scaleExtent([nextZoom.minScale, nextZoom.maxScale]); - this.setZoom(nextZoom); - } + isTopologyGraphComplex() { + return this.state.layoutNodes.size > GRAPH_COMPLEXITY_NODES_TRESHOLD; + } - return nextZoom; + cacheZoomState(state) { + const zoomState = pick(state, ZOOM_CACHE_FIELDS); + const zoomCache = assign({}, state.zoomCache); + zoomCache[this.props.topologyId] = 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) { - this.setState({ + let state = assign({}, this.state, { panTranslateX: d3Event.transform.x, panTranslateY: d3Event.transform.y, - scale: d3Event.transform.k + 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); } - // console.log(d3Event.transform); - } - - setZoom(newZoom) { - this.svg.call(this.zoom.transform, zoomIdentity - .translate(newZoom.panTranslateX, newZoom.panTranslateY) - .scale(newZoom.scale)); } } diff --git a/client/app/scripts/constants/styles.js b/client/app/scripts/constants/styles.js index c81285f491..e7d6a16cfd 100644 --- a/client/app/scripts/constants/styles.js +++ b/client/app/scripts/constants/styles.js @@ -24,7 +24,12 @@ export const NODE_SHAPE_BORDER_RADIUS = 0.5; export const NODE_SHAPE_SHADOW_RADIUS = 0.45; export const NODE_SHAPE_DOT_RADIUS = 0.125; export const NODE_BLUR_OPACITY = 0.2; -export const NODE_BASE_SIZE = 50; +// NOTE: Modifying this value shouldn't actually change much in the way +// nodes are rendered, as long as its kept >> 1. The idea was to draw all +// the nodes in a unit scale and control their size just through scaling +// transform, but the problem is that dagre only works with integer coordinates, +// so this constant basically serves as a precision factor for dagre. +export const NODE_BASE_SIZE = 100; // Node details table constants export const NODE_DETAILS_TABLE_CW = { diff --git a/client/app/scripts/selectors/nodes-chart-focus.js b/client/app/scripts/selectors/nodes-chart-focus.js new file mode 100644 index 0000000000..15ad07f0f8 --- /dev/null +++ b/client/app/scripts/selectors/nodes-chart-focus.js @@ -0,0 +1,149 @@ +import { includes, without } from 'lodash'; +import { fromJS } from 'immutable'; +import { createSelector } from 'reselect'; +import { scaleThreshold } from 'd3-scale'; + +import { NODE_BASE_SIZE, DETAILS_PANEL_WIDTH } from '../constants/styles'; + + +const circularOffsetAngle = Math.PI / 4; + +// make sure circular layouts a bit denser with 3-6 nodes +const radiusDensity = scaleThreshold() + .domain([3, 6]) + .range([2.5, 3.5, 3]); + + +const layoutNodesSelector = state => state.layoutNodes; +const layoutEdgesSelector = state => state.layoutEdges; +const stateWidthSelector = state => state.width; +const stateHeightSelector = state => state.height; +const stateScaleSelector = state => state.zoomScale; +const stateTranslateXSelector = state => state.panTranslateX; +const stateTranslateYSelector = state => state.panTranslateY; +const propsSelectedNodeIdSelector = (_, props) => props.selectedNodeId; +const propsAdjacentNodesSelector = (_, props) => props.adjacentNodes; +const propsMarginsSelector = (_, props) => props.margins; + +// The narrower dimension of the viewport, used for scaling. +const viewportExpanseSelector = createSelector( + [ + stateWidthSelector, + stateHeightSelector, + ], + (width, height) => Math.min(width, height) +); + +// Coordinates of the viewport center (when the details +// panel is open), used for focusing the selected node. +const viewportCenterSelector = createSelector( + [ + stateWidthSelector, + stateHeightSelector, + stateTranslateXSelector, + stateTranslateYSelector, + stateScaleSelector, + propsMarginsSelector, + ], + (width, height, translateX, translateY, scale, margins) => { + const viewportHalfWidth = ((width + margins.left) - DETAILS_PANEL_WIDTH) / 2; + const viewportHalfHeight = (height + margins.top) / 2; + return { + x: (-translateX + viewportHalfWidth) / scale, + y: (-translateY + viewportHalfHeight) / scale, + }; + } +); + +// List of all the adjacent nodes to the selected +// one, excluding itself (in case of loops). +const selectedNodeNeighborsIdsSelector = createSelector( + [ + propsSelectedNodeIdSelector, + propsAdjacentNodesSelector, + ], + (selectedNodeId, adjacentNodes) => without(adjacentNodes.toArray(), selectedNodeId) +); + +const selectedNodesLayoutSettingsSelector = createSelector( + [ + selectedNodeNeighborsIdsSelector, + viewportExpanseSelector, + stateScaleSelector, + ], + (circularNodesIds, viewportExpanse, scale) => { + const circularNodesCount = circularNodesIds.length; + + // Here we calculate the zoom factor of the nodes that get selected into focus. + // The factor is a somewhat arbitrary function (based on what looks good) of the + // viewport dimensions and the number of nodes in the circular layout. The idea + // is that the node should never be zoomed more than to cover 1/3 of the viewport + // (`maxScale`) and then the factor gets decresed asymptotically to the inverse + // square of the number of circular nodes, with a little constant push to make + // the layout more stable for a small number of nodes. Finally, the zoom factor is + // divided by the zoom factor applied to the whole topology layout to cancel it out. + const maxScale = viewportExpanse / NODE_BASE_SIZE / 3; + const shrinkFactor = Math.sqrt(circularNodesCount + 10); + const selectedScale = maxScale / shrinkFactor / scale; + + // Following a similar logic as above, we set the radius of the circular + // layout based on the viewport dimensions and the number of circular nodes. + const circularRadius = viewportExpanse / radiusDensity(circularNodesCount) / scale; + const circularInnerAngle = (2 * Math.PI) / circularNodesCount; + + return { selectedScale, circularRadius, circularInnerAngle }; + } +); + +export const layoutWithSelectedNode = createSelector( + [ + layoutNodesSelector, + layoutEdgesSelector, + viewportCenterSelector, + propsSelectedNodeIdSelector, + selectedNodeNeighborsIdsSelector, + selectedNodesLayoutSettingsSelector, + ], + (layoutNodes, layoutEdges, viewportCenter, selectedNodeId, neighborsIds, layoutSettings) => { + // Do nothing if the layout doesn't contain the selected node anymore. + if (!layoutNodes.has(selectedNodeId)) { + return {}; + } + + const { selectedScale, circularRadius, circularInnerAngle } = layoutSettings; + + // Fix the selected node in the viewport center. + layoutNodes = layoutNodes.mergeIn([selectedNodeId], viewportCenter); + + // Put the nodes that are adjacent to the selected one in a circular layout around it. + layoutNodes = layoutNodes.map((node, nodeId) => { + const index = neighborsIds.indexOf(nodeId); + if (index > -1) { + const angle = circularOffsetAngle + (index * circularInnerAngle); + return node.merge({ + x: viewportCenter.x + (circularRadius * Math.sin(angle)), + y: viewportCenter.y + (circularRadius * Math.cos(angle)) + }); + } + return node; + }); + + // Update the edges in the circular layout to link the nodes in a straight line. + layoutEdges = layoutEdges.map((edge) => { + if (edge.get('source') === selectedNodeId + || edge.get('target') === selectedNodeId + || includes(neighborsIds, edge.get('source')) + || includes(neighborsIds, edge.get('target'))) { + const source = layoutNodes.get(edge.get('source')); + const target = layoutNodes.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; + }); + + return { layoutNodes, layoutEdges, selectedScale }; + } +); diff --git a/client/app/scripts/selectors/nodes-chart-layout.js b/client/app/scripts/selectors/nodes-chart-layout.js new file mode 100644 index 0000000000..e5c8a8e73b --- /dev/null +++ b/client/app/scripts/selectors/nodes-chart-layout.js @@ -0,0 +1,94 @@ +import debug from 'debug'; +import { createSelector } from 'reselect'; +import { Map as makeMap } from 'immutable'; +import timely from 'timely'; + +import { EDGE_ID_SEPARATOR } from '../constants/naming'; +import { doLayout } from '../charts/nodes-layout'; + +const log = debug('scope:nodes-chart'); + + +const stateWidthSelector = state => state.width; +const stateHeightSelector = state => state.height; +const inputNodesSelector = (_, props) => props.nodes; +const propsMarginsSelector = (_, props) => props.margins; +const forceRelayoutSelector = (_, props) => props.forceRelayout; +const topologyIdSelector = (_, props) => props.topologyId; +const topologyOptionsSelector = (_, props) => props.topologyOptions; + + +function initEdgesFromNodes(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; +} + +const layoutOptionsSelector = createSelector( + [ + stateWidthSelector, + stateHeightSelector, + propsMarginsSelector, + forceRelayoutSelector, + topologyIdSelector, + topologyOptionsSelector, + ], + (width, height, margins, forceRelayout, topologyId, topologyOptions) => ( + { width, height, margins, forceRelayout, topologyId, topologyOptions } + ) +); + +export const graphLayout = createSelector( + [ + inputNodesSelector, + layoutOptionsSelector, + ], + (nodes, options) => { + // If the graph is empty, skip computing the layout. + if (nodes.size === 0) { + return { + layoutNodes: makeMap(), + layoutEdges: makeMap(), + }; + } + + const edges = initEdgesFromNodes(nodes); + const timedLayouter = timely(doLayout); + const graph = timedLayouter(nodes, edges, options); + + // NOTE: We probably shouldn't log anything in a + // computed property, but this is still useful. + log(`graph layout calculation took ${timedLayouter.time}ms`); + + const layoutEdges = graph.edges; + const layoutNodes = graph.nodes.map(node => makeMap({ + x: node.get('x'), + y: node.get('y'), + })); + + return { layoutNodes, layoutEdges }; + } +); diff --git a/client/app/scripts/selectors/nodes-chart-zoom.js b/client/app/scripts/selectors/nodes-chart-zoom.js new file mode 100644 index 0000000000..c9e8abe3a5 --- /dev/null +++ b/client/app/scripts/selectors/nodes-chart-zoom.js @@ -0,0 +1,73 @@ +import { createSelector } from 'reselect'; +import { NODE_BASE_SIZE } from '../constants/styles'; + + +const layoutNodesSelector = state => state.layoutNodes; +const stateWidthSelector = state => state.width; +const stateHeightSelector = state => state.height; +const propsMarginsSelector = (_, props) => props.margins; +const cachedZoomStateSelector = (state, props) => state.zoomCache[props.topologyId]; + +const viewportWidthSelector = createSelector( + [ + stateWidthSelector, + propsMarginsSelector, + ], + (width, margins) => width - margins.left - margins.right +); +const viewportHeightSelector = createSelector( + [ + stateHeightSelector, + propsMarginsSelector, + ], + (height, margins) => height - margins.top +); + +// Compute the default zoom settings for the given graph layout. +const defaultZoomSelector = createSelector( + [ + layoutNodesSelector, + viewportWidthSelector, + viewportHeightSelector, + propsMarginsSelector, + ], + (layoutNodes, width, height, margins) => { + if (layoutNodes.size === 0) { + return {}; + } + + const xMin = layoutNodes.minBy(n => n.get('x')).get('x'); + const xMax = layoutNodes.maxBy(n => n.get('x')).get('x'); + const yMin = layoutNodes.minBy(n => n.get('y')).get('y'); + const yMax = layoutNodes.maxBy(n => n.get('y')).get('y'); + + const xFactor = width / (xMax - xMin); + const yFactor = height / (yMax - yMin); + + // Maximal allowed zoom will always be such that a node covers 1/5 of the viewport. + const maxZoomScale = Math.min(width, height) / NODE_BASE_SIZE / 5; + + // Initial zoom is such that the graph covers 90% of either + // the viewport, respecting the maximal zoom constraint. + const zoomScale = Math.min(xFactor, yFactor, maxZoomScale) * 0.9; + + // Finally, we always allow zooming out exactly 5x compared to the initial zoom. + const minZoomScale = zoomScale / 5; + + // This translation puts the graph in the center of the viewport, respecting the margins. + const panTranslateX = ((width - ((xMax + xMin) * zoomScale)) / 2) + margins.left; + const panTranslateY = ((height - ((yMax + yMin) * zoomScale)) / 2) + margins.top; + + return { zoomScale, minZoomScale, maxZoomScale, panTranslateX, panTranslateY }; + } +); + +// Use the cache to get the last zoom state for the selected topology, +// otherwise use the default zoom options computed from the graph layout. +export const topologyZoomState = createSelector( + [ + cachedZoomStateSelector, + defaultZoomSelector, + ], + (cachedZoomState, defaultZoomState) => cachedZoomState || defaultZoomState +); diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 8456f8974c..c2c64769a3 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -422,7 +422,7 @@ .link { fill: none; - stroke-width: $edge-link-stroke-width; + stroke: $text-secondary-color; stroke-opacity: $edge-opacity; } .shadow {