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