Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Endpoint] Resolver nonlinear zoom #54936

Merged
merged 3 commits into from
Jan 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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