Files
weave-scope/client/app/scripts/selectors/nodes-chart-focus.js
2017-02-03 16:59:25 +01:00

150 lines
5.5 KiB
JavaScript

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 };
}
);