Initial version of the resource view (#2296)

* Added resource view selector button

* Showing resource boxes in the resource view

* Crude CPU resource view prototype

* Improved the viewMode state logic

* Extracted zooming into a separate wrapper component

* Split the layout selectors between graph-view and resource-view

* Proper zooming logic for the resource view

* Moved all node networks utils to selectors

* Improved the zoom caching logic

* Further refactoring of selectors

* Added sticky labels to the resource boxes

* Added panning translation limits in the resource view

* Renamed GridModeSelector -> ViewModeSelector

* Polished the topology resource view selection logic

* Search bar hidden in the resource view

* Added per-layer topology names to the resource view

* Made metric selectors work for the resource view

* Adjusted the viewport selectors

* Renamed viewport selector to canvas (+ maximal zoom fix)

* Showing more useful metric info in the resource box labels

* Fetching only necessary nodes for the resource view

* Refactored the resource view layer component

* Addressed first batch UI comments (from the Scope meeting)

* Switch to deep zooming transform in the resource view to avoid SVG precision errors

* Renamed and moved resource view components

* Polished all the resource view components

* Changing the available metrics selection

* Improved and polished the state transition logic for the resource view

* Separated zoom limits from the zoom active state

* Renaming and bunch of comments

* Addressed all the UI comments (@davkal + @fons)

* Made graph view selectors independent from resource view selectors
This commit is contained in:
Filip Barl
2017-03-24 14:51:53 +01:00
committed by GitHub
parent 8814e856e0
commit 69fd397217
50 changed files with 1592 additions and 568 deletions

View File

@@ -1,48 +0,0 @@
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)
);

View File

@@ -0,0 +1,63 @@
import { createSelector } from 'reselect';
import {
CANVAS_MARGINS,
DETAILS_PANEL_WIDTH,
DETAILS_PANEL_MARGINS
} from '../constants/styles';
export const canvasMarginsSelector = createSelector(
[
state => state.get('topologyViewMode'),
],
viewMode => CANVAS_MARGINS[viewMode] || { top: 0, left: 0, right: 0, bottom: 0 }
);
export const canvasWidthSelector = createSelector(
[
state => state.getIn(['viewport', 'width']),
canvasMarginsSelector,
],
(width, margins) => width - margins.left - margins.right
);
export const canvasHeightSelector = createSelector(
[
state => state.getIn(['viewport', 'height']),
canvasMarginsSelector,
],
(height, margins) => height - margins.top - margins.bottom
);
const canvasWithDetailsWidthSelector = createSelector(
[
canvasWidthSelector,
],
width => width - DETAILS_PANEL_WIDTH - DETAILS_PANEL_MARGINS.right
);
export const canvasDetailsHorizontalCenterSelector = createSelector(
[
canvasWithDetailsWidthSelector,
canvasMarginsSelector,
],
(width, margins) => (width / 2) + margins.left
);
export const canvasDetailsVerticalCenterSelector = createSelector(
[
canvasHeightSelector,
canvasMarginsSelector,
],
(height, margins) => (height / 2) + margins.top
);
// The narrower dimension of the viewport, used for the circular layout.
export const canvasCircularExpanseSelector = createSelector(
[
canvasWithDetailsWidthSelector,
canvasHeightSelector,
],
(width, height) => Math.min(width, height)
);

View File

