Skip to content

Commit

Permalink
Resolver nonlinear zoom (elastic#54936)
Browse files Browse the repository at this point in the history
  • Loading branch information
dplumlee authored and jkelastic committed Jan 17, 2020
1 parent 1a83836 commit a2d8396
Show file tree
Hide file tree
Showing 11 changed files with 92 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -65,7 +66,7 @@ interface UserMovedPointer {
}

export type CameraAction =
| UserScaled
| UserSetZoomLevel
| UserSetRasterSize
| UserSetPositionOfCamera
| UserStartedPanning
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CameraState, CameraAction>;
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CameraState, CameraAction>;
Expand Down Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CameraState, ResolverAction> = (
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),
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
};
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
orthographicProjection,
translationTransformation,
} from '../../lib/transformation';
import { maximum, minimum, zoomCurveRate } from './scaling_constants';

interface ClippingPlanes {
renderWidth: number;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CameraState, CameraAction>;

/**
* 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.)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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({
Expand All @@ -52,20 +54,29 @@ 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',
payload: 1,
};
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())), [
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions x-pack/plugins/endpoint/public/embeddables/resolver/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -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,
});
}
},
Expand Down

0 comments on commit a2d8396

Please sign in to comment.