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) {