@@ -2,12 +2,12 @@ import debug from 'debug';
import { createSelector, createStructuredSelector } from 'reselect';
import { Map as makeMap } from 'immutable';
import { initEdgesFromNodes } from '../utils/layouter-utils';
import { viewportWidthSelector, viewportHeightSelector } from './canvas-viewport';
import { activeTopologyOptionsSelector } from './topology';
import { shownNodesSelector } from './node-filters';
import { doLayout } from '../charts/nodes-layout';
import timer from '../utils/timer-utils';
import { initEdgesFromNodes } from '../../utils/layouter-utils';
import { canvasWidthSelector, canvasHeightSelector } from '../canvas';
import { activeTopologyOptionsSelector } from '../topology';
import { shownNodesSelector } from '../node-filters';
import { doLayout } from '../../charts/nodes-layout';
import timer from '../../utils/timer-utils';
const log = debug('scope:nodes-chart');
@@ -16,8 +16,8 @@ const layoutOptionsSelector = createStructuredSelector({
forceRelayout: state => state.get('forceRelayout'),
topologyId: state => state.get('currentTopologyId'),
topologyOptions: activeTopologyOptionsSelector,
height: viewportHeightSelector,
width: viewportWidthSelector,
height: canvasHeightSelector,
width: canvasWidthSelector,
});
const graphLayoutSelector = createSelector(

View File

@@ -3,14 +3,14 @@ import { createSelector } from 'reselect';
import { scaleThreshold } from 'd3-scale';
import { fromJS, Set as makeSet, List as makeList } from 'immutable';
import { NODE_BASE_SIZE } from '../constants/styles';
import { graphNodesSelector, graphEdgesSelector } from './nodes-chart-graph';
import { activeLayoutZoomSelector } from './nodes-chart-zoom';
import { NODE_BASE_SIZE } from '../../constants/styles';
import { graphNodesSelector, graphEdgesSelector } from './graph';
import { graphZoomStateSelector } from './zoom';
import {
viewportCircularExpanseSelector,
viewportFocusHorizontalCenterSelector,
viewportFocusVerticalCenterSelector,
} from './canvas-viewport';
canvasCircularExpanseSelector,
canvasDetailsHorizontalCenterSelector,
canvasDetailsVerticalCenterSelector,
} from '../canvas';
const circularOffsetAngle = Math.PI / 4;
@@ -23,15 +23,15 @@ const radiusDensity = scaleThreshold()
const translationToViewportCenterSelector = createSelector(
[
viewportFocusHorizontalCenterSelector,
viewportFocusVerticalCenterSelector,
activeLayoutZoomSelector,
canvasDetailsHorizontalCenterSelector,
canvasDetailsVerticalCenterSelector,
graphZoomStateSelector,
],
(centerX, centerY, zoomState) => {
const { zoomScale, panTranslateX, panTranslateY } = zoomState.toJS();
const { scaleX, scaleY, translateX, translateY } = zoomState.toJS();
return {
x: (-panTranslateX + centerX) / zoomScale,
y: (-panTranslateY + centerY) / zoomScale,
x: (-translateX + centerX) / scaleX,
y: (-translateY + centerY) / scaleY,
};
}
);
@@ -75,9 +75,9 @@ const focusedNodesIdsSelector = createSelector(
const circularLayoutScalarsSelector = createSelector(
[
state => activeLayoutZoomSelector(state).get('zoomScale'),
state => graphZoomStateSelector(state).get('scaleX'),
state => focusedNodesIdsSelector(state).length - 1,
viewportCircularExpanseSelector,
canvasCircularExpanseSelector,
],
(scale, circularNodesCount, viewportExpanse) => {
// Here we calculate the zoom factor of the nodes that get selected into focus.

View File

@@ -0,0 +1,90 @@
import { createSelector } from 'reselect';
import { Map as makeMap } from 'immutable';
import { NODE_BASE_SIZE } from '../../constants/styles';
import { canvasMarginsSelector, canvasWidthSelector, canvasHeightSelector } from '../canvas';
import { activeLayoutCachedZoomSelector } from '../zooming';
import { graphNodesSelector } from './graph';
const graphBoundingRectangleSelector = createSelector(
[
graphNodesSelector,
],
(graphNodes) => {
if (graphNodes.size === 0) return null;
const xMin = graphNodes.map(n => n.get('x') - NODE_BASE_SIZE).min();
const yMin = graphNodes.map(n => n.get('y') - NODE_BASE_SIZE).min();
const xMax = graphNodes.map(n => n.get('x') + NODE_BASE_SIZE).max();
const yMax = graphNodes.map(n => n.get('y') + NODE_BASE_SIZE).max();
return makeMap({ xMin, yMin, xMax, yMax });
}
);
// Max scale limit will always be such that a node covers 1/5 of the viewport.
const maxScaleSelector = createSelector(
[
canvasWidthSelector,
canvasHeightSelector,
],
(width, height) => Math.min(width, height) / NODE_BASE_SIZE / 5
);
// Compute the default zoom settings for the given graph.
export const graphDefaultZoomSelector = createSelector(
[
graphBoundingRectangleSelector,
canvasMarginsSelector,
canvasWidthSelector,
canvasHeightSelector,
maxScaleSelector,
],
(boundingRectangle, canvasMargins, width, height, maxScale) => {
if (!boundingRectangle) return makeMap();
const { xMin, xMax, yMin, yMax } = boundingRectangle.toJS();
const xFactor = width / (xMax - xMin);
const yFactor = height / (yMax - yMin);
// Initial zoom is such that the graph covers 90% of either the viewport,
// or one half of maximal zoom constraint, whichever is smaller.
const scale = Math.min(xFactor, yFactor, maxScale / 2) * 0.9;
// This translation puts the graph in the center of the viewport, respecting the margins.
const translateX = ((width - ((xMax + xMin) * scale)) / 2) + canvasMargins.left;
const translateY = ((height - ((yMax + yMin) * scale)) / 2) + canvasMargins.top;
return makeMap({
translateX,
translateY,
scaleX: scale,
scaleY: scale,
});
}
);
export const graphZoomLimitsSelector = createSelector(
[
graphDefaultZoomSelector,
maxScaleSelector,
],
(defaultZoom, maxScale) => {
if (defaultZoom.isEmpty()) return makeMap();
// We always allow zooming out exactly 5x compared to the initial zoom.
const minScale = defaultZoom.get('scaleX') / 5;
return makeMap({ minScale, maxScale });
}
);
export const graphZoomStateSelector = createSelector(
[
graphDefaultZoomSelector,
activeLayoutCachedZoomSelector,
],
// All the cached fields override the calculated default ones.
(graphDefaultZoom, cachedZoomState) => graphDefaultZoom.merge(cachedZoomState)
);

View File

@@ -1,7 +1,54 @@
import { createSelector } from 'reselect';
import { createMapSelector } from 'reselect-map';
import { fromJS } from 'immutable';
import { fromJS, Map as makeMap, List as makeList } from 'immutable';
import { isGraphViewModeSelector, isResourceViewModeSelector } from '../selectors/topology';
import { RESOURCE_VIEW_METRICS } from '../constants/resources';
// Resource view uses the metrics of the nodes from the cache, while the graph and table
// view are looking at the current nodes (which are among other things filtered by topology
// options which are currently ignored in the resource view).
export const availableMetricsSelector = createSelector(
[
isGraphViewModeSelector,
isResourceViewModeSelector,
state => state.get('nodes'),
],
(isGraphView, isResourceView, nodes) => {
// In graph view, we always look through the fresh state
// of topology nodes to get all the available metrics.
if (isGraphView) {
return nodes
.valueSeq()
.flatMap(n => n.get('metrics', makeList()))
.map(m => makeMap({ id: m.get('id'), label: m.get('label') }))
.toSet()
.toList()
.sortBy(m => m.get('label'));
}
// In resource view, we're displaying only the hardcoded CPU and Memory metrics.
// TODO: Make this dynamic as well.
if (isResourceView) {
return fromJS(RESOURCE_VIEW_METRICS);
}
// Don't show any metrics in the table view mode.
return makeList();
}
);
export const pinnedMetricSelector = createSelector(
[
availableMetricsSelector,
state => state.get('pinnedMetricType'),
],
(availableMetrics, pinnedMetricType) => {
const metric = availableMetrics.find(m => m.get('label') === pinnedMetricType);
return metric && metric.get('id');
}
);
const topCardNodeSelector = createSelector(
[

View File

@@ -1,28 +1,22 @@
import { createSelector } from 'reselect';
import { createMapSelector } from 'reselect-map';
import { fromJS, Map as makeMap, List as makeList } from 'immutable';
import { fromJS, List as makeList, Map as makeMap } from 'immutable';
const extractNodeNetworksValue = (node) => {
if (node.has('metadata')) {
const networks = node.get('metadata')
.find(field => field.get('id') === 'docker_container_networks');
return networks && networks.get('value');
}
return null;
};
const NETWORKS_ID = 'docker_container_networks';
// TODO: Move this setting of networks as toplevel node field to backend,
// to not rely on field IDs here. should be determined by topology implementer.
export const nodeNetworksSelector = createMapSelector(
[
state => state.get('nodes').map(extractNodeNetworksValue),
state => state.get('nodes'),
],
(networksValue) => {
if (!networksValue) {
return makeList();
}
return fromJS(networksValue.split(', ').map(network => ({
(node) => {
const metadata = node.get('metadata', makeList());
const networks = metadata.find(f => f.get('id') === NETWORKS_ID) || makeMap();
const networkValues = networks.has('value') ? networks.get('value').split(', ') : [];
return fromJS(networkValues.map(network => ({
id: network, label: network, colorKey: network
})));
}
@@ -36,12 +30,19 @@ export const availableNetworksSelector = createSelector(
.sortBy(m => m.get('label'))
);
// NOTE: Don't use this selector directly in mapStateToProps
// as it would get called too many times.
export const selectedNetworkNodesIdsSelector = createSelector(
[
state => state.get('networkNodes'),
nodeNetworksSelector,
state => state.get('selectedNetwork'),
],
(networkNodes, selectedNetwork) => networkNodes.get(selectedNetwork, makeMap())
(nodeNetworks, selectedNetworkId) => {
const nodeIds = [];
nodeNetworks.forEach((networks, nodeId) => {
const networksIds = networks.map(n => n.get('id'));
if (networksIds.contains(selectedNetworkId)) {
nodeIds.push(nodeId);
}
});
return fromJS(nodeIds);
}
);

View File

@@ -1,64 +0,0 @@
import { createSelector } from 'reselect';
import { Map as makeMap } from 'immutable';
import { CANVAS_MARGINS, NODE_BASE_SIZE } from '../constants/styles';
import { activeTopologyZoomCacheKeyPathSelector } from './topology';
import { viewportWidthSelector, viewportHeightSelector } from './canvas-viewport';
import { graphNodesSelector } from './nodes-chart-graph';
// Compute the default zoom settings for the given graph layout.
const defaultZoomSelector = createSelector(
[
graphNodesSelector,
viewportWidthSelector,
viewportHeightSelector,
],
(graphNodes, width, height) => {
if (graphNodes.size === 0) {
return {};
}
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);
// 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,
// or one half of maximal zoom constraint, whichever is smaller.
const zoomScale = Math.min(xFactor, yFactor, maxZoomScale / 2) * 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) + 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'),
activeTopologyZoomCacheKeyPathSelector,
],
(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 activeLayoutZoomSelector = createSelector(
[
activeLayoutCachedZoomSelector,
defaultZoomSelector,
],
(cachedZoomState, defaultZoomState) => makeMap(cachedZoomState || defaultZoomState)
);

View File

@@ -0,0 +1,177 @@
import debug from 'debug';
import { times } from 'lodash';
import { fromJS, Map as makeMap } from 'immutable';
import { createSelector } from 'reselect';
import { RESOURCES_LAYER_PADDING, RESOURCES_LAYER_HEIGHT } from '../../constants/styles';
import {
RESOURCE_VIEW_MAX_LAYERS,
RESOURCE_VIEW_LAYERS,
TOPOLOGIES_WITH_CAPACITY,
} from '../../constants/resources';
import {
nodeParentDecoratorByTopologyId,
nodeMetricSummaryDecoratorByType,
nodeResourceViewColorDecorator,
nodeResourceBoxDecorator,
} from '../../decorators/node';
const log = debug('scope:nodes-layout');
// Used for ordering the resource nodes.
const resourceNodeConsumptionComparator = (node) => {
const metricSummary = node.get('metricSummary');
return metricSummary.get('showCapacity') ?
-metricSummary.get('relativeConsumption') :
-metricSummary.get('absoluteConsumption');
};
// A list of topologies shown in the resource view of the active topology (bottom to top).
export const layersTopologyIdsSelector = createSelector(
[
state => state.get('currentTopologyId'),
],
topologyId => fromJS(RESOURCE_VIEW_LAYERS[topologyId] || [])
);
// Calculates the resource view layer Y-coordinate for every topology in the resource view.
export const layerVerticalPositionByTopologyIdSelector = createSelector(
[
layersTopologyIdsSelector,
],
(topologiesIds) => {
let yPositions = makeMap();
let currentY = RESOURCES_LAYER_PADDING;
topologiesIds.forEach((topologyId) => {
currentY -= RESOURCES_LAYER_HEIGHT + RESOURCES_LAYER_PADDING;
yPositions = yPositions.set(topologyId, currentY);
});
return yPositions;
}
);
// Decorate and filter all the nodes to be displayed in the current resource view, except
// for the exact node horizontal offsets which are calculated from the data created here.
const decoratedNodesByTopologySelector = createSelector(
[
layersTopologyIdsSelector,
state => state.get('pinnedMetricType'),
// Generate the dependencies for this selector programmatically (because we want their
// number to be customizable directly by changing the constant). The dependency functions
// here depend on another selector, but this seems to work quite fine. For example, if
// layersTopologyIdsSelector = ['hosts', 'containers'] and RESOURCE_VIEW_MAX_LAYERS = 3,
// this code will generate:
// [
// state => state.getIn(['nodesByTopology', 'hosts'])
// state => state.getIn(['nodesByTopology', 'containers'])
// state => state.getIn(['nodesByTopology', undefined])
// ]
// which will all be captured by `topologiesNodes` and processed correctly (even for undefined).
...times(RESOURCE_VIEW_MAX_LAYERS, index => (
state => state.getIn(['nodesByTopology', layersTopologyIdsSelector(state).get(index)])
))
],
(layersTopologyIds, pinnedMetricType, ...topologiesNodes) => {
let nodesByTopology = makeMap();
let parentLayerTopologyId = null;
topologiesNodes.forEach((topologyNodes, index) => {
const layerTopologyId = layersTopologyIds.get(index);
const parentTopologyNodes = nodesByTopology.get(parentLayerTopologyId, makeMap());
const showCapacity = TOPOLOGIES_WITH_CAPACITY.includes(layerTopologyId);
const isBaseLayer = (index === 0);
const nodeParentDecorator = nodeParentDecoratorByTopologyId(parentLayerTopologyId);
const nodeMetricSummaryDecorator = nodeMetricSummaryDecoratorByType(
pinnedMetricType, showCapacity);
// Color the node, deduce its anchor point, dimensions and info about its pinned metric.
const decoratedTopologyNodes = (topologyNodes || makeMap())
.map(nodeResourceViewColorDecorator)
.map(nodeMetricSummaryDecorator)
.map(nodeResourceBoxDecorator)
.map(nodeParentDecorator);
const filteredTopologyNodes = decoratedTopologyNodes
// Filter out the nodes with no parent in the topology of the previous layer, as their
// positions in the layout could not be determined. The exception is the base layer.
// TODO: Also make an exception for uncontained nodes (e.g. processes).
.filter(node => parentTopologyNodes.has(node.get('parentNodeId')) || isBaseLayer)
// Filter out the nodes with no metric summary data, which is needed to render the node.
.filter(node => node.get('metricSummary'));
nodesByTopology = nodesByTopology.set(layerTopologyId, filteredTopologyNodes);
parentLayerTopologyId = layerTopologyId;
});
return nodesByTopology;
}
);
// Calculate (and fix) the offsets for all the displayed resource nodes.
export const layoutNodesByTopologyIdSelector = createSelector(
[
layersTopologyIdsSelector,
decoratedNodesByTopologySelector,
],
(layersTopologyIds, nodesByTopology) => {
let layoutNodes = makeMap();
let parentTopologyId = null;
// Calculate the offsets bottom-to top as each layer needs to know exact offsets of its parents.
layersTopologyIds.forEach((layerTopologyId) => {
let positionedNodes = makeMap();
// Get the nodes in the current layer grouped by their parent nodes.
// Each of those buckets will be positioned and sorted independently.
const nodesByParent = nodesByTopology
.get(layerTopologyId, makeMap())
.groupBy(n => n.get('parentNodeId'));
nodesByParent.forEach((nodesBucket, parentNodeId) => {
// Set the initial offset to the offset of the parent (that has already been set).
// If there is no offset information, i.e. we're processing the base layer, set it to 0.
const parentNode = layoutNodes.getIn([parentTopologyId, parentNodeId], makeMap());
let currentOffset = parentNode.get('offset', 0);
// Sort the nodes in the current bucket and lay them down one after another.
nodesBucket.sortBy(resourceNodeConsumptionComparator).forEach((node, nodeId) => {
const positionedNode = node.set('offset', currentOffset);
positionedNodes = positionedNodes.set(nodeId, positionedNode);
currentOffset += node.get('width');
});
// TODO: This block of code checks for the overlaps which are caused by children
// consuming more resources than their parent node. This happens due to inconsistent
// data being sent from the backend and it needs to be fixed there.
const parentOffset = parentNode.get('offset', 0);
const parentWidth = parentNode.get('width', currentOffset);
const totalChildrenWidth = currentOffset - parentOffset;
// If the total width of the children exceeds the parent node box width, we have a problem.
// We fix it by shrinking all the children to by a factor to perfectly fit into the parent.
if (totalChildrenWidth > parentWidth) {
const shrinkFactor = parentWidth / totalChildrenWidth;
log(`Inconsistent data: Children of ${parentNodeId} reported to use more ` +
`resource than the node itself - shrinking by factor ${shrinkFactor}`);
// Shrink all the children.
nodesBucket.forEach((_, nodeId) => {
const node = positionedNodes.get(nodeId);
positionedNodes = positionedNodes.mergeIn([nodeId], makeMap({
offset: ((node.get('offset') - parentOffset) * shrinkFactor) + parentOffset,
width: node.get('width') * shrinkFactor,
}));
});
}
});
// Update the layout with the positioned node from the current layer.
layoutNodes = layoutNodes.mergeIn([layerTopologyId], positionedNodes);
parentTopologyId = layerTopologyId;
});
return layoutNodes;
}
);

View File

@@ -0,0 +1,101 @@
import { createSelector } from 'reselect';
import { Map as makeMap } from 'immutable';
import { RESOURCES_LAYER_HEIGHT } from '../../constants/styles';
import { canvasMarginsSelector, canvasWidthSelector, canvasHeightSelector } from '../canvas';
import { activeLayoutCachedZoomSelector } from '../zooming';
import {
layerVerticalPositionByTopologyIdSelector,
layoutNodesByTopologyIdSelector,
} from './layout';
// This is used to determine the maximal zoom factor.
const minNodeWidthSelector = createSelector(
[
layoutNodesByTopologyIdSelector,
],
layoutNodes => layoutNodes.flatten(true).map(n => n.get('width')).min()
);
const resourceNodesBoundingRectangleSelector = createSelector(
[
layerVerticalPositionByTopologyIdSelector,
layoutNodesByTopologyIdSelector,
],
(verticalPositions, layoutNodes) => {
if (layoutNodes.size === 0) return null;
const flattenedNodes = layoutNodes.flatten(true);
const xMin = flattenedNodes.map(n => n.get('offset')).min();
const yMin = verticalPositions.toList().min();
const xMax = flattenedNodes.map(n => n.get('offset') + n.get('width')).max();
const yMax = verticalPositions.toList().max() + RESOURCES_LAYER_HEIGHT;
return makeMap({ xMin, xMax, yMin, yMax });
}
);
// Compute the default zoom settings for given resources.
export const resourcesDefaultZoomSelector = createSelector(
[
resourceNodesBoundingRectangleSelector,
canvasMarginsSelector,
canvasWidthSelector,
canvasHeightSelector,
],
(boundingRectangle, canvasMargins, width, height) => {
if (!boundingRectangle) return makeMap();
const { xMin, xMax, yMin, yMax } = boundingRectangle.toJS();
// The default scale takes all the available horizontal space and 70% of the vertical space.
const scaleX = (width / (xMax - xMin)) * 1.0;
const scaleY = (height / (yMax - yMin)) * 0.7;
// This translation puts the graph in the center of the viewport, respecting the margins.
const translateX = ((width - ((xMax + xMin) * scaleX)) / 2) + canvasMargins.left;
const translateY = ((height - ((yMax + yMin) * scaleY)) / 2) + canvasMargins.top;
return makeMap({
translateX,
translateY,
scaleX,
scaleY,
});
}
);
export const resourcesZoomLimitsSelector = createSelector(
[
resourcesDefaultZoomSelector,
resourceNodesBoundingRectangleSelector,
minNodeWidthSelector,
canvasWidthSelector,
],
(defaultZoom, boundingRectangle, minNodeWidth, width) => {
if (defaultZoom.isEmpty()) return makeMap();
const { xMin, xMax, yMin, yMax } = boundingRectangle.toJS();
return makeMap({
// Maximal zoom is such that the smallest box takes the whole canvas.
maxScale: width / minNodeWidth,
// Minimal zoom is equivalent to the initial one, where the whole layout matches the canvas.
minScale: defaultZoom.get('scaleX'),
minTranslateX: xMin,
maxTranslateX: xMax,
minTranslateY: yMin,
maxTranslateY: yMax,
});
}
);
export const resourcesZoomStateSelector = createSelector(
[
resourcesDefaultZoomSelector,
activeLayoutCachedZoomSelector,
],
// All the cached fields override the calculated default ones.
(resourcesDefaultZoom, cachedZoomState) => resourcesDefaultZoom.merge(cachedZoomState)
);

View File

@@ -1,7 +1,35 @@
import { createSelector } from 'reselect';
import {
RESOURCE_VIEW_MODE,
GRAPH_VIEW_MODE,
TABLE_VIEW_MODE,
} from '../constants/naming';
// TODO: Consider moving more stuff from 'topology-utils' here.
export const isGraphViewModeSelector = createSelector(
[
state => state.get('topologyViewMode'),
],
viewMode => viewMode === GRAPH_VIEW_MODE
);
export const isTableViewModeSelector = createSelector(
[
state => state.get('topologyViewMode'),
],
viewMode => viewMode === TABLE_VIEW_MODE
);
export const isResourceViewModeSelector = createSelector(
[
state => state.get('topologyViewMode'),
],
viewMode => viewMode === RESOURCE_VIEW_MODE
);
// Checks if graph complexity is high. Used to trigger
// table view on page load and decide on animations.
export const graphExceedsComplexityThreshSelector = createSelector(
@@ -23,11 +51,3 @@ export const activeTopologyOptionsSelector = createSelector(
topologyOptions.get(parentTopologyId || currentTopologyId)
)
);
export const activeTopologyZoomCacheKeyPathSelector = createSelector(
[
state => state.get('currentTopologyId'),
activeTopologyOptionsSelector,
],
(topologyId, topologyOptions) => ['zoomCache', topologyId, JSON.stringify(topologyOptions)]
);

View File

@@ -0,0 +1,33 @@
import { createSelector } from 'reselect';
import { Map as makeMap } from 'immutable';
import { isGraphViewModeSelector, activeTopologyOptionsSelector } from './topology';
export const activeTopologyZoomCacheKeyPathSelector = createSelector(
[
isGraphViewModeSelector,
state => state.get('topologyViewMode'),
state => state.get('currentTopologyId'),
state => state.get('pinnedMetricType'),
state => JSON.stringify(activeTopologyOptionsSelector(state)),
],
(isGraphViewMode, viewMode, topologyId, pinnedMetricType, topologyOptions) => (
isGraphViewMode ?
// In graph view, selecting different options/filters produces a different layout.
['zoomCache', viewMode, topologyId, topologyOptions] :
// Otherwise we're in the resource view where the options are hidden (for now),
// but pinning different metrics can result in very different layouts.
// TODO: Take `topologyId` into account once the resource
// view layouts start differing between the topologies.
['zoomCache', viewMode, pinnedMetricType]
)
);
export const activeLayoutCachedZoomSelector = createSelector(
[
state => state.get('zoomCache'),
activeTopologyZoomCacheKeyPathSelector,
],
(zoomCache, keyPath) => zoomCache.getIn(keyPath.slice(1), makeMap())
);