Merge pull request #2812 from weaveworks/unified-zooming-sensitivity

Unified zooming sensitivity
This commit is contained in:
Filip Barl
2017-08-24 18:05:01 +02:00
committed by GitHub
12 changed files with 213 additions and 116 deletions
+17 -6
View File
@@ -3,12 +3,15 @@ import { connect } from 'react-redux';
import NodesChartElements from './nodes-chart-elements';
import ZoomableCanvas from '../components/zoomable-canvas';
import { transformToString } from '../utils/transform-utils';
import { clickBackground } from '../actions/app-actions';
import {
graphZoomLimitsSelector,
graphLimitsSelector,
graphZoomStateSelector,
} from '../selectors/graph-view/zoom';
import { CONTENT_INCLUDED } from '../constants/naming';
const EdgeMarkerDefinition = ({ selectedNodeId }) => {
const markerOffset = selectedNodeId ? '35' : '40';
@@ -43,17 +46,25 @@ class NodesChart extends React.Component {
}
}
renderContent(transform) {
return (
<g transform={transformToString(transform)}>
<EdgeMarkerDefinition selectedNodeId={this.props.selectedNodeId} />
<NodesChartElements />
</g>
);
}
render() {
const { selectedNodeId } = this.props;
return (
<div className="nodes-chart">
<ZoomableCanvas
onClick={this.handleMouseClick}
zoomLimitsSelector={graphZoomLimitsSelector}
boundContent={CONTENT_INCLUDED}
limitsSelector={graphLimitsSelector}
zoomStateSelector={graphZoomStateSelector}
disabled={selectedNodeId}>
<EdgeMarkerDefinition selectedNodeId={selectedNodeId} />
<NodesChartElements />
disabled={this.props.selectedNodeId}>
{transform => this.renderContent(transform)}
</ZoomableCanvas>
</div>
);
@@ -5,11 +5,13 @@ import ZoomableCanvas from './zoomable-canvas';
import NodesResourcesLayer from './nodes-resources/node-resources-layer';
import { layersTopologyIdsSelector } from '../selectors/resource-view/layout';
import {
resourcesZoomLimitsSelector,
resourcesLimitsSelector,
resourcesZoomStateSelector,
} from '../selectors/resource-view/zoom';
import { clickBackground } from '../actions/app-actions';
import { CONTENT_COVERING } from '../constants/naming';
class NodesResources extends React.Component {
constructor(props, context) {
@@ -40,8 +42,8 @@ class NodesResources extends React.Component {
<div className="nodes-resources">
<ZoomableCanvas
onClick={this.handleMouseClick}
bounded forwardTransform fixVertical
zoomLimitsSelector={resourcesZoomLimitsSelector}
fixVertical boundContent={CONTENT_COVERING}
limitsSelector={resourcesLimitsSelector}
zoomStateSelector={resourcesZoomStateSelector}>
{transform => this.renderLayers(transform)}
</ZoomableCanvas>
@@ -168,7 +168,7 @@ class TimeTravelTimeline extends React.Component {
}
handleZoom(ev) {
let durationPerPixel = scaleDuration(this.state.durationPerPixel, zoomFactor(ev));
let durationPerPixel = scaleDuration(this.state.durationPerPixel, 1 / zoomFactor(ev));
if (durationPerPixel > MAX_DURATION_PER_PX) durationPerPixel = MAX_DURATION_PER_PX;
if (durationPerPixel < MIN_DURATION_PER_PX) durationPerPixel = MIN_DURATION_PER_PX;
+134 -86
View File
@@ -1,15 +1,17 @@
import React from 'react';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { debounce, pick } from 'lodash';
import { clamp, debounce, pick } from 'lodash';
import { fromJS } from 'immutable';
import { drag } from 'd3-drag';
import { event as d3Event, select } from 'd3-selection';
import { zoom, zoomIdentity } from 'd3-zoom';
import Logo from '../components/logo';
import ZoomControl from '../components/zoom-control';
import { cacheZoomState } from '../actions/app-actions';
import { transformToString } from '../utils/transform-utils';
import { zoomFactor } from '../utils/zoom-utils';
import { applyTransform, inverseTransform } from '../utils/transform-utils';
import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming';
import {
canvasMarginsSelector,
@@ -18,6 +20,7 @@ import {
} from '../selectors/canvas';
import { ZOOM_CACHE_DEBOUNCE_INTERVAL } from '../constants/timer';
import { CONTENT_INCLUDED, CONTENT_COVERING } from '../constants/naming';
class ZoomableCanvas extends React.Component {
@@ -25,10 +28,11 @@ class ZoomableCanvas extends React.Component {
super(props, context);
this.state = {
minTranslateX: 0,
maxTranslateX: 0,
minTranslateY: 0,
maxTranslateY: 0,
isPanning: false,
contentMinX: 0,
contentMaxX: 0,
contentMinY: 0,
contentMaxY: 0,
translateX: 0,
translateY: 0,
minScale: 1,
@@ -40,27 +44,33 @@ class ZoomableCanvas extends React.Component {
this.debouncedCacheZoom = debounce(this.cacheZoom.bind(this), ZOOM_CACHE_DEBOUNCE_INTERVAL);
this.handleZoomControlAction = this.handleZoomControlAction.bind(this);
this.canChangeZoom = this.canChangeZoom.bind(this);
this.zoomed = this.zoomed.bind(this);
this.handleZoom = this.handleZoom.bind(this);
this.handlePanStart = this.handlePanStart.bind(this);
this.handlePanEnd = this.handlePanEnd.bind(this);
this.handlePan = this.handlePan.bind(this);
}
componentDidMount() {
this.svg = select('.zoomable-canvas svg');
this.drag = drag()
.on('start', this.handlePanStart)
.on('end', this.handlePanEnd)
.on('drag', this.handlePan);
this.svg.call(this.drag);
this.zoomRestored = false;
this.zoom = zoom().on('zoom', this.zoomed);
this.svg = select('svg#canvas');
this.setZoomTriggers(!this.props.disabled);
this.updateZoomLimits(this.props);
this.restoreZoomState(this.props);
}
componentWillUnmount() {
this.setZoomTriggers(false);
this.debouncedCacheZoom.cancel();
}
componentWillReceiveProps(nextProps) {
const layoutChanged = nextProps.layoutId !== this.props.layoutId;
const disabledChanged = nextProps.disabled !== this.props.disabled;
// If the layout has changed (either active topology or its options) or
// relayouting has been requested, stop pending zoom caching event and
@@ -70,11 +80,6 @@ class ZoomableCanvas extends React.Component {
this.zoomRestored = false;
}
// If the zooming has been enabled/disabled, update its triggers.
if (disabledChanged) {
this.setZoomTriggers(!nextProps.disabled);
}
this.updateZoomLimits(nextProps);
if (!this.zoomRestored) {
this.restoreZoomState(nextProps);
@@ -82,31 +87,25 @@ class ZoomableCanvas extends React.Component {
}
handleZoomControlAction(scale) {
// Update the canvas scale (not touching the translation).
this.svg.call(this.zoom.scaleTo, scale);
// Update the scale state and propagate to the global cache.
this.setState(this.cachableState({
scaleX: scale,
scaleY: scale,
}));
this.debouncedCacheZoom();
// Get the center of the SVG and zoom around it.
const { top, bottom, left, right } = this.svg.node().getBoundingClientRect();
const centerOfCanvas = {
x: (left + right) / 2,
y: (top + bottom) / 2,
};
// Zoom factor diff is obtained by dividing the new zoom scale with the old one.
this.zoomAtPositionByFactor(centerOfCanvas, scale / this.state.scaleX);
}
render() {
// `forwardTransform` says whether the zoom transform is forwarded to the child
// component. The advantage of that is more control rendering control in the
// children, while the disadvantage is that it's slower, as all the children
// get updated on every zoom/pan action.
const { children, forwardTransform } = this.props;
const transform = forwardTransform ? '' : transformToString(this.state);
const className = classNames({ panning: this.state.isPanning });
return (
<div className="zoomable-canvas">
<svg id="canvas" width="100%" height="100%" onClick={this.props.onClick}>
<svg className={className} onClick={this.props.onClick} onWheel={this.handleZoom}>
<Logo transform="translate(24,24) scale(0.25)" />
<g className="zoom-content" transform={transform}>
{forwardTransform ? children(this.state) : children}
<g className="zoom-content">
{this.props.children(this.state)}
</g>
</svg>
{this.canChangeZoom() && <ZoomControl
@@ -119,16 +118,6 @@ class ZoomableCanvas extends React.Component {
);
}
setZoomTriggers(zoomingEnabled) {
if (zoomingEnabled) {
// use d3-zoom defaults but exclude double clicks
this.svg.call(this.zoom)
.on('dblclick.zoom', null);
} else {
this.svg.on('.zoom', null);
}
}
// Decides which part of the zoom state is cachable depending
// on the horizontal/vertical degrees of freedom.
cachableState(state = this.state) {
@@ -144,28 +133,7 @@ class ZoomableCanvas extends React.Component {
}
updateZoomLimits(props) {
const zoomLimits = props.layoutZoomLimits.toJS();
this.zoom = this.zoom.scaleExtent([zoomLimits.minScale, zoomLimits.maxScale]);
if (props.bounded) {
this.zoom = this.zoom
// Translation limits are only set if explicitly demanded (currently we are using them
// in the resource view, but not in the graph view, although I think the idea would be
// to use them everywhere).
.translateExtent([
[zoomLimits.minTranslateX, zoomLimits.minTranslateY],
[zoomLimits.maxTranslateX, zoomLimits.maxTranslateY],
])
// This is to ensure that the translation limits are properly
// centered, so that the canvas margins are respected.
.extent([
[props.canvasMargins.left, props.canvasMargins.top],
[props.canvasMargins.left + props.width, props.canvasMargins.top + props.height]
]);
}
this.setState(zoomLimits);
this.setState(props.layoutLimits.toJS());
}
// Restore the zooming settings
@@ -173,11 +141,6 @@ class ZoomableCanvas extends React.Component {
if (!props.layoutZoomState.isEmpty()) {
const zoomState = props.layoutZoomState.toJS();
// After the limits have been set, update the zoom.
this.svg.call(this.zoom.transform, zoomIdentity
.translate(zoomState.translateX, zoomState.translateY)
.scale(zoomState.scaleX, zoomState.scaleY));
// Update the state variables.
this.setState(zoomState);
this.zoomRestored = true;
@@ -185,23 +148,108 @@ class ZoomableCanvas extends React.Component {
}
canChangeZoom() {
const { disabled, layoutZoomLimits } = this.props;
const canvasHasContent = !layoutZoomLimits.isEmpty();
const { disabled, layoutLimits } = this.props;
const canvasHasContent = !layoutLimits.isEmpty();
return !disabled && canvasHasContent;
}
zoomed() {
if (this.canChangeZoom()) {
const updatedState = this.cachableState({
scaleX: d3Event.transform.k,
scaleY: d3Event.transform.k,
translateX: d3Event.transform.x,
translateY: d3Event.transform.y,
});
handlePanStart() {
this.setState({ isPanning: true });
}
this.setState(updatedState);
this.debouncedCacheZoom();
handlePanEnd() {
this.setState({ isPanning: false });
}
handlePan() {
let state = this.state;
// Apply the translation respecting the boundaries.
state = this.clampedTranslation({ ...state,
translateX: this.state.translateX + d3Event.dx,
translateY: this.state.translateY + d3Event.dy,
});
this.updateState(state);
}
handleZoom(ev) {
if (this.canChangeZoom()) {
// Get the exact mouse cursor position in the SVG and zoom around it.
const { top, left } = this.svg.node().getBoundingClientRect();
const mousePosition = {
x: ev.clientX - left,
y: ev.clientY - top,
};
this.zoomAtPositionByFactor(mousePosition, zoomFactor(ev));
}
ev.preventDefault();
}
clampedTranslation(state) {
const { width, height, canvasMargins, boundContent, layoutLimits } = this.props;
const { contentMinX, contentMaxX, contentMinY, contentMaxY } = layoutLimits.toJS();
if (boundContent) {
// If the content is required to be bounded in any way, the translation will
// be adjusted so that certain constraints between the viewport and displayed
// content bounding box are met.
const viewportMin = { x: canvasMargins.left, y: canvasMargins.top };
const viewportMax = { x: canvasMargins.left + width, y: canvasMargins.top + height };
const contentMin = applyTransform(state, { x: contentMinX, y: contentMinY });
const contentMax = applyTransform(state, { x: contentMaxX, y: contentMaxY });
switch (boundContent) {
case CONTENT_COVERING:
// These lines will adjust the translation by 'minimal effort' in
// such a way that the content always FULLY covers the viewport,
// i.e. that the viewport rectangle is always fully contained in
// the content bounding box rectangle - the assumption made here
// is that that can always be done.
state.translateX += Math.max(0, viewportMax.x - contentMax.x);
state.translateX -= Math.max(0, contentMin.x - viewportMin.x);
state.translateY += Math.max(0, viewportMax.y - contentMax.y);
state.translateY -= Math.max(0, contentMin.y - viewportMin.y);
break;
case CONTENT_INCLUDED:
// These lines will adjust the translation by 'minimal effort' in
// such a way that the content is always at least PARTLY contained
// within the viewport, i.e. that the intersection between the
// viewport and the content bounding box always exists.
state.translateX -= Math.max(0, contentMin.x - viewportMax.x);
state.translateX += Math.max(0, viewportMin.x - contentMax.x);
state.translateY -= Math.max(0, contentMin.y - viewportMax.y);
state.translateY += Math.max(0, viewportMin.y - contentMax.y);
break;
default:
break;
}
}
return state;
}
zoomAtPositionByFactor(position, factor) {
// Update the scales by the given factor, respecting the zoom limits.
const { minScale, maxScale } = this.state;
const scaleX = clamp(this.state.scaleX * factor, minScale, maxScale);
const scaleY = clamp(this.state.scaleY * factor, minScale, maxScale);
let state = { ...this.state, scaleX, scaleY };
// Get the position in the coordinates before the transition and use it
// to adjust the translation part of the new transition (respecting the
// translation limits). Adapted from:
// https://github.com/d3/d3-zoom/blob/807f02c7a5fe496fbd08cc3417b62905a8ce95fa/src/zoom.js#L251
const inversePosition = inverseTransform(this.state, position);
state = this.clampedTranslation({ ...state,
translateX: position.x - (inversePosition.x * scaleX),
translateY: position.y - (inversePosition.y * scaleY),
});
this.updateState(state);
}
updateState(state) {
this.setState(this.cachableState(state));
this.debouncedCacheZoom();
}
}
@@ -212,7 +260,7 @@ function mapStateToProps(state, props) {
height: canvasHeightSelector(state),
canvasMargins: canvasMarginsSelector(state),
layoutZoomState: props.zoomStateSelector(state),
layoutZoomLimits: props.zoomLimitsSelector(state),
layoutLimits: props.limitsSelector(state),
layoutId: JSON.stringify(activeTopologyZoomCacheKeyPathSelector(state)),
forceRelayout: state.get('forceRelayout'),
};
+3
View File
@@ -16,3 +16,6 @@ export const HIGHLIGHTED_EDGES_LAYER = 'highlighted-edges';
export const HIGHLIGHTED_NODES_LAYER = 'highlighted-nodes';
export const HOVERED_EDGES_LAYER = 'hovered-edges';
export const HOVERED_NODES_LAYER = 'hovered-nodes';
export const CONTENT_INCLUDED = 'content-included';
export const CONTENT_COVERING = 'content-covering';
+1 -1
View File
@@ -38,7 +38,7 @@ export const NODE_BASE_SIZE = 100;
export const EDGE_WAYPOINTS_CAP = 10;
export const CANVAS_MARGINS = {
[GRAPH_VIEW_MODE]: { top: 160, left: 40, right: 40, bottom: 150 },
[GRAPH_VIEW_MODE]: { top: 160, left: 80, right: 80, bottom: 150 },
[TABLE_VIEW_MODE]: { top: 220, left: 40, right: 40, bottom: 30 },
[RESOURCE_VIEW_MODE]: { top: 140, left: 210, right: 40, bottom: 150 },
};
@@ -6,7 +6,7 @@ import { canvasMarginsSelector, canvasWidthSelector, canvasHeightSelector } from
import { activeLayoutCachedZoomSelector } from '../zooming';
import { graphNodesSelector } from './graph';
// Nodes in the layout are always kept between 1px and 200px big.
// Nodes in the layout are always kept between 3px and 200px big.
const MAX_SCALE = 200 / NODE_BASE_SIZE;
const MIN_SCALE = 3 / NODE_BASE_SIZE;
@@ -58,10 +58,24 @@ export const graphDefaultZoomSelector = createSelector(
}
);
// NOTE: This constant is made into a selector to fit
// props requirements for <ZoomableCanvas /> component.
export const graphZoomLimitsSelector = createSelector(
[], () => makeMap({ minScale: MIN_SCALE, maxScale: MAX_SCALE })
export const graphLimitsSelector = createSelector(
[
graphBoundingRectangleSelector,
],
(boundingRectangle) => {
if (!boundingRectangle) return makeMap();
const { xMin, xMax, yMin, yMax } = boundingRectangle.toJS();
return makeMap({
minScale: MIN_SCALE,
maxScale: MAX_SCALE,
contentMinX: xMin,
contentMaxX: xMax,
contentMinY: yMin,
contentMaxY: yMax,
});
}
);
export const graphZoomStateSelector = createSelector(
@@ -66,7 +66,7 @@ export const resourcesDefaultZoomSelector = createSelector(
}
);
export const resourcesZoomLimitsSelector = createSelector(
export const resourcesLimitsSelector = createSelector(
[
resourcesDefaultZoomSelector,
resourceNodesBoundingRectangleSelector,
@@ -83,10 +83,10 @@ export const resourcesZoomLimitsSelector = createSelector(
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,
contentMinX: xMin,
contentMaxX: xMax,
contentMinY: yMin,
contentMaxY: yMax,
});
}
);
+15 -1
View File
@@ -4,13 +4,27 @@ const applyTranslateY = ({ scaleY = 1, translateY = 0 }, y) => (y * scaleY) + tr
const applyScaleX = ({ scaleX = 1 }, width) => width * scaleX;
const applyScaleY = ({ scaleY = 1 }, height) => height * scaleY;
export const applyTransform = (transform, { width, height, x, y }) => ({
export const applyTransform = (transform, { width = 0, height = 0, x, y }) => ({
x: applyTranslateX(transform, x),
y: applyTranslateY(transform, y),
width: applyScaleX(transform, width),
height: applyScaleY(transform, height),
});
const inverseTranslateX = ({ scaleX = 1, translateX = 0 }, x) => (x - translateX) / scaleX;
const inverseTranslateY = ({ scaleY = 1, translateY = 0 }, y) => (y - translateY) / scaleY;
const inverseScaleX = ({ scaleX = 1 }, width) => width / scaleX;
const inverseScaleY = ({ scaleY = 1 }, height) => height / scaleY;
export const inverseTransform = (transform, { width = 0, height = 0, x, y }) => ({
x: inverseTranslateX(transform, x),
y: inverseTranslateY(transform, y),
width: inverseScaleX(transform, width),
height: inverseScaleY(transform, height),
});
export const transformToString = ({ translateX = 0, translateY = 0, scaleX = 1, scaleY = 1 }) => (
`translate(${translateX},${translateY}) scale(${scaleX},${scaleY})`
);
+1 -1
View File
@@ -7,7 +7,7 @@ function wheelDelta(ev) {
// Only Firefox seems to use the line unit (which we assume to
// be 25px), otherwise the delta is already measured in pixels.
const unitInPixels = (ev.deltaMode === DOM_DELTA_LINE ? 25 : 1);
return ev.deltaY * unitInPixels * ZOOM_SENSITIVITY;
return -ev.deltaY * unitInPixels * ZOOM_SENSITIVITY;
}
export function zoomFactor(ev) {
+12 -5
View File
@@ -63,6 +63,14 @@ a {
cursor: -webkit-grabbing;
}
.fully-pannable {
width: 100%;
height: 100%;
@extend .grabbable;
&.panning { @extend .grabbing; }
}
.shadow-2 {
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.16), 0 3px 10px rgba(0, 0, 0, 0.23);
}
@@ -284,15 +292,11 @@ a {
height: $timeline-height;
svg {
@extend .grabbable;
@extend .fully-pannable;
background-color: rgba(255, 255, 255, 0.85);
box-shadow: inset 0 0 7px #aaa;
pointer-events: all;
margin: 0 7px;
width: 100%;
height: 100%;
&.panning { @extend .grabbing; }
.available-range {
fill: #888;
@@ -358,6 +362,9 @@ a {
}
}
.zoomable-canvas svg {
@extend .fully-pannable;
}
.topologies {
margin: 0 4px;
-2
View File
@@ -18,8 +18,6 @@
"d3-selection": "1.0.5",
"d3-shape": "1.0.6",
"d3-time-format": "2.0.5",
"d3-transition": "1.0.4",
"d3-zoom": "1.1.4",
"dagre": "0.7.4",
"debug": "2.6.6",
"filesize": "3.5.9",