mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
Resolver nonlinear zoom (#54936)
This commit is contained in:
parent
ed3c8991db
commit
cab5925c59
11 changed files with 99 additions and 73 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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;
|
|
@ -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.
|
||||
|
|
|
@ -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.)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue