From a2d83963ce5785dcf6a76cce3e148545f4538b74 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Wed, 15 Jan 2020 12:17:17 -0700 Subject: [PATCH] Resolver nonlinear zoom (#54936) --- .../resolver/store/camera/action.ts | 13 ++++---- .../camera/inverse_projection_matrix.test.ts | 5 +-- .../store/camera/projection_matrix.test.ts | 5 +-- .../resolver/store/camera/reducer.ts | 30 ++++------------- .../resolver/store/camera/scale_to_zoom.ts | 15 +++++++++ .../store/camera/scaling_constants.ts | 20 ++++++++++++ .../resolver/store/camera/selectors.ts | 15 ++++++--- .../resolver/store/camera/test_helpers.ts | 14 +------- .../resolver/store/camera/zooming.test.ts | 32 +++++++++++++------ .../public/embeddables/resolver/types.ts | 4 +-- .../embeddables/resolver/view/index.tsx | 5 +-- 11 files changed, 92 insertions(+), 66 deletions(-) create mode 100644 x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/scale_to_zoom.ts create mode 100644 x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/scaling_constants.ts diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/action.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/action.ts index b21b79e84f741..090d5de901318 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/action.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/action.ts @@ -6,18 +6,19 @@ import { Vector2 } from '../../types'; -interface UserScaled { - readonly type: 'userScaled'; +interface UserSetZoomLevel { + readonly type: 'userSetZoomLevel'; /** - * A vector who's `x` and `y` component will be the new scaling factors for the projection. + * A number whose value is always between 0 and 1 and will be the new scaling factor for the projection. */ - readonly payload: Vector2; + readonly payload: number; } interface UserZoomed { readonly type: 'userZoomed'; /** - * A value to zoom in by. Should be a fraction of `1`. For a `'wheel'` event when `event.deltaMode` is `'pixel'`, pass `event.deltaY / -renderHeight` where `renderHeight` is the height of the Resolver element in pixels. + * A value to zoom in by. Should be a fraction of `1`. For a `'wheel'` event when `event.deltaMode` is `'pixel'`, + * pass `event.deltaY / -renderHeight` where `renderHeight` is the height of the Resolver element in pixels. */ payload: number; } @@ -65,7 +66,7 @@ interface UserMovedPointer { } export type CameraAction = - | UserScaled + | UserSetZoomLevel | UserSetRasterSize | UserSetPositionOfCamera | UserStartedPanning diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/inverse_projection_matrix.test.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/inverse_projection_matrix.test.ts index 3d555b63d8392..41e3bc025f557 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/inverse_projection_matrix.test.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/inverse_projection_matrix.test.ts @@ -10,6 +10,7 @@ import { CameraState } from '../../types'; import { cameraReducer } from './reducer'; import { inverseProjectionMatrix } from './selectors'; import { applyMatrix3 } from '../../lib/vector2'; +import { scaleToZoom } from './scale_to_zoom'; describe('inverseProjectionMatrix', () => { let store: Store; @@ -59,7 +60,7 @@ describe('inverseProjectionMatrix', () => { }); describe('when the user has zoomed to 0.5', () => { beforeEach(() => { - const action: CameraAction = { type: 'userScaled', payload: [0.5, 0.5] }; + const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(0.5) }; store.dispatch(action); }); it('should convert 150, 100 (center) to 0, 0 (center) in world space', () => { @@ -89,7 +90,7 @@ describe('inverseProjectionMatrix', () => { describe('when the user has scaled to 2', () => { // the viewport will only cover half, or 150x100 instead of 300x200 beforeEach(() => { - const action: CameraAction = { type: 'userScaled', payload: [2, 2] }; + const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) }; store.dispatch(action); }); // we expect the viewport to be diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/projection_matrix.test.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/projection_matrix.test.ts index 025c436a957e8..e21e3d1001794 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/projection_matrix.test.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/projection_matrix.test.ts @@ -10,6 +10,7 @@ import { CameraState } from '../../types'; import { cameraReducer } from './reducer'; import { projectionMatrix } from './selectors'; import { applyMatrix3 } from '../../lib/vector2'; +import { scaleToZoom } from './scale_to_zoom'; describe('projectionMatrix', () => { let store: Store; @@ -56,7 +57,7 @@ describe('projectionMatrix', () => { }); describe('when the user has zoomed to 0.5', () => { beforeEach(() => { - const action: CameraAction = { type: 'userScaled', payload: [0.5, 0.5] }; + const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(0.5) }; store.dispatch(action); }); it('should convert 0, 0 (center) in world space to 150, 100 (center)', () => { @@ -92,7 +93,7 @@ describe('projectionMatrix', () => { describe('when the user has scaled to 2', () => { // the viewport will only cover half, or 150x100 instead of 300x200 beforeEach(() => { - const action: CameraAction = { type: 'userScaled', payload: [2, 2] }; + const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) }; store.dispatch(action); }); // we expect the viewport to be diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/reducer.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/reducer.ts index 457d3904804f2..b7229240684f1 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/reducer.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/reducer.ts @@ -10,52 +10,34 @@ import { userIsPanning, translation, projectionMatrix, inverseProjectionMatrix } import { clamp } from '../../lib/math'; import { CameraState, ResolverAction } from '../../types'; +import { scaleToZoom } from './scale_to_zoom'; function initialState(): CameraState { return { - scaling: [1, 1] as const, + scalingFactor: scaleToZoom(1), // Defaulted to 1 to 1 scale rasterSize: [0, 0] as const, translationNotCountingCurrentPanning: [0, 0] as const, latestFocusedWorldCoordinates: null, }; } -/** - * The minimum allowed value for the camera scale. This is the least scale that we will ever render something at. - */ -const minimumScale = 0.1; - -/** - * The maximum allowed value for the camera scale. This is greatest scale that we will ever render something at. - */ -const maximumScale = 6; - export const cameraReducer: Reducer = ( state = initialState(), action ) => { - if (action.type === 'userScaled') { + if (action.type === 'userSetZoomLevel') { /** * Handle the scale being explicitly set, for example by a 'reset zoom' feature, or by a range slider with exact scale values */ - const [deltaX, deltaY] = action.payload; + return { ...state, - scaling: [ - clamp(deltaX, minimumScale, maximumScale), - clamp(deltaY, minimumScale, maximumScale), - ], + scalingFactor: clamp(action.payload, 0, 1), }; } else if (action.type === 'userZoomed') { - /** - * When the user zooms we change the scale. Limit the change in scale so that we aren't liable for supporting crazy values (e.g. infinity or negative scale.) - */ - const newScaleX = clamp(state.scaling[0] + action.payload, minimumScale, maximumScale); - const newScaleY = clamp(state.scaling[1] + action.payload, minimumScale, maximumScale); - const stateWithNewScaling: CameraState = { ...state, - scaling: [newScaleX, newScaleY], + scalingFactor: clamp(state.scalingFactor + action.payload, 0, 1), }; /** diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/scale_to_zoom.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/scale_to_zoom.ts new file mode 100644 index 0000000000000..534e20e9ed3c4 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/scale_to_zoom.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { maximum, minimum, zoomCurveRate } from './scaling_constants'; + +/** + * Calculates the zoom factor (between 0 and 1) for a given scale value. + */ +export const scaleToZoom = (scale: number): number => { + const delta = maximum - minimum; + return Math.pow((scale - minimum) / delta, 1 / zoomCurveRate); +}; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/scaling_constants.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/scaling_constants.ts new file mode 100644 index 0000000000000..93c41fde64f0e --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/scaling_constants.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * The minimum allowed value for the camera scale. This is the least scale that we will ever render something at. + */ +export const minimum = 0.1; + +/** + * The maximum allowed value for the camera scale. This is greatest scale that we will ever render something at. + */ +export const maximum = 6; + +/** + * The curve of the zoom function growth rate. The higher the scale factor is, the higher the zoom rate will be. + */ +export const zoomCurveRate = 4; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/selectors.ts index e0d2062bfc870..a7b0bbf66052d 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/selectors.ts @@ -13,6 +13,7 @@ import { orthographicProjection, translationTransformation, } from '../../lib/transformation'; +import { maximum, minimum, zoomCurveRate } from './scaling_constants'; interface ClippingPlanes { renderWidth: number; @@ -43,8 +44,8 @@ export function viewableBoundingBox(state: CameraState): AABB { function clippingPlanes(state: CameraState): ClippingPlanes { const renderWidth = state.rasterSize[0]; const renderHeight = state.rasterSize[1]; - const clippingPlaneRight = renderWidth / 2 / state.scaling[0]; - const clippingPlaneTop = renderHeight / 2 / state.scaling[1]; + const clippingPlaneRight = renderWidth / 2 / scale(state)[0]; + const clippingPlaneTop = renderHeight / 2 / scale(state)[1]; return { renderWidth, @@ -112,9 +113,9 @@ export function translation(state: CameraState): Vector2 { return add( state.translationNotCountingCurrentPanning, divide(subtract(state.panning.currentOffset, state.panning.origin), [ - state.scaling[0], + scale(state)[0], // Invert `y` since the `.panning` vectors are in screen coordinates and therefore have backwards `y` - -state.scaling[1], + -scale(state)[1], ]) ); } else { @@ -175,7 +176,11 @@ export const inverseProjectionMatrix: (state: CameraState) => Matrix3 = state => /** * The scale by which world values are scaled when rendered. */ -export const scale = (state: CameraState): Vector2 => state.scaling; +export const scale = (state: CameraState): Vector2 => { + const delta = maximum - minimum; + const value = Math.pow(state.scalingFactor, zoomCurveRate) * delta + minimum; + return [value, value]; +}; /** * Whether or not the user is current panning the map. diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/test_helpers.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/test_helpers.ts index fd446c42116a4..25e0ec642086f 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/test_helpers.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/test_helpers.ts @@ -4,19 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Store } from 'redux'; -import { CameraAction } from './action'; -import { CameraState, Vector2 } from '../../types'; - -type CameraStore = Store; - -/** - * Dispatches a 'userScaled' action. - */ -export function userScaled(store: CameraStore, scalingValue: [number, number]): void { - const action: CameraAction = { type: 'userScaled', payload: scalingValue }; - store.dispatch(action); -} +import { Vector2 } from '../../types'; /** * Used to assert that two Vector2s are close to each other (accounting for round-off errors.) diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/zooming.test.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/zooming.test.ts index a04ca8376c9b1..4b0915282e86f 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/zooming.test.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/zooming.test.ts @@ -9,7 +9,8 @@ import { cameraReducer } from './reducer'; import { createStore, Store } from 'redux'; import { CameraState, AABB } from '../../types'; import { viewableBoundingBox, inverseProjectionMatrix } from './selectors'; -import { userScaled, expectVectorsToBeClose } from './test_helpers'; +import { expectVectorsToBeClose } from './test_helpers'; +import { scaleToZoom } from './scale_to_zoom'; import { applyMatrix3 } from '../../lib/vector2'; describe('zooming', () => { @@ -43,7 +44,8 @@ describe('zooming', () => { ); describe('when the user has scaled in to 2x', () => { beforeEach(() => { - userScaled(store, [2, 2]); + const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) }; + store.dispatch(action); }); it( ...cameraShouldBeBoundBy({ @@ -52,7 +54,7 @@ describe('zooming', () => { }) ); }); - describe('when the user zooms in by 1 zoom unit', () => { + describe('when the user zooms in all the way', () => { beforeEach(() => { const action: CameraAction = { type: 'userZoomed', @@ -60,12 +62,21 @@ describe('zooming', () => { }; store.dispatch(action); }); - it( - ...cameraShouldBeBoundBy({ - minimum: [-75, -50], - maximum: [75, 50], - }) - ); + it('should zoom to maximum scale factor', () => { + const actual = viewableBoundingBox(store.getState()); + expect(actual).toMatchInlineSnapshot(` + Object { + "maximum": Array [ + 25.000000000000007, + 16.666666666666668, + ], + "minimum": Array [ + -25, + -16.666666666666668, + ], + } + `); + }); }); it('the raster position 200, 50 should map to the world position 50, 50', () => { expectVectorsToBeClose(applyMatrix3([200, 50], inverseProjectionMatrix(store.getState())), [ @@ -126,7 +137,8 @@ describe('zooming', () => { }); describe('when the user scales to 2x', () => { beforeEach(() => { - userScaled(store, [2, 2]); + const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) }; + store.dispatch(action); }); it('should be centered on 100, 0', () => { const worldCenterPoint = applyMatrix3( diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts index ed7eb79d621fc..eae9ebf9ee9a6 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts @@ -43,9 +43,9 @@ export interface CameraState { readonly panning?: PanningState; /** - * Scales the coordinate system, used for zooming. + * Scales the coordinate system, used for zooming. Should always be between 0 and 1 */ - readonly scaling: Vector2; + readonly scalingFactor: number; /** * The size (in pixels) of the Resolver component. diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx index 2c5c60440522d..c439e3318d3c9 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx @@ -95,7 +95,6 @@ const Resolver = styled( const handleWheel = useCallback( (event: WheelEvent) => { - // we use elementBoundingClientRect to interpret pixel deltas as a fraction of the element's height if ( elementBoundingClientRect !== null && event.ctrlKey && @@ -105,7 +104,9 @@ const Resolver = styled( event.preventDefault(); dispatch({ type: 'userZoomed', - payload: (-2 * event.deltaY) / elementBoundingClientRect.height, + // we use elementBoundingClientRect to interpret pixel deltas as a fraction of the element's height + // when pinch-zooming in on a mac, deltaY is a negative number but we want the payload to be positive + payload: event.deltaY / -elementBoundingClientRect.height, }); } },