From f11a9d361461591bb330bdb060e051881f8191c6 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Mon, 14 Aug 2017 18:47:57 +0200 Subject: [PATCH 1/5] Working prototype with internal zooming/panning handlers. --- .../app/scripts/components/zoomable-canvas.js | 168 +++++++++++------- 1 file changed, 100 insertions(+), 68 deletions(-) diff --git a/client/app/scripts/components/zoomable-canvas.js b/client/app/scripts/components/zoomable-canvas.js index 205b4f2c1..1f5264795 100644 --- a/client/app/scripts/components/zoomable-canvas.js +++ b/client/app/scripts/components/zoomable-canvas.js @@ -1,14 +1,15 @@ import React from 'react'; 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 { zoomFactor } from '../utils/zoom-utils'; import { transformToString } from '../utils/transform-utils'; import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming'; import { @@ -19,6 +20,15 @@ import { import { ZOOM_CACHE_DEBOUNCE_INTERVAL } from '../constants/timer'; +const transformF = ({ x, y }, t) => ({ + x: t.translateX + (t.scaleX * x), + y: t.translateY + (t.scaleY * y), +}); + +const inverseTransform = ({ x, y }, t) => ({ + x: (x - t.translateX) / t.scaleX, + y: (y - t.translateY) / t.scaleY, +}); class ZoomableCanvas extends React.Component { constructor(props, context) { @@ -40,27 +50,26 @@ 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.handlePan = this.handlePan.bind(this); } componentDidMount() { this.zoomRestored = false; - this.zoom = zoom().on('zoom', this.zoomed); this.svg = select('svg#canvas'); + this.drag = drag().on('drag', this.handlePan); + this.svg.call(this.drag); - 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 +79,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); @@ -83,14 +87,12 @@ 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(); + const { top, bottom, left, right } = this.svg.node().getBoundingClientRect(); + const centerOfCanvas = { + x: (left + right) / 2, + y: (top + bottom) / 2, + }; + this.zoomAtPosition(centerOfCanvas, scale / this.state.scaleX); } render() { @@ -103,7 +105,9 @@ class ZoomableCanvas extends React.Component { return (
- + {forwardTransform ? children(this.state) : children} @@ -119,16 +123,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 +138,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.layoutZoomLimits.toJS()); } // Restore the zooming settings @@ -173,11 +146,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; @@ -190,19 +158,83 @@ class ZoomableCanvas extends React.Component { 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, - }); + handlePan() { + let state = { ...this.state }; + state = this.clampedTranslation({ ...state, + translateX: this.state.translateX + d3Event.dx, + translateY: this.state.translateY + d3Event.dy, + }); + this.updateState(state); + } - this.setState(updatedState); - this.debouncedCacheZoom(); + handleZoom(ev) { + if (this.canChangeZoom()) { + const { top, left } = this.svg.node().getBoundingClientRect(); + const mousePosition = { + x: ev.clientX - left, + y: ev.clientY - top, + }; + this.zoomAtPosition(mousePosition, 1 / zoomFactor(ev)); } } + + clampedTranslation(state) { + if (this.props.bounded) { + const { width, height, canvasMargins } = this.props; + const { maxTranslateX, minTranslateX, maxTranslateY, minTranslateY } + = this.props.layoutZoomLimits.toJS(); + + const minPoint = transformF({ x: minTranslateX, y: minTranslateY }, state); + const maxPoint = transformF({ x: maxTranslateX, y: maxTranslateY }, state); + const viewportMinPoint = { x: canvasMargins.left, y: canvasMargins.top }; + const viewportMaxPoint = { x: canvasMargins.left + width, y: canvasMargins.top + height }; + + if (true) { + if (maxPoint.x < viewportMaxPoint.x) { + state.translateX += viewportMaxPoint.x - maxPoint.x; + } else if (minPoint.x > viewportMinPoint.x) { + state.translateX -= minPoint.x - viewportMinPoint.x; + } + if (maxPoint.y < viewportMaxPoint.y) { + state.translateY += viewportMaxPoint.y - maxPoint.y; + } else if (minPoint.y > viewportMinPoint.y) { + state.translateY -= minPoint.y - viewportMinPoint.y; + } + } else { + if (minPoint.x > viewportMaxPoint.x) { + state.translateX -= minPoint.x - viewportMaxPoint.x; + } else if (maxPoint.x < viewportMinPoint.x) { + state.translateX += viewportMinPoint.x - maxPoint.x; + } + if (minPoint.y > viewportMaxPoint.y) { + state.translateY -= minPoint.y - viewportMaxPoint.y; + } else if (maxPoint.y < viewportMinPoint.y) { + state.translateY += viewportMinPoint.y - maxPoint.y; + } + } + } + return state; + } + + zoomAtPosition(position, factor) { + 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 }; + + const inversePosition = inverseTransform(position, this.state); + 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(); + } } From a185ae4e15627f8901aef89d0eb4ce58205499ac Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 15 Aug 2017 16:41:12 +0200 Subject: [PATCH 2/5] Introduced hand cursor and unified ZoomableCanvas usage. --- client/app/scripts/charts/nodes-chart.js | 16 +++- .../app/scripts/components/nodes-resources.js | 2 +- .../app/scripts/components/zoomable-canvas.js | 75 ++++++++----------- client/app/styles/_base.scss | 9 +++ 4 files changed, 55 insertions(+), 47 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index e200efe1f..fcbc60a8d 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -3,6 +3,7 @@ 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, @@ -43,17 +44,24 @@ class NodesChart extends React.Component { } } + renderContent(transform) { + return ( + + + + + ); + } + render() { - const { selectedNodeId } = this.props; return (
- - + disabled={this.props.selectedNodeId}> + {transform => this.renderContent(transform)}
); diff --git a/client/app/scripts/components/nodes-resources.js b/client/app/scripts/components/nodes-resources.js index 50f257ab3..28d91d182 100644 --- a/client/app/scripts/components/nodes-resources.js +++ b/client/app/scripts/components/nodes-resources.js @@ -39,8 +39,8 @@ class NodesResources extends React.Component { return (
{transform => this.renderLayers(transform)} diff --git a/client/app/scripts/components/zoomable-canvas.js b/client/app/scripts/components/zoomable-canvas.js index 1f5264795..a37dca26a 100644 --- a/client/app/scripts/components/zoomable-canvas.js +++ b/client/app/scripts/components/zoomable-canvas.js @@ -1,4 +1,5 @@ import React from 'react'; +import classNames from 'classnames'; import { connect } from 'react-redux'; import { clamp, debounce, pick } from 'lodash'; import { fromJS } from 'immutable'; @@ -10,7 +11,6 @@ import Logo from '../components/logo'; import ZoomControl from '../components/zoom-control'; import { cacheZoomState } from '../actions/app-actions'; import { zoomFactor } from '../utils/zoom-utils'; -import { transformToString } from '../utils/transform-utils'; import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming'; import { canvasMarginsSelector, @@ -35,6 +35,7 @@ class ZoomableCanvas extends React.Component { super(props, context); this.state = { + isPanning: false, minTranslateX: 0, maxTranslateX: 0, minTranslateY: 0, @@ -50,16 +51,23 @@ 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.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.zoomRestored = false; - this.svg = select('svg#canvas'); - this.drag = drag().on('drag', this.handlePan); + 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.updateZoomLimits(this.props); this.restoreZoomState(this.props); } @@ -96,21 +104,14 @@ class ZoomableCanvas extends React.Component { } 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 (
- + - - {forwardTransform ? children(this.state) : children} + + {this.props.children(this.state)} {this.canChangeZoom() && viewportMinPoint.x) { - state.translateX -= minPoint.x - viewportMinPoint.x; - } - if (maxPoint.y < viewportMaxPoint.y) { - state.translateY += viewportMaxPoint.y - maxPoint.y; - } else if (minPoint.y > viewportMinPoint.y) { - state.translateY -= minPoint.y - viewportMinPoint.y; - } - } else { - if (minPoint.x > viewportMaxPoint.x) { - state.translateX -= minPoint.x - viewportMaxPoint.x; - } else if (maxPoint.x < viewportMinPoint.x) { - state.translateX += viewportMinPoint.x - maxPoint.x; - } - if (minPoint.y > viewportMaxPoint.y) { - state.translateY -= minPoint.y - viewportMaxPoint.y; - } else if (maxPoint.y < viewportMinPoint.y) { - state.translateY += viewportMinPoint.y - maxPoint.y; - } - } + state.translateX += Math.max(0, viewportMaxPoint.x - maxPoint.x); + state.translateX += Math.min(0, viewportMinPoint.x - minPoint.x); + state.translateY += Math.max(0, viewportMaxPoint.y - maxPoint.y); + state.translateY += Math.min(0, viewportMinPoint.y - minPoint.y); } + return state; } diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index a859eb655..e2ccdfc98 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -358,6 +358,15 @@ a { } } +.zoomable-canvas { + svg { + @extend .grabbable; + width: 100%; + height: 100%; + + &.panning { @extend .grabbing; } + } +} .topologies { margin: 0 4px; From 297e21d42c8f75e037d9ea1285b69b50920d440c Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 15 Aug 2017 16:46:57 +0200 Subject: [PATCH 3/5] Removed d3-zoom reference in package.json. --- .../app/scripts/components/zoomable-canvas.js | 29 +++++++------------ client/app/scripts/utils/transform-utils.js | 16 +++++++++- client/app/styles/_base.scss | 24 +++++++-------- client/package.json | 2 -- 4 files changed, 36 insertions(+), 35 deletions(-) diff --git a/client/app/scripts/components/zoomable-canvas.js b/client/app/scripts/components/zoomable-canvas.js index a37dca26a..07744ce58 100644 --- a/client/app/scripts/components/zoomable-canvas.js +++ b/client/app/scripts/components/zoomable-canvas.js @@ -11,6 +11,7 @@ import Logo from '../components/logo'; import ZoomControl from '../components/zoom-control'; import { cacheZoomState } from '../actions/app-actions'; import { zoomFactor } from '../utils/zoom-utils'; +import { applyTransform, inverseTransform } from '../utils/transform-utils'; import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming'; import { canvasMarginsSelector, @@ -20,15 +21,6 @@ import { import { ZOOM_CACHE_DEBOUNCE_INTERVAL } from '../constants/timer'; -const transformF = ({ x, y }, t) => ({ - x: t.translateX + (t.scaleX * x), - y: t.translateY + (t.scaleY * y), -}); - -const inverseTransform = ({ x, y }, t) => ({ - x: (x - t.translateX) / t.scaleX, - y: (y - t.translateY) / t.scaleY, -}); class ZoomableCanvas extends React.Component { constructor(props, context) { @@ -188,20 +180,19 @@ class ZoomableCanvas extends React.Component { } clampedTranslation(state) { - const { width, height, canvasMargins, bounded } = this.props; - const { maxTranslateX, minTranslateX, maxTranslateY, minTranslateY } - = this.props.layoutZoomLimits.toJS(); + const { width, height, canvasMargins, bounded, layoutZoomLimits } = this.props; + const { maxTranslateX, minTranslateX, maxTranslateY, minTranslateY } = layoutZoomLimits.toJS(); if (bounded) { - const minPoint = transformF({ x: minTranslateX, y: minTranslateY }, state); - const maxPoint = transformF({ x: maxTranslateX, y: maxTranslateY }, state); + const contentMinPoint = applyTransform(state, { x: minTranslateX, y: minTranslateY }); + const contentMaxPoint = applyTransform(state, { x: maxTranslateX, y: maxTranslateY }); const viewportMinPoint = { x: canvasMargins.left, y: canvasMargins.top }; const viewportMaxPoint = { x: canvasMargins.left + width, y: canvasMargins.top + height }; - state.translateX += Math.max(0, viewportMaxPoint.x - maxPoint.x); - state.translateX += Math.min(0, viewportMinPoint.x - minPoint.x); - state.translateY += Math.max(0, viewportMaxPoint.y - maxPoint.y); - state.translateY += Math.min(0, viewportMinPoint.y - minPoint.y); + state.translateX += Math.max(0, viewportMaxPoint.x - contentMaxPoint.x); + state.translateX += Math.min(0, viewportMinPoint.x - contentMinPoint.x); + state.translateY += Math.max(0, viewportMaxPoint.y - contentMaxPoint.y); + state.translateY += Math.min(0, viewportMinPoint.y - contentMinPoint.y); } return state; @@ -213,7 +204,7 @@ class ZoomableCanvas extends React.Component { const scaleY = clamp(this.state.scaleY * factor, minScale, maxScale); let state = { ...this.state, scaleX, scaleY }; - const inversePosition = inverseTransform(position, this.state); + const inversePosition = inverseTransform(this.state, position); state = this.clampedTranslation({ ...state, translateX: position.x - (inversePosition.x * scaleX), translateY: position.y - (inversePosition.y * scaleY), diff --git a/client/app/scripts/utils/transform-utils.js b/client/app/scripts/utils/transform-utils.js index 21160ae79..cc1834dd6 100644 --- a/client/app/scripts/utils/transform-utils.js +++ b/client/app/scripts/utils/transform-utils.js @@ -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})` ); diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index e2ccdfc98..15493c682 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -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,14 +362,8 @@ a { } } -.zoomable-canvas { - svg { - @extend .grabbable; - width: 100%; - height: 100%; - - &.panning { @extend .grabbing; } - } +.zoomable-canvas svg { + @extend .fully-pannable; } .topologies { diff --git a/client/package.json b/client/package.json index 00b4abf3a..27690be6a 100644 --- a/client/package.json +++ b/client/package.json @@ -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", From a51d34f1e51d4e0309282f4bcfb91f836a779ae1 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 15 Aug 2017 18:02:16 +0200 Subject: [PATCH 4/5] Introduced translation limits to the graph view. --- client/app/scripts/charts/nodes-chart.js | 7 +- .../app/scripts/components/nodes-resources.js | 8 +- .../components/time-travel-timeline.js | 2 +- .../app/scripts/components/zoomable-canvas.js | 79 +++++++++++++------ client/app/scripts/constants/naming.js | 3 + client/app/scripts/constants/styles.js | 2 +- .../app/scripts/selectors/graph-view/zoom.js | 24 ++++-- .../scripts/selectors/resource-view/zoom.js | 10 +-- client/app/scripts/utils/zoom-utils.js | 2 +- 9 files changed, 96 insertions(+), 41 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index fcbc60a8d..59ffb2839 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -6,10 +6,12 @@ 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'; @@ -58,7 +60,8 @@ class NodesChart extends React.Component {
{transform => this.renderContent(transform)} diff --git a/client/app/scripts/components/nodes-resources.js b/client/app/scripts/components/nodes-resources.js index 28d91d182..8912d29da 100644 --- a/client/app/scripts/components/nodes-resources.js +++ b/client/app/scripts/components/nodes-resources.js @@ -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) { @@ -39,9 +41,9 @@ class NodesResources extends React.Component { return (
{transform => this.renderLayers(transform)} diff --git a/client/app/scripts/components/time-travel-timeline.js b/client/app/scripts/components/time-travel-timeline.js index 71fd32229..fc427fbb4 100644 --- a/client/app/scripts/components/time-travel-timeline.js +++ b/client/app/scripts/components/time-travel-timeline.js @@ -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; diff --git a/client/app/scripts/components/zoomable-canvas.js b/client/app/scripts/components/zoomable-canvas.js index 07744ce58..6931db78c 100644 --- a/client/app/scripts/components/zoomable-canvas.js +++ b/client/app/scripts/components/zoomable-canvas.js @@ -20,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 { @@ -28,10 +29,10 @@ class ZoomableCanvas extends React.Component { this.state = { isPanning: false, - minTranslateX: 0, - maxTranslateX: 0, - minTranslateY: 0, - maxTranslateY: 0, + contentMinX: 0, + contentMaxX: 0, + contentMinY: 0, + contentMaxY: 0, translateX: 0, translateY: 0, minScale: 1, @@ -86,13 +87,14 @@ class ZoomableCanvas extends React.Component { } handleZoomControlAction(scale) { - // Update the canvas scale (not touching the translation). + // 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, }; - this.zoomAtPosition(centerOfCanvas, scale / this.state.scaleX); + // Zoom factor diff is obtained by dividing the new zoom scale with the old one. + this.zoomAtPositionByFactor(centerOfCanvas, scale / this.state.scaleX); } render() { @@ -131,7 +133,7 @@ class ZoomableCanvas extends React.Component { } updateZoomLimits(props) { - this.setState(props.layoutZoomLimits.toJS()); + this.setState(props.layoutLimits.toJS()); } // Restore the zooming settings @@ -146,8 +148,8 @@ 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; } @@ -161,6 +163,7 @@ class ZoomableCanvas extends React.Component { 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, @@ -170,40 +173,70 @@ class ZoomableCanvas extends React.Component { 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.zoomAtPosition(mousePosition, 1 / zoomFactor(ev)); + this.zoomAtPositionByFactor(mousePosition, zoomFactor(ev)); } } clampedTranslation(state) { - const { width, height, canvasMargins, bounded, layoutZoomLimits } = this.props; - const { maxTranslateX, minTranslateX, maxTranslateY, minTranslateY } = layoutZoomLimits.toJS(); + const { width, height, canvasMargins, boundContent, layoutLimits } = this.props; + const { contentMinX, contentMaxX, contentMinY, contentMaxY } = layoutLimits.toJS(); - if (bounded) { - const contentMinPoint = applyTransform(state, { x: minTranslateX, y: minTranslateY }); - const contentMaxPoint = applyTransform(state, { x: maxTranslateX, y: maxTranslateY }); - const viewportMinPoint = { x: canvasMargins.left, y: canvasMargins.top }; - const viewportMaxPoint = { x: canvasMargins.left + width, y: canvasMargins.top + height }; + 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 }); - state.translateX += Math.max(0, viewportMaxPoint.x - contentMaxPoint.x); - state.translateX += Math.min(0, viewportMinPoint.x - contentMinPoint.x); - state.translateY += Math.max(0, viewportMaxPoint.y - contentMaxPoint.y); - state.translateY += Math.min(0, viewportMinPoint.y - contentMinPoint.y); + 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; } - zoomAtPosition(position, factor) { + 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), @@ -226,7 +259,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'), }; diff --git a/client/app/scripts/constants/naming.js b/client/app/scripts/constants/naming.js index 58688d5c4..10448be8e 100644 --- a/client/app/scripts/constants/naming.js +++ b/client/app/scripts/constants/naming.js @@ -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'; diff --git a/client/app/scripts/constants/styles.js b/client/app/scripts/constants/styles.js index a72ccd065..9d312ac9c 100644 --- a/client/app/scripts/constants/styles.js +++ b/client/app/scripts/constants/styles.js @@ -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 }, }; diff --git a/client/app/scripts/selectors/graph-view/zoom.js b/client/app/scripts/selectors/graph-view/zoom.js index 8f27d3d27..c9fcee219 100644 --- a/client/app/scripts/selectors/graph-view/zoom.js +++ b/client/app/scripts/selectors/graph-view/zoom.js @@ -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 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( diff --git a/client/app/scripts/selectors/resource-view/zoom.js b/client/app/scripts/selectors/resource-view/zoom.js index 39b43d55d..41c1bb8e4 100644 --- a/client/app/scripts/selectors/resource-view/zoom.js +++ b/client/app/scripts/selectors/resource-view/zoom.js @@ -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, }); } ); diff --git a/client/app/scripts/utils/zoom-utils.js b/client/app/scripts/utils/zoom-utils.js index 043d59a65..1b58a99ae 100644 --- a/client/app/scripts/utils/zoom-utils.js +++ b/client/app/scripts/utils/zoom-utils.js @@ -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) { From 065c2b11d692eafed9c2a52a003b540a1e2fbb34 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Wed, 23 Aug 2017 16:41:17 +0200 Subject: [PATCH 5/5] Addressed small @foot's comments. --- client/app/scripts/components/zoomable-canvas.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/app/scripts/components/zoomable-canvas.js b/client/app/scripts/components/zoomable-canvas.js index 6931db78c..2616c966f 100644 --- a/client/app/scripts/components/zoomable-canvas.js +++ b/client/app/scripts/components/zoomable-canvas.js @@ -162,7 +162,7 @@ class ZoomableCanvas extends React.Component { } handlePan() { - let state = { ...this.state }; + let state = this.state; // Apply the translation respecting the boundaries. state = this.clampedTranslation({ ...state, translateX: this.state.translateX + d3Event.dx, @@ -181,6 +181,7 @@ class ZoomableCanvas extends React.Component { }; this.zoomAtPositionByFactor(mousePosition, zoomFactor(ev)); } + ev.preventDefault(); } clampedTranslation(state) {