mirror of
https://github.com/weaveworks/scope.git
synced 2026-07-28 09:41:57 +00:00
Moved nodes-chart-layout.
Moved nodes-chart-zoom. Moved zoomCache to global state. Moved nodes-chart-focus. Fixed some bugs and polished the code. Keeping track of topology options in zoomCache. Fixed forceRelayout and circular layout. Unified graph complexity heuristic criterion.
This commit is contained in:
@@ -237,6 +237,12 @@ export function clickForceRelayout() {
|
||||
};
|
||||
}
|
||||
|
||||
export function setViewportDimensions(width, height) {
|
||||
return (dispatch) => {
|
||||
dispatch({ type: ActionTypes.SET_VIEWPORT_DIMENSIONS, width, height });
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleGridMode(enabledArgument) {
|
||||
return (dispatch, getState) => {
|
||||
const enabled = (enabledArgument === undefined) ?
|
||||
@@ -247,9 +253,6 @@ export function toggleGridMode(enabledArgument) {
|
||||
enabled
|
||||
});
|
||||
updateRoute(getState);
|
||||
if (!enabled) {
|
||||
dispatch(clickForceRelayout());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -346,6 +349,13 @@ export function clickTopology(topologyId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function cacheZoomState(zoomState) {
|
||||
return {
|
||||
type: ActionTypes.CACHE_ZOOM_STATE,
|
||||
zoomState
|
||||
};
|
||||
}
|
||||
|
||||
export function openWebsocket() {
|
||||
return {
|
||||
type: ActionTypes.OPEN_WEBSOCKET
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import NodesChartEdges from './nodes-chart-edges';
|
||||
import NodesChartNodes from './nodes-chart-nodes';
|
||||
import { graphExceedsComplexityThreshSelector } from '../selectors/topology';
|
||||
import {
|
||||
selectedScaleSelector,
|
||||
layoutNodesSelector,
|
||||
layoutEdgesSelector
|
||||
} from '../selectors/nodes-chart-layout';
|
||||
|
||||
export default class NodesChartElements extends React.PureComponent {
|
||||
|
||||
class NodesChartElements extends React.Component {
|
||||
render() {
|
||||
const { transform, layoutEdges, layoutNodes, selectedScale, isAnimated } = this.props;
|
||||
const { layoutNodes, layoutEdges, selectedScale, transform, isAnimated } = this.props;
|
||||
|
||||
return (
|
||||
<g className="nodes-chart-elements" transform={transform}>
|
||||
<NodesChartEdges
|
||||
@@ -20,3 +29,17 @@ export default class NodesChartElements extends React.PureComponent {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
layoutNodes: layoutNodesSelector(state),
|
||||
layoutEdges: layoutEdgesSelector(state),
|
||||
selectedScale: selectedScaleSelector(state),
|
||||
isAnimated: !graphExceedsComplexityThreshSelector(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps
|
||||
)(NodesChartElements);
|
||||
|
||||
@@ -1,66 +1,54 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { assign, pick } from 'lodash';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
import { debounce, pick } from 'lodash';
|
||||
import { fromJS } 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';
|
||||
import { clickBackground, cacheZoomState } from '../actions/app-actions';
|
||||
import { activeLayoutZoomSelector } from '../selectors/nodes-chart-zoom';
|
||||
import { activeTopologyZoomCacheKeyPath } from '../utils/topology-utils';
|
||||
import { ZOOM_CACHE_DEBOUNCE_INTERVAL } from '../constants/timer';
|
||||
|
||||
|
||||
const GRAPH_COMPLEXITY_NODES_TRESHOLD = 100;
|
||||
const ZOOM_CACHE_FIELDS = [
|
||||
'panTranslateX', 'panTranslateY',
|
||||
'zoomScale', 'minZoomScale', 'maxZoomScale'
|
||||
'zoomScale',
|
||||
'minZoomScale',
|
||||
'maxZoomScale',
|
||||
'panTranslateX',
|
||||
'panTranslateY',
|
||||
];
|
||||
|
||||
|
||||
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.debouncedCacheZoom = debounce(this.cacheZoom.bind(this), ZOOM_CACHE_DEBOUNCE_INTERVAL);
|
||||
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.zoomRestored = false;
|
||||
this.zoom = zoom().on('zoom', this.zoomed);
|
||||
|
||||
this.svg = select('.nodes-chart svg');
|
||||
this.svg.call(this.zoom);
|
||||
|
||||
this.restoreCachedZoom(this.props);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -71,41 +59,31 @@ class NodesChart extends React.Component {
|
||||
.on('onmousewheel', null)
|
||||
.on('dblclick.zoom', null)
|
||||
.on('touchstart.zoom', null);
|
||||
|
||||
this.debouncedCacheZoom.cancel();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// Don't modify the original state, as we only want to call setState once at the end.
|
||||
const state = assign({}, this.state);
|
||||
const layoutChanged = nextProps.layoutId !== this.props.layoutId;
|
||||
|
||||
// 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));
|
||||
// If the layout has changed (either active topology or its options) or
|
||||
// relayouting has been requested, stop pending zoom caching event and
|
||||
// ask for the new zoom settings to be restored again from the cache.
|
||||
if (layoutChanged || nextProps.forceRelayout) {
|
||||
this.debouncedCacheZoom.cancel();
|
||||
this.zoomRestored = false;
|
||||
}
|
||||
|
||||
this.applyZoomState(state);
|
||||
this.setState(state);
|
||||
if (!this.zoomRestored) {
|
||||
this.restoreCachedZoom(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="nodes-chart">
|
||||
@@ -115,17 +93,33 @@ class NodesChart extends React.Component {
|
||||
<g transform="translate(24,24) scale(0.25)">
|
||||
<Logo />
|
||||
</g>
|
||||
<NodesChartElements
|
||||
layoutNodes={this.state.layoutNodes}
|
||||
layoutEdges={this.state.layoutEdges}
|
||||
selectedScale={this.state.selectedScale}
|
||||
transform={transform}
|
||||
isAnimated={isAnimated} />
|
||||
<NodesChartElements transform={transform} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
cacheZoom() {
|
||||
const zoomState = pick(this.state, ZOOM_CACHE_FIELDS);
|
||||
this.props.cacheZoomState(fromJS(zoomState));
|
||||
}
|
||||
|
||||
restoreCachedZoom(props) {
|
||||
if (!props.layoutZoom.isEmpty()) {
|
||||
const zoomState = props.layoutZoom.toJS();
|
||||
|
||||
// Restore the zooming settings
|
||||
this.zoom = this.zoom.scaleExtent([zoomState.minZoomScale, zoomState.maxZoomScale]);
|
||||
this.svg.call(this.zoom.transform, zoomIdentity
|
||||
.translate(zoomState.panTranslateX, zoomState.panTranslateY)
|
||||
.scale(zoomState.zoomScale));
|
||||
|
||||
// Update the state variables
|
||||
this.setState(zoomState);
|
||||
this.zoomRestored = true;
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseClick() {
|
||||
if (!this.isZooming || this.props.selectedNodeId) {
|
||||
this.props.clickBackground();
|
||||
@@ -134,37 +128,16 @@ class NodesChart extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
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, {
|
||||
this.setState({
|
||||
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);
|
||||
this.debouncedCacheZoom();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,16 +145,15 @@ class NodesChart extends React.Component {
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
nodes: shownNodesSelector(state),
|
||||
forceRelayout: state.get('forceRelayout'),
|
||||
layoutZoom: activeLayoutZoomSelector(state),
|
||||
layoutId: JSON.stringify(activeTopologyZoomCacheKeyPath(state)),
|
||||
selectedNodeId: state.get('selectedNodeId'),
|
||||
topologyId: state.get('currentTopologyId'),
|
||||
topologyOptions: getActiveTopologyOptions(state),
|
||||
forceRelayout: state.get('forceRelayout'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ clickBackground }
|
||||
{ clickBackground, cacheZoomState }
|
||||
)(NodesChart);
|
||||
|
||||
@@ -7,6 +7,7 @@ import NodeDetailsTable from '../components/node-details/node-details-table';
|
||||
import { clickNode, sortOrderChanged } from '../actions/app-actions';
|
||||
import { shownNodesSelector } from '../selectors/node-filters';
|
||||
|
||||
import { CANVAS_MARGINS } from '../constants/styles';
|
||||
import { searchNodeMatchesSelector } from '../selectors/search';
|
||||
import { getNodeColor } from '../utils/color-utils';
|
||||
|
||||
@@ -96,13 +97,13 @@ class NodesGrid extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { margins, nodes, height, gridSortedBy, gridSortedDesc,
|
||||
const { nodes, height, gridSortedBy, gridSortedDesc,
|
||||
searchNodeMatches, searchQuery } = this.props;
|
||||
const cmpStyle = {
|
||||
height,
|
||||
marginTop: margins.top,
|
||||
paddingLeft: margins.left,
|
||||
paddingRight: margins.right,
|
||||
marginTop: CANVAS_MARGINS.top,
|
||||
paddingLeft: CANVAS_MARGINS.left,
|
||||
paddingRight: CANVAS_MARGINS.right,
|
||||
};
|
||||
const tbodyHeight = height - 24 - 18;
|
||||
const className = 'scroll-body';
|
||||
@@ -151,7 +152,8 @@ function mapStateToProps(state) {
|
||||
currentTopologyId: state.get('currentTopologyId'),
|
||||
searchNodeMatches: searchNodeMatchesSelector(state),
|
||||
searchQuery: state.get('searchQuery'),
|
||||
selectedNodeId: state.get('selectedNodeId')
|
||||
selectedNodeId: state.get('selectedNodeId'),
|
||||
height: state.getIn(['viewport', 'height']),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import NodesChart from '../charts/nodes-chart';
|
||||
import NodesGrid from '../charts/nodes-grid';
|
||||
@@ -7,12 +8,13 @@ import NodesError from '../charts/nodes-error';
|
||||
import DelayedShow from '../utils/delayed-show';
|
||||
import { Loading, getNodeType } from './loading';
|
||||
import { isTopologyEmpty } from '../utils/topology-utils';
|
||||
import { CANVAS_MARGINS } from '../constants/styles';
|
||||
import { setViewportDimensions } from '../actions/app-actions';
|
||||
import { VIEWPORT_RESIZE_DEBOUNCE_INTERVAL } from '../constants/timer';
|
||||
|
||||
|
||||
const navbarHeight = 194;
|
||||
const marginTop = 0;
|
||||
|
||||
|
||||
const EmptyTopologyError = show => (
|
||||
<NodesError faIconClass="fa-circle-thin" hidden={!show}>
|
||||
<div className="heading">Nothing to show. This can have any of these reasons:</div>
|
||||
@@ -30,12 +32,10 @@ const EmptyTopologyError = show => (
|
||||
class Nodes extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
|
||||
this.state = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight - navbarHeight - marginTop,
|
||||
};
|
||||
this.setDimensions = this.setDimensions.bind(this);
|
||||
this.handleResize = debounce(this.setDimensions, VIEWPORT_RESIZE_DEBOUNCE_INTERVAL);
|
||||
this.setDimensions();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -60,22 +60,15 @@ class Nodes extends React.Component {
|
||||
</DelayedShow>
|
||||
{EmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)}
|
||||
|
||||
{gridMode ?
|
||||
<NodesGrid {...this.state} nodeSize="24" margins={CANVAS_MARGINS} /> :
|
||||
<NodesChart {...this.state} margins={CANVAS_MARGINS} />}
|
||||
{gridMode ? <NodesGrid /> : <NodesChart />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
this.setDimensions();
|
||||
}
|
||||
|
||||
setDimensions() {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight - navbarHeight - marginTop;
|
||||
|
||||
this.setState({height, width});
|
||||
this.props.setViewportDimensions(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,5 +86,6 @@ function mapStateToProps(state) {
|
||||
|
||||
|
||||
export default connect(
|
||||
mapStateToProps
|
||||
mapStateToProps,
|
||||
{ setViewportDimensions }
|
||||
)(Nodes);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { zipObject } from 'lodash';
|
||||
const ACTION_TYPES = [
|
||||
'ADD_QUERY_FILTER',
|
||||
'BLUR_SEARCH',
|
||||
'CACHE_ZOOM_STATE',
|
||||
'CHANGE_TOPOLOGY_OPTION',
|
||||
'CLEAR_CONTROL_ERROR',
|
||||
'CLICK_BACKGROUND',
|
||||
@@ -49,6 +50,7 @@ const ACTION_TYPES = [
|
||||
'ROUTE_TOPOLOGY',
|
||||
'SELECT_METRIC',
|
||||
'SHOW_HELP',
|
||||
'SET_VIEWPORT_DIMENSIONS',
|
||||
'SET_EXPORTING_GRAPH',
|
||||
'SELECT_NETWORK',
|
||||
'TOGGLE_TROUBLESHOOTING_MENU',
|
||||
|
||||
@@ -13,7 +13,7 @@ export const CANVAS_MARGINS = {
|
||||
top: 160,
|
||||
left: 40,
|
||||
right: 40,
|
||||
bottom: 100,
|
||||
bottom: 0,
|
||||
};
|
||||
|
||||
// Node shapes
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/* Intervals in ms */
|
||||
export const API_INTERVAL = 30000;
|
||||
export const TOPOLOGY_INTERVAL = 5000;
|
||||
|
||||
export const TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL = 10;
|
||||
export const VIEWPORT_RESIZE_DEBOUNCE_INTERVAL = 200;
|
||||
export const ZOOM_CACHE_DEBOUNCE_INTERVAL = 500;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap,
|
||||
|
||||
import ActionTypes from '../constants/action-types';
|
||||
import { EDGE_ID_SEPARATOR } from '../constants/naming';
|
||||
import { graphExceedsComplexityThreshSelector } from '../selectors/topology';
|
||||
import { applyPinnedSearches } from '../utils/search-utils';
|
||||
import { getNetworkNodes } from '../utils/network-view-utils';
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
filterHiddenTopologies,
|
||||
addTopologyFullname,
|
||||
getDefaultTopology,
|
||||
graphExceedsComplexityThresh
|
||||
activeTopologyZoomCacheKeyPath,
|
||||
} from '../utils/topology-utils';
|
||||
|
||||
const log = debug('scope:app-store');
|
||||
@@ -37,6 +38,7 @@ export const initialState = makeMap({
|
||||
currentTopology: null,
|
||||
currentTopologyId: null,
|
||||
errorUrl: null,
|
||||
exportingGraph: false,
|
||||
forceRelayout: false,
|
||||
gridMode: false,
|
||||
gridSortedBy: null,
|
||||
@@ -45,6 +47,7 @@ export const initialState = makeMap({
|
||||
highlightedEdgeIds: makeSet(),
|
||||
highlightedNodeIds: makeSet(),
|
||||
hostname: '...',
|
||||
initialNodesLoaded: false,
|
||||
mouseOverEdgeId: null,
|
||||
mouseOverNodeId: null,
|
||||
networkNodes: makeMap(),
|
||||
@@ -76,9 +79,9 @@ export const initialState = makeMap({
|
||||
updatePausedAt: null, // Date
|
||||
version: '...',
|
||||
versionUpdate: null,
|
||||
viewport: makeMap(),
|
||||
websocketClosed: false,
|
||||
exportingGraph: false,
|
||||
initialNodesLoaded: false
|
||||
zoomCache: makeMap(),
|
||||
});
|
||||
|
||||
// adds ID field to topology (based on last part of URL path) and save urls in
|
||||
@@ -184,6 +187,13 @@ export function rootReducer(state = initialState, action) {
|
||||
return state;
|
||||
}
|
||||
|
||||
case ActionTypes.SET_VIEWPORT_DIMENSIONS: {
|
||||
return state.mergeIn(['viewport'], {
|
||||
width: action.width,
|
||||
height: action.height,
|
||||
});
|
||||
}
|
||||
|
||||
case ActionTypes.SET_EXPORTING_GRAPH: {
|
||||
return state.set('exportingGraph', action.exporting);
|
||||
}
|
||||
@@ -199,6 +209,10 @@ export function rootReducer(state = initialState, action) {
|
||||
return state.setIn(['gridMode'], action.enabled);
|
||||
}
|
||||
|
||||
case ActionTypes.CACHE_ZOOM_STATE: {
|
||||
return state.setIn(activeTopologyZoomCacheKeyPath(state), action.zoomState);
|
||||
}
|
||||
|
||||
case ActionTypes.CLEAR_CONTROL_ERROR: {
|
||||
return state.removeIn(['controlStatus', action.nodeId, 'error']);
|
||||
}
|
||||
@@ -223,6 +237,10 @@ export function rootReducer(state = initialState, action) {
|
||||
}
|
||||
|
||||
case ActionTypes.CLICK_FORCE_RELAYOUT: {
|
||||
if (action.forceRelayout) {
|
||||
// Reset the zoom cache when forcing relayout.
|
||||
state = state.deleteIn(activeTopologyZoomCacheKeyPath(state));
|
||||
}
|
||||
return state.set('forceRelayout', action.forceRelayout);
|
||||
}
|
||||
|
||||
@@ -524,11 +542,10 @@ export function rootReducer(state = initialState, action) {
|
||||
}
|
||||
|
||||
case ActionTypes.SET_RECEIVED_NODES_DELTA: {
|
||||
// Turn on the table view if the graph is too complex, but skip this block if
|
||||
// the user has already loaded topologies once.
|
||||
// Turn on the table view if the graph is too complex, but skip
|
||||
// this block if the user has already loaded topologies once.
|
||||
if (!state.get('initialNodesLoaded') && !state.get('nodesLoaded')) {
|
||||
const topoStats = state.get('currentTopology').get('stats');
|
||||
state = graphExceedsComplexityThresh(topoStats)
|
||||
state = graphExceedsComplexityThreshSelector(state)
|
||||
? state.set('gridMode', true)
|
||||
: state;
|
||||
state = state.set('initialNodesLoaded', true);
|
||||
|
||||
48
client/app/scripts/selectors/canvas-viewport.js
Normal file
48
client/app/scripts/selectors/canvas-viewport.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { CANVAS_MARGINS, DETAILS_PANEL_WIDTH, DETAILS_PANEL_MARGINS } from '../constants/styles';
|
||||
|
||||
|
||||
export const viewportWidthSelector = createSelector(
|
||||
[
|
||||
state => state.getIn(['viewport', 'width']),
|
||||
],
|
||||
width => width - CANVAS_MARGINS.left - CANVAS_MARGINS.right
|
||||
);
|
||||
|
||||
export const viewportHeightSelector = createSelector(
|
||||
[
|
||||
state => state.getIn(['viewport', 'height']),
|
||||
],
|
||||
height => height - CANVAS_MARGINS.top - CANVAS_MARGINS.bottom
|
||||
);
|
||||
|
||||
const viewportFocusWidthSelector = createSelector(
|
||||
[
|
||||
viewportWidthSelector,
|
||||
],
|
||||
width => width - DETAILS_PANEL_WIDTH - DETAILS_PANEL_MARGINS.right
|
||||
);
|
||||
|
||||
export const viewportFocusHorizontalCenterSelector = createSelector(
|
||||
[
|
||||
viewportFocusWidthSelector,
|
||||
],
|
||||
width => (width / 2) + CANVAS_MARGINS.left
|
||||
);
|
||||
|
||||
export const viewportFocusVerticalCenterSelector = createSelector(
|
||||
[
|
||||
viewportHeightSelector,
|
||||
],
|
||||
height => (height / 2) + CANVAS_MARGINS.top
|
||||
);
|
||||
|
||||
// The narrower dimension of the viewport, used for the circular layout.
|
||||
export const viewportCircularExpanseSelector = createSelector(
|
||||
[
|
||||
viewportFocusWidthSelector,
|
||||
viewportHeightSelector,
|
||||
],
|
||||
(width, height) => Math.min(width, height)
|
||||
);
|
||||
@@ -1,157 +0,0 @@
|
||||
import { includes, without } from 'lodash';
|
||||
import { createSelector } from 'reselect';
|
||||
import { scaleThreshold } from 'd3-scale';
|
||||
import { fromJS, Set as makeSet } from 'immutable';
|
||||
|
||||
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]);
|
||||
|
||||
// TODO: Make all the selectors below pure (so that they only depend on the global state).
|
||||
|
||||
// The narrower dimension of the viewport, used for scaling.
|
||||
const viewportExpanseSelector = createSelector(
|
||||
[
|
||||
state => state.width,
|
||||
state => state.height,
|
||||
],
|
||||
(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(
|
||||
[
|
||||
state => state.width,
|
||||
state => state.height,
|
||||
state => state.panTranslateX,
|
||||
state => state.panTranslateY,
|
||||
state => state.zoomScale,
|
||||
(_, props) => props.margins,
|
||||
],
|
||||
(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).
|
||||
// TODO: Use createMapSelector here instead.
|
||||
const selectedNodeNeighborsIdsSelector = createSelector(
|
||||
[
|
||||
(_, props) => props.selectedNodeId,
|
||||
(_, props) => props.nodes,
|
||||
],
|
||||
(selectedNodeId, nodes) => {
|
||||
let adjacentNodes = makeSet();
|
||||
if (!selectedNodeId) {
|
||||
return adjacentNodes;
|
||||
}
|
||||
|
||||
if (nodes && nodes.has(selectedNodeId)) {
|
||||
adjacentNodes = makeSet(nodes.getIn([selectedNodeId, 'adjacency']));
|
||||
// fill up set with reverse edges
|
||||
nodes.forEach((node, id) => {
|
||||
if (node.get('adjacency') && node.get('adjacency').includes(selectedNodeId)) {
|
||||
adjacentNodes = adjacentNodes.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return without(adjacentNodes.toArray(), selectedNodeId);
|
||||
}
|
||||
);
|
||||
|
||||
const selectedNodesLayoutSettingsSelector = createSelector(
|
||||
[
|
||||
state => state.zoomScale,
|
||||
selectedNodeNeighborsIdsSelector,
|
||||
viewportExpanseSelector,
|
||||
],
|
||||
(scale, circularNodesIds, viewportExpanse) => {
|
||||
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(
|
||||
[
|
||||
state => state.layoutNodes,
|
||||
state => state.layoutEdges,
|
||||
(_, props) => props.selectedNodeId,
|
||||
viewportCenterSelector,
|
||||
selectedNodeNeighborsIdsSelector,
|
||||
selectedNodesLayoutSettingsSelector,
|
||||
],
|
||||
(layoutNodes, layoutEdges, selectedNodeId, viewportCenter, 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 };
|
||||
}
|
||||
);
|
||||
76
client/app/scripts/selectors/nodes-chart-graph.js
Normal file
76
client/app/scripts/selectors/nodes-chart-graph.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import debug from 'debug';
|
||||
import { createSelector, createStructuredSelector } from 'reselect';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
import timely from 'timely';
|
||||
|
||||
import { getActiveTopologyOptions } from '../utils/topology-utils';
|
||||
import { initEdgesFromNodes, collapseMultiEdges } from '../utils/layouter-utils';
|
||||
import { viewportWidthSelector, viewportHeightSelector } from './canvas-viewport';
|
||||
import { shownNodesSelector } from './node-filters';
|
||||
import { doLayout } from '../charts/nodes-layout';
|
||||
|
||||
const log = debug('scope:nodes-chart');
|
||||
|
||||
|
||||
const layoutOptionsSelector = createStructuredSelector({
|
||||
forceRelayout: state => state.get('forceRelayout'),
|
||||
topologyId: state => state.get('currentTopologyId'),
|
||||
topologyOptions: getActiveTopologyOptions,
|
||||
height: viewportHeightSelector,
|
||||
width: viewportWidthSelector,
|
||||
});
|
||||
|
||||
const graphLayoutSelector = createSelector(
|
||||
[
|
||||
// TODO: Instead of sending the nodes with all the information (metrics, metadata, etc...)
|
||||
// to the layout engine, it would suffice to forward it just the nodes adjacencies map, which
|
||||
// we could get with another selector like:
|
||||
//
|
||||
// const nodesAdjacenciesSelector = createMapSelector(
|
||||
// [ (_, props) => props.nodes ],
|
||||
// node => node.get('adjacency') || makeList()
|
||||
// );
|
||||
//
|
||||
// That would enable us to use smarter caching, so that the layout doesn't get recalculated
|
||||
// if adjacencies don't change but e.g. metrics gets updated. We also don't need to init
|
||||
// edges here as the adjacencies data is enough to reconstruct them in the layout engine (this
|
||||
// might enable us to simplify the caching system there since we really only need to cache
|
||||
// the adjacencies map in that case and not nodes and edges).
|
||||
shownNodesSelector,
|
||||
layoutOptionsSelector,
|
||||
],
|
||||
(nodes, options) => {
|
||||
// If the graph is empty, skip computing the layout.
|
||||
if (nodes.size === 0) {
|
||||
return {
|
||||
nodes: makeMap(),
|
||||
edges: 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`);
|
||||
|
||||
return graph;
|
||||
}
|
||||
);
|
||||
|
||||
export const graphNodesSelector = createSelector(
|
||||
[
|
||||
graphLayoutSelector,
|
||||
],
|
||||
// NOTE: This might be a good place to add (some of) nodes/edges decorators.
|
||||
graph => graph.nodes
|
||||
);
|
||||
|
||||
export const graphEdgesSelector = createSelector(
|
||||
[
|
||||
graphLayoutSelector,
|
||||
],
|
||||
graph => collapseMultiEdges(graph.edges)
|
||||
);
|
||||
@@ -1,65 +1,170 @@
|
||||
import debug from 'debug';
|
||||
import { createSelector, createStructuredSelector } from 'reselect';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
import timely from 'timely';
|
||||
import { includes, without, pick } from 'lodash';
|
||||
import { createSelector } from 'reselect';
|
||||
import { scaleThreshold } from 'd3-scale';
|
||||
import { fromJS, Set as makeSet, List as makeList } from 'immutable';
|
||||
|
||||
import { initEdgesFromNodes, collapseMultiEdges } from '../utils/layouter-utils';
|
||||
import { doLayout } from '../charts/nodes-layout';
|
||||
|
||||
const log = debug('scope:nodes-chart');
|
||||
import { NODE_BASE_SIZE } from '../constants/styles';
|
||||
import { graphNodesSelector, graphEdgesSelector } from './nodes-chart-graph';
|
||||
import { activeLayoutZoomSelector } from './nodes-chart-zoom';
|
||||
import {
|
||||
viewportCircularExpanseSelector,
|
||||
viewportFocusHorizontalCenterSelector,
|
||||
viewportFocusVerticalCenterSelector,
|
||||
} from './canvas-viewport';
|
||||
|
||||
|
||||
// TODO: Make all the selectors below pure (so that they only depend on the global state).
|
||||
const circularOffsetAngle = Math.PI / 4;
|
||||
|
||||
const layoutOptionsSelector = createStructuredSelector({
|
||||
width: state => state.width,
|
||||
height: state => state.height,
|
||||
margins: (_, props) => props.margins,
|
||||
forceRelayout: (_, props) => props.forceRelayout,
|
||||
topologyId: (_, props) => props.topologyId,
|
||||
topologyOptions: (_, props) => props.topologyOptions,
|
||||
});
|
||||
// make sure circular layouts a bit denser with 3-6 nodes
|
||||
const radiusDensity = scaleThreshold()
|
||||
.domain([3, 6])
|
||||
.range([2.5, 3, 2.5]);
|
||||
|
||||
export const graphLayout = createSelector(
|
||||
|
||||
const translationToViewportCenterSelector = createSelector(
|
||||
[
|
||||
// TODO: Instead of sending the nodes with all the information (metrics, metadata, etc...)
|
||||
// to the layout engine, it would suffice to forward it just the nodes adjacencies map, which
|
||||
// we could get with another selector like:
|
||||
//
|
||||
// const nodesAdjacenciesSelector = createMapSelector(
|
||||
// [ (_, props) => props.nodes ],
|
||||
// node => node.get('adjacency') || makeList()
|
||||
// );
|
||||
//
|
||||
// That would enable us to use smarter caching, so that the layout doesn't get recalculated
|
||||
// if adjacencies don't change but e.g. metrics gets updated. We also don't need to init
|
||||
// edges here as the adjacencies data is enough to reconstruct them in the layout engine (this
|
||||
// might enable us to simplify the caching system there since we really only need to cache
|
||||
// the adjacencies map in that case and not nodes and edges).
|
||||
(_, props) => props.nodes,
|
||||
layoutOptionsSelector,
|
||||
viewportFocusHorizontalCenterSelector,
|
||||
viewportFocusVerticalCenterSelector,
|
||||
activeLayoutZoomSelector,
|
||||
],
|
||||
(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`);
|
||||
|
||||
(centerX, centerY, zoomState) => {
|
||||
const { zoomScale, panTranslateX, panTranslateY } = zoomState.toJS();
|
||||
return {
|
||||
// NOTE: This might be a good place to add (some of) nodes/edges decorators.
|
||||
layoutNodes: graph.nodes,
|
||||
layoutEdges: collapseMultiEdges(graph.edges),
|
||||
x: (-panTranslateX + centerX) / zoomScale,
|
||||
y: (-panTranslateY + centerY) / zoomScale,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const selectedNodeIdSelector = createSelector(
|
||||
[
|
||||
graphNodesSelector,
|
||||
state => state.get('selectedNodeId'),
|
||||
],
|
||||
(graphNodes, selectedNodeId) => (graphNodes.has(selectedNodeId) ? selectedNodeId : null)
|
||||
);
|
||||
|
||||
// TODO: Combine this with the corresponding nodes decorator.
|
||||
const focusedNodesIdsSelector = createSelector(
|
||||
[
|
||||
selectedNodeIdSelector,
|
||||
state => state.get('nodes'),
|
||||
],
|
||||
(selectedNodeId, nodes) => {
|
||||
if (!selectedNodeId || nodes.isEmpty()) {
|
||||
return makeList();
|
||||
}
|
||||
|
||||
// The selected node always goes in focus.
|
||||
let focusedNodes = makeSet([selectedNodeId]);
|
||||
|
||||
// Add all the nodes the selected node is connected to...
|
||||
focusedNodes = focusedNodes.merge(nodes.getIn([selectedNodeId, 'adjacency']) || makeList());
|
||||
|
||||
// ... and also all the nodes that connect to the selected one.
|
||||
nodes.forEach((node, nodeId) => {
|
||||
const adjacency = node.get('adjacency') || makeList();
|
||||
if (adjacency.includes(selectedNodeId)) {
|
||||
focusedNodes = focusedNodes.add(nodeId);
|
||||
}
|
||||
});
|
||||
|
||||
return focusedNodes.toArray();
|
||||
}
|
||||
);
|
||||
|
||||
const circularLayoutScalarsSelector = createSelector(
|
||||
[
|
||||
state => activeLayoutZoomSelector(state).get('zoomScale'),
|
||||
state => focusedNodesIdsSelector(state).length - 1,
|
||||
viewportCircularExpanseSelector,
|
||||
],
|
||||
(scale, circularNodesCount, viewportExpanse) => {
|
||||
// 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/2 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 / 2;
|
||||
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 selectedScaleSelector = createSelector(
|
||||
[
|
||||
circularLayoutScalarsSelector,
|
||||
],
|
||||
layout => layout.selectedScale
|
||||
);
|
||||
|
||||
// Nodes after the selection circular layout has been applied to dagre engine output.
|
||||
export const layoutNodesSelector = createSelector(
|
||||
[
|
||||
selectedNodeIdSelector,
|
||||
focusedNodesIdsSelector,
|
||||
graphNodesSelector,
|
||||
translationToViewportCenterSelector,
|
||||
circularLayoutScalarsSelector,
|
||||
],
|
||||
(selectedNodeId, focusedNodesIds, graphNodes, translationToCenter, layoutScalars) => {
|
||||
const { circularRadius, circularInnerAngle } = layoutScalars;
|
||||
|
||||
// Do nothing if the layout doesn't contain the selected node anymore.
|
||||
if (!selectedNodeId) {
|
||||
return graphNodes;
|
||||
}
|
||||
|
||||
// Fix the selected node in the viewport center.
|
||||
let layoutNodes = graphNodes.mergeIn([selectedNodeId], translationToCenter);
|
||||
|
||||
// Put the nodes that are adjacent to the selected one in a circular layout around it.
|
||||
const circularNodesIds = without(focusedNodesIds, selectedNodeId);
|
||||
layoutNodes = layoutNodes.map((node, nodeId) => {
|
||||
const index = circularNodesIds.indexOf(nodeId);
|
||||
if (index > -1) {
|
||||
const angle = circularOffsetAngle + (index * circularInnerAngle);
|
||||
return node.merge({
|
||||
x: translationToCenter.x + (circularRadius * Math.sin(angle)),
|
||||
y: translationToCenter.y + (circularRadius * Math.cos(angle))
|
||||
});
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
return layoutNodes;
|
||||
}
|
||||
);
|
||||
|
||||
// Edges after the selection circular layout has been applied to dagre engine output.
|
||||
export const layoutEdgesSelector = createSelector(
|
||||
[
|
||||
graphEdgesSelector,
|
||||
layoutNodesSelector,
|
||||
focusedNodesIdsSelector,
|
||||
],
|
||||
(graphEdges, layoutNodes, focusedNodesIds) => (
|
||||
// Update the edges in the circular layout to link the nodes in a straight line.
|
||||
graphEdges.map((edge) => {
|
||||
const source = edge.get('source');
|
||||
const target = edge.get('target');
|
||||
if (includes(focusedNodesIds, source) || includes(focusedNodesIds, target)) {
|
||||
return edge.set('points', fromJS([
|
||||
pick(layoutNodes.get(source).toJS(), ['x', 'y']),
|
||||
pick(layoutNodes.get(target).toJS(), ['x', 'y']),
|
||||
]));
|
||||
}
|
||||
return edge;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,42 +1,28 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { NODE_BASE_SIZE } from '../constants/styles';
|
||||
import { zoomCacheKey } from '../utils/topology-utils';
|
||||
import { CANVAS_MARGINS, NODE_BASE_SIZE } from '../constants/styles';
|
||||
import { activeTopologyZoomCacheKeyPath } from '../utils/topology-utils';
|
||||
import { viewportWidthSelector, viewportHeightSelector } from './canvas-viewport';
|
||||
import { graphNodesSelector } from './nodes-chart-graph';
|
||||
|
||||
// TODO: Make all the selectors below pure (so that they only depend on the global state).
|
||||
|
||||
const viewportWidthSelector = createSelector(
|
||||
[
|
||||
state => state.width,
|
||||
(_, props) => props.margins,
|
||||
],
|
||||
(width, margins) => width - margins.left - margins.right
|
||||
);
|
||||
const viewportHeightSelector = createSelector(
|
||||
[
|
||||
state => state.height,
|
||||
(_, props) => props.margins,
|
||||
],
|
||||
(height, margins) => height - margins.top
|
||||
);
|
||||
|
||||
// Compute the default zoom settings for the given graph layout.
|
||||
const defaultZoomSelector = createSelector(
|
||||
[
|
||||
state => state.layoutNodes,
|
||||
(_, props) => props.margins,
|
||||
graphNodesSelector,
|
||||
viewportWidthSelector,
|
||||
viewportHeightSelector,
|
||||
],
|
||||
(layoutNodes, margins, width, height) => {
|
||||
if (layoutNodes.size === 0) {
|
||||
(graphNodes, width, height) => {
|
||||
if (graphNodes.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 xMin = graphNodes.minBy(n => n.get('x')).get('x');
|
||||
const xMax = graphNodes.maxBy(n => n.get('x')).get('x');
|
||||
const yMin = graphNodes.minBy(n => n.get('y')).get('y');
|
||||
const yMax = graphNodes.maxBy(n => n.get('y')).get('y');
|
||||
|
||||
const xFactor = width / (xMax - xMin);
|
||||
const yFactor = height / (yMax - yMin);
|
||||
@@ -52,19 +38,27 @@ const defaultZoomSelector = createSelector(
|
||||
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;
|
||||
const panTranslateX = ((width - ((xMax + xMin) * zoomScale)) / 2) + CANVAS_MARGINS.left;
|
||||
const panTranslateY = ((height - ((yMax + yMin) * zoomScale)) / 2) + CANVAS_MARGINS.top;
|
||||
|
||||
return { zoomScale, minZoomScale, maxZoomScale, panTranslateX, panTranslateY };
|
||||
}
|
||||
);
|
||||
|
||||
const activeLayoutCachedZoomSelector = createSelector(
|
||||
[
|
||||
state => state.get('zoomCache'),
|
||||
activeTopologyZoomCacheKeyPath,
|
||||
],
|
||||
(zoomCache, keyPath) => zoomCache.getIn(keyPath.slice(1))
|
||||
);
|
||||
|
||||
// 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(
|
||||
export const activeLayoutZoomSelector = createSelector(
|
||||
[
|
||||
(state, props) => state.zoomCache[zoomCacheKey(props)],
|
||||
activeLayoutCachedZoomSelector,
|
||||
defaultZoomSelector,
|
||||
],
|
||||
(cachedZoomState, defaultZoomState) => cachedZoomState || defaultZoomState
|
||||
(cachedZoomState, defaultZoomState) => makeMap(cachedZoomState || defaultZoomState)
|
||||
);
|
||||
|
||||
13
client/app/scripts/selectors/topology.js
Normal file
13
client/app/scripts/selectors/topology.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
// TODO: Consider moving more stuff from 'topology-utils' here.
|
||||
|
||||
// Checks if graph complexity is high. Used to trigger
|
||||
// table view on page load and decide on animations.
|
||||
export const graphExceedsComplexityThreshSelector = createSelector(
|
||||
[
|
||||
state => state.getIn(['currentTopology', 'stats', 'node_count']) || 0,
|
||||
state => state.getIn(['currentTopology', 'stats', 'edge_count']) || 0,
|
||||
],
|
||||
(nodeCount, edgeCount) => (nodeCount + (2 * edgeCount)) > 1000
|
||||
);
|
||||
@@ -174,11 +174,8 @@ export function getCurrentTopologyUrl(state) {
|
||||
return state.getIn(['currentTopology', 'url']);
|
||||
}
|
||||
|
||||
export function graphExceedsComplexityThresh(stats) {
|
||||
// Check to see if complexity is high. Used to trigger table view on page load.
|
||||
return (stats.get('node_count') + (2 * stats.get('edge_count'))) > 1000;
|
||||
}
|
||||
|
||||
export function zoomCacheKey(props) {
|
||||
return `${props.topologyId}-${JSON.stringify(props.topologyOptions)}`;
|
||||
export function activeTopologyZoomCacheKeyPath(state) {
|
||||
const topologyId = state.get('currentTopologyId');
|
||||
const topologyOptions = JSON.stringify(getActiveTopologyOptions(state));
|
||||
return ['zoomCache', topologyId, topologyOptions];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user