Resolver nonlinear zoom (#54936)

This commit is contained in:
Davis Plumlee 2020-01-15 12:17:17 -07:00 committed by GitHub
parent ed3c8991db
commit cab5925c59
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 99 additions and 73 deletions

View file

@ -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

View file

@ -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>;
@ -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

View file

@ -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>;
@ -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

View file

@ -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),
};
/**

View file

@ -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);
};

View file

@ -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;

View file

@ -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.

View file

@ -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.)

View file

@ -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,21 +44,7 @@ describe('zooming', () => {
);
describe('when the user has scaled in to 2x', () => {
beforeEach(() => {
userScaled(store, [2, 2]);
});
it(
...cameraShouldBeBoundBy({
minimum: [-75, -50],
maximum: [75, 50],
})
);
});
describe('when the user zooms in by 1 zoom unit', () => {
beforeEach(() => {
const action: CameraAction = {
type: 'userZoomed',
payload: 1,
};
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) };
store.dispatch(action);
});
it(
@ -67,6 +54,30 @@ describe('zooming', () => {
})
);
});
describe('when the user zooms in all the way', () => {
beforeEach(() => {
const action: CameraAction = {
type: 'userZoomed',
payload: 1,
};
store.dispatch(action);
});
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())), [
50,
@ -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(

View file

@ -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.

View file

@ -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,
});
}
},