diff --git a/x-pack/plugins/endpoint/package.json b/x-pack/plugins/endpoint/package.json index 8efd0eab0eee0..25afe2c8442ba 100644 --- a/x-pack/plugins/endpoint/package.json +++ b/x-pack/plugins/endpoint/package.json @@ -9,6 +9,7 @@ "react-redux": "^7.1.0" }, "devDependencies": { - "@types/react-redux": "^7.1.0" + "@types/react-redux": "^7.1.0", + "redux-devtools-extension": "^2.13.8" } } diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/actions.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/actions.ts deleted file mode 100644 index c7f790588a739..0000000000000 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/actions.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * 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 { CameraAction } from './store/camera'; -import { DataAction } from './store/data'; - -export type ResolverAction = CameraAction | DataAction; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/documentation/camera.md b/x-pack/plugins/endpoint/public/embeddables/resolver/documentation/camera.md new file mode 100644 index 0000000000000..aeca76fad916f --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/documentation/camera.md @@ -0,0 +1,26 @@ +# Introduction + +Resolver renders a map in a DOM element. Items on the map are placed in 2 dimensions using arbitrary units. Like other mapping software, the map can show things at different scales. The 'camera' determines what is shown on the map. + +The camera is positioned. When the user clicks-and-drags the map, the camera's position is changed. This allows the user to pan around the map and see things that would otherwise be out of view, at a given scale. + +The camera determines the scale. If the scale is smaller, the viewport of the map is larger and more is visible. This allows the user to zoom in an out. On screen controls and gestures (trackpad-pinch, or CTRL-mousewheel) change the scale. + +# Concepts + +## Scaling +The camera scale is controlled both by the user and programatically by Resolver. There is a maximum and minimum scale value (at the time of this writing they are 0.5 and 6.) This means that the map, and things on the map, will be rendered at between 0.5 and 6 times their instrinsic dimensions. + +A range control is provided so that the user can change the scale. The user can also pinch-to-zoom on Mac OS X (or use ctrl-mousewheel otherwise) to change the scale. These interactions change the `scalingFactor`. This number is between 0 and 1. It represents how zoomed-in things should be. When the `scalingFactor` is 1, the scale will be the maximum scale value. When `scalingFactor` is 0, the scale will be the minimum scale value. Otherwise we interpolate between the minimum and maximum scale factor. The rate that the scale increases between the two is controlled by `scalingFactor**zoomCurveRate` The zoom curve rate is 4 at the time of this writing. This makes it so that the change in scale is more pronounced when the user is zoomed in. + +``` +renderScale = minimumScale * (1 - scalingFactor**curveRate) + maximumScale * scalingFactor**curveRate; +``` + +## Panning +When the user clicks and drags the map, the camera is 'moved' around. This allows the user to see different things on the map. The on-screen controls provide 4 directional buttons which nudge the camera, as well as a reset button. The reset button brings the camera back where it started (0, 0). + +Resolver may programatically change the position of the camera in order to bring some interesting elements into view. + +## Animation +The camera can animate changes to its position. Animations usually have a short, fixed duration, such as 1 second. If the camera is moving a great deal during the animation, then things could end up moving across the screen too quickly. In this case, looking at Resolver might be disorienting. In order to combat this, Resolver may temporarily decrease the scale. By decreasing the scale, objects look futher away. Far away objects appear to move slower. diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/embeddable.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/embeddable.tsx index 9539162f9cfb6..6680ba615e353 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/embeddable.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/embeddable.tsx @@ -6,7 +6,8 @@ import ReactDOM from 'react-dom'; import React from 'react'; -import { AppRoot } from './view'; +import { Provider } from 'react-redux'; +import { Resolver } from './view'; import { storeFactory } from './store'; import { Embeddable } from '../../../../../../src/plugins/embeddable/public'; @@ -20,7 +21,12 @@ export class ResolverEmbeddable extends Embeddable { } this.lastRenderTarget = node; const { store } = storeFactory(); - ReactDOM.render(, node); + ReactDOM.render( + + + , + node + ); } public reload(): void { diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/lib/math.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/lib/math.ts index c59db31c39e82..6bf0fedc84dfe 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/lib/math.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/lib/math.ts @@ -10,3 +10,10 @@ export function clamp(value: number, minimum: number, maximum: number) { return Math.max(Math.min(value, maximum), minimum); } + +/** + * linearly interpolate between `a` and `b` at a ratio of `ratio`. If `ratio` is `0`, return `a`, if ratio is `1`, return `b`. + */ +export function lerp(a: number, b: number, ratio: number): number { + return a * (1 - ratio) + b * ratio; +} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/lib/transformation.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/lib/transformation.ts index 3084ce0eacdb4..bd7d1bf743df8 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/lib/transformation.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/lib/transformation.ts @@ -15,11 +15,32 @@ export function inverseOrthographicProjection( bottom: number, left: number ): Matrix3 { - const m11 = (right - left) / 2; - const m13 = (right + left) / (right - left); + let m11: number; + let m13: number; + let m22: number; + let m23: number; - const m22 = (top - bottom) / 2; - const m23 = (top + bottom) / (top - bottom); + /** + * If `right - left` is 0, the width is 0, so scale everything to 0 + */ + if (right - left === 0) { + m11 = 0; + m13 = 0; + } else { + m11 = (right - left) / 2; + m13 = (right + left) / (right - left); + } + + /** + * If `top - bottom` is 0, the height is 0, so scale everything to 0 + */ + if (top - bottom === 0) { + m22 = 0; + m23 = 0; + } else { + m22 = (top - bottom) / 2; + m23 = (top + bottom) / (top - bottom); + } return [m11, 0, m13, 0, m22, m23, 0, 0, 0]; } @@ -37,11 +58,32 @@ export function orthographicProjection( bottom: number, left: number ): Matrix3 { - const m11 = 2 / (right - left); // adjust x scale to match ndc (-1, 1) bounds - const m13 = -((right + left) / (right - left)); + let m11: number; + let m13: number; + let m22: number; + let m23: number; + + /** + * If `right - left` is 0, the width is 0, so scale everything to 0 + */ + if (right - left === 0) { + m11 = 0; + m13 = 0; + } else { + m11 = 2 / (right - left); // adjust x scale to match ndc (-1, 1) bounds + m13 = -((right + left) / (right - left)); + } - const m22 = 2 / (top - bottom); // adjust y scale to match ndc (-1, 1) bounds - const m23 = -((top + bottom) / (top - bottom)); + /** + * If `top - bottom` is 0, the height is 0, so scale everything to 0 + */ + if (top - bottom === 0) { + m22 = 0; + m23 = 0; + } else { + m22 = top - bottom === 0 ? 0 : 2 / (top - bottom); // adjust y scale to match ndc (-1, 1) bounds + m23 = top - bottom === 0 ? 0 : -((top + bottom) / (top - bottom)); + } return [m11, 0, m13, 0, m22, m23, 0, 0, 0]; } @@ -68,6 +110,6 @@ export function translationTransformation([x, y]: Vector2): Matrix3 { return [ 1, 0, x, 0, 1, y, - 0, 0, 1 + 0, 0, 0 ] } diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/lib/vector2.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/lib/vector2.ts index 3c0681413305e..898ce6f6bacd2 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/lib/vector2.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/lib/vector2.ts @@ -26,6 +26,13 @@ export function divide(a: Vector2, b: Vector2): Vector2 { return [a[0] / b[0], a[1] / b[1]]; } +/** + * Return `[ a[0] * b[0], a[1] * b[1] ]` + */ +export function multiply(a: Vector2, b: Vector2): Vector2 { + return [a[0] * b[0], a[1] * b[1]]; +} + /** * Returns a vector which is the result of applying a 2D transformation matrix to the provided vector. */ @@ -50,3 +57,33 @@ export function angle(a: Vector2, b: Vector2) { const deltaY = b[1] - a[1]; return Math.atan2(deltaY, deltaX); } + +/** + * Clamp `vector`'s components. + */ +export function clamp([x, y]: Vector2, [minX, minY]: Vector2, [maxX, maxY]: Vector2): Vector2 { + return [Math.max(minX, Math.min(maxX, x)), Math.max(minY, Math.min(maxY, y))]; +} + +/** + * Scale vector by number + */ +export function scale(a: Vector2, n: number): Vector2 { + return [a[0] * n, a[1] * n]; +} + +/** + * Linearly interpolate between `a` and `b`. + * `t` represents progress and: + * 0 <= `t` <= 1 + */ +export function lerp(a: Vector2, b: Vector2, t: number): Vector2 { + return add(scale(a, 1 - t), scale(b, t)); +} + +/** + * The length of the vector + */ +export function length([x, y]: Vector2): number { + return Math.sqrt(x * x + y * y); +} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event_test_helpers.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event_test_helpers.ts index 67acdbd253f65..9a6f19adcc101 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event_test_helpers.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event_test_helpers.ts @@ -25,6 +25,7 @@ export function mockProcessEvent( machine_id: '', ...parts, data_buffer: { + timestamp_utc: '2019-09-24 01:47:47Z', event_subtype_full: 'creation_event', event_type_full: 'process_event', process_name: '', diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts new file mode 100644 index 0000000000000..25f196c76a290 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts @@ -0,0 +1,27 @@ +/* + * 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 { ProcessEvent } from '../types'; +import { CameraAction } from './camera'; +import { DataAction } from './data'; + +/** + * When the user wants to bring a process node front-and-center on the map. + */ +interface UserBroughtProcessIntoView { + readonly type: 'userBroughtProcessIntoView'; + readonly payload: { + /** + * Used to identify the process node that should be brought into view. + */ + readonly process: ProcessEvent; + /** + * The time (since epoch in milliseconds) when the action was dispatched. + */ + readonly time: number; + }; +} + +export type ResolverAction = CameraAction | DataAction | UserBroughtProcessIntoView; 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 7d3e64ab34f23..dcc6c2c9c9411 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 @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Vector2, PanDirection } from '../../types'; +import { Vector2 } from '../../types'; + +interface TimestampedPayload { + /** + * Time (since epoch in milliseconds) when this action was dispatched. + */ + readonly time: number; +} interface UserSetZoomLevel { readonly type: 'userSetZoomLevel'; @@ -24,11 +31,13 @@ interface UserClickedZoomIn { 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. - */ - readonly payload: number; + readonly payload: { + /** + * 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. + */ + readonly zoomChange: number; + } & TimestampedPayload; } interface UserSetRasterSize { @@ -40,7 +49,7 @@ interface UserSetRasterSize { } /** - * This is currently only used in tests. The 'back to center' button will use this action, and more tests around its behavior will need to be added. + * When the user warps the camera to an exact point instantly. */ interface UserSetPositionOfCamera { readonly type: 'userSetPositionOfCamera'; @@ -52,33 +61,45 @@ interface UserSetPositionOfCamera { interface UserStartedPanning { readonly type: 'userStartedPanning'; - /** - * A vector in screen coordinates (each unit is a pixel and the Y axis increases towards the bottom of the screen) - * relative to the Resolver component. - * Represents a starting position during panning for a pointing device. - */ - readonly payload: Vector2; + + readonly payload: { + /** + * A vector in screen coordinates (each unit is a pixel and the Y axis increases towards the bottom of the screen) + * relative to the Resolver component. + * Represents a starting position during panning for a pointing device. + */ + readonly screenCoordinates: Vector2; + } & TimestampedPayload; } interface UserStoppedPanning { readonly type: 'userStoppedPanning'; + + readonly payload: TimestampedPayload; } -interface UserClickedPanControl { - readonly type: 'userClickedPanControl'; +interface UserNudgedCamera { + readonly type: 'userNudgedCamera'; /** * String that represents the direction in which Resolver can be panned */ - readonly payload: PanDirection; + readonly payload: { + /** + * A cardinal direction to move the users perspective in. + */ + readonly direction: Vector2; + } & TimestampedPayload; } interface UserMovedPointer { readonly type: 'userMovedPointer'; - /** - * A vector in screen coordinates relative to the Resolver component. - * The payload should be contain clientX and clientY minus the client position of the Resolver component. - */ - readonly payload: Vector2; + readonly payload: { + /** + * A vector in screen coordinates relative to the Resolver component. + * The payload should be contain clientX and clientY minus the client position of the Resolver component. + */ + screenCoordinates: Vector2; + } & TimestampedPayload; } export type CameraAction = @@ -91,4 +112,4 @@ export type CameraAction = | UserMovedPointer | UserClickedZoomOut | UserClickedZoomIn - | UserClickedPanControl; + | UserNudgedCamera; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/animation.test.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/animation.test.ts new file mode 100644 index 0000000000000..795344d8af092 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/animation.test.ts @@ -0,0 +1,193 @@ +/* + * 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 { createStore, Store, Reducer } from 'redux'; +import { cameraReducer, cameraInitialState } from './reducer'; +import { CameraState, Vector2, ResolverAction } from '../../types'; +import * as selectors from './selectors'; +import { animatePanning } from './methods'; +import { lerp } from '../../lib/math'; + +type TestAction = + | ResolverAction + | { + readonly type: 'animatePanning'; + readonly payload: { + /** + * The start time of the animation. + */ + readonly time: number; + /** + * The duration of the animation. + */ + readonly duration: number; + /** + * The target translation the camera will animate towards. + */ + readonly targetTranslation: Vector2; + }; + }; + +describe('when the camera is created', () => { + let store: Store; + beforeEach(() => { + const testReducer: Reducer = ( + state = cameraInitialState(), + action + ): CameraState => { + // If the test action is fired, call the animatePanning method + if (action.type === 'animatePanning') { + const { + payload: { time, targetTranslation, duration }, + } = action; + return animatePanning(state, time, targetTranslation, duration); + } + return cameraReducer(state, action); + }; + store = createStore(testReducer); + }); + it('should be at 0,0', () => { + expect(selectors.translation(store.getState())(0)).toEqual([0, 0]); + }); + it('should have scale of [1,1]', () => { + expect(selectors.scale(store.getState())(0)).toEqual([1, 1]); + }); + describe('when animation begins', () => { + const duration = 1000; + let targetTranslation: Vector2; + const startTime = 0; + beforeEach(() => { + // The distance the camera moves must be nontrivial in order to trigger a scale animation + targetTranslation = [1000, 1000]; + const action: TestAction = { + type: 'animatePanning', + payload: { + time: startTime, + duration, + targetTranslation, + }, + }; + store.dispatch(action); + }); + describe('when the animation is in progress', () => { + let translationAtIntervals: Vector2[]; + let scaleAtIntervals: Vector2[]; + beforeEach(() => { + translationAtIntervals = []; + scaleAtIntervals = []; + const state = store.getState(); + for (let progress = 0; progress <= 1; progress += 0.1) { + translationAtIntervals.push( + selectors.translation(state)(lerp(startTime, startTime + duration, progress)) + ); + scaleAtIntervals.push( + selectors.scale(state)(lerp(startTime, startTime + duration, progress)) + ); + } + }); + it('should gradually translate to the target', () => { + expect(translationAtIntervals).toMatchInlineSnapshot(` + Array [ + Array [ + 0, + 0, + ], + Array [ + 4.000000000000001, + 4.000000000000001, + ], + Array [ + 32.00000000000001, + 32.00000000000001, + ], + Array [ + 108.00000000000004, + 108.00000000000004, + ], + Array [ + 256.00000000000006, + 256.00000000000006, + ], + Array [ + 500, + 500, + ], + Array [ + 744, + 744, + ], + Array [ + 891.9999999999999, + 891.9999999999999, + ], + Array [ + 968, + 968, + ], + Array [ + 996, + 996, + ], + Array [ + 1000, + 1000, + ], + ] + `); + }); + it('should gradually zoom in and out to the target', () => { + expect(scaleAtIntervals).toMatchInlineSnapshot(` + Array [ + Array [ + 1, + 1, + ], + Array [ + 0.9873589660765236, + 0.9873589660765236, + ], + Array [ + 0.8988717286121894, + 0.8988717286121894, + ], + Array [ + 0.7060959612791753, + 0.7060959612791753, + ], + Array [ + 0.6176087238148411, + 0.6176087238148411, + ], + Array [ + 0.6049676898913647, + 0.6049676898913647, + ], + Array [ + 0.6176087238148411, + 0.6176087238148411, + ], + Array [ + 0.7060959612791753, + 0.7060959612791753, + ], + Array [ + 0.8988717286121893, + 0.8988717286121893, + ], + Array [ + 0.9873589660765237, + 0.9873589660765237, + ], + Array [ + 1, + 1, + ], + ] + `); + }); + }); + }); +}); 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 41e3bc025f557..000dbb8d52841 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 @@ -18,14 +18,27 @@ describe('inverseProjectionMatrix', () => { beforeEach(() => { store = createStore(cameraReducer, undefined); compare = (rasterPosition: [number, number], expectedWorldPosition: [number, number]) => { + // time isn't really relevant as we aren't testing animation + const time = 0; const [worldX, worldY] = applyMatrix3( rasterPosition, - inverseProjectionMatrix(store.getState()) + inverseProjectionMatrix(store.getState())(time) ); expect(worldX).toBeCloseTo(expectedWorldPosition[0]); expect(worldY).toBeCloseTo(expectedWorldPosition[1]); }; }); + + describe('when the raster size is 0x0 pixels', () => { + beforeEach(() => { + const action: CameraAction = { type: 'userSetRasterSize', payload: [0, 0] }; + store.dispatch(action); + }); + it('should convert 0,0 in raster space to 0,0 (center) in world space', () => { + compare([10, 0], [0, 0]); + }); + }); + describe('when the raster size is 300 x 200 pixels', () => { beforeEach(() => { const action: CameraAction = { type: 'userSetRasterSize', payload: [300, 200] }; @@ -69,7 +82,7 @@ describe('inverseProjectionMatrix', () => { }); describe('when the user has panned to the right and up by 50', () => { beforeEach(() => { - const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [-50, -50] }; + const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [50, 50] }; store.dispatch(action); }); it('should convert 100,150 in raster space to 0,0 (center) in world space', () => { @@ -84,7 +97,7 @@ describe('inverseProjectionMatrix', () => { }); describe('when the user has panned to the right by 350 and up by 250', () => { beforeEach(() => { - const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [-350, -250] }; + const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [350, 250] }; store.dispatch(action); }); describe('when the user has scaled to 2', () => { diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/methods.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/methods.ts new file mode 100644 index 0000000000000..4afbacb819b1a --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/methods.ts @@ -0,0 +1,36 @@ +/* + * 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 { translation } from './selectors'; +import { CameraState, Vector2 } from '../../types'; + +/** + * Return a new `CameraState` with the `animation` property + * set. The camera will animate to `targetTranslation` over `duration`. + */ +export function animatePanning( + state: CameraState, + startTime: number, + targetTranslation: Vector2, + duration: number +): CameraState { + const nextState: CameraState = { + ...state, + /** + * This cancels panning if any was taking place. + */ + panning: undefined, + translationNotCountingCurrentPanning: targetTranslation, + animation: { + startTime, + targetTranslation, + initialTranslation: translation(state)(startTime), + duration, + }, + }; + + return nextState; +} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/panning.test.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/panning.test.ts index 17401a63b5ae8..9a9a5ea1c0cfc 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/panning.test.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/panning.test.ts @@ -13,11 +13,14 @@ import { translation } from './selectors'; describe('panning interaction', () => { let store: Store; let translationShouldBeCloseTo: (expectedTranslation: Vector2) => void; + let time: number; beforeEach(() => { + // The time isn't relevant as we don't use animations in this suite. + time = 0; store = createStore(cameraReducer, undefined); translationShouldBeCloseTo = expectedTranslation => { - const actualTranslation = translation(store.getState()); + const actualTranslation = translation(store.getState())(time); expect(expectedTranslation[0]).toBeCloseTo(actualTranslation[0]); expect(expectedTranslation[1]).toBeCloseTo(actualTranslation[1]); }; @@ -30,94 +33,64 @@ describe('panning interaction', () => { it('should have a translation of 0,0', () => { translationShouldBeCloseTo([0, 0]); }); - describe('when the user has started panning', () => { + describe('when the user has started panning at (100, 100)', () => { beforeEach(() => { - const action: CameraAction = { type: 'userStartedPanning', payload: [100, 100] }; + const action: CameraAction = { + type: 'userStartedPanning', + payload: { screenCoordinates: [100, 100], time }, + }; store.dispatch(action); }); it('should have a translation of 0,0', () => { translationShouldBeCloseTo([0, 0]); }); - describe('when the user continues to pan 50px up and to the right', () => { + describe('when the user moves their pointer 50px up and right (towards the top right of the screen)', () => { beforeEach(() => { - const action: CameraAction = { type: 'userMovedPointer', payload: [150, 50] }; + const action: CameraAction = { + type: 'userMovedPointer', + payload: { screenCoordinates: [150, 50], time }, + }; store.dispatch(action); }); - it('should have a translation of 50,50', () => { - translationShouldBeCloseTo([50, 50]); + it('should have a translation of [-50, -50] as the camera is now focused on things lower and to the left.', () => { + translationShouldBeCloseTo([-50, -50]); }); describe('when the user then stops panning', () => { beforeEach(() => { - const action: CameraAction = { type: 'userStoppedPanning' }; + const action: CameraAction = { + type: 'userStoppedPanning', + payload: { time }, + }; store.dispatch(action); }); - it('should have a translation of 50,50', () => { - translationShouldBeCloseTo([50, 50]); + it('should still have a translation of [-50, -50]', () => { + translationShouldBeCloseTo([-50, -50]); }); }); }); }); }); - describe('panning controls', () => { - describe('when user clicks on pan north button', () => { - beforeEach(() => { - const action: CameraAction = { type: 'userClickedPanControl', payload: 'north' }; - store.dispatch(action); - }); - it('moves the camera south so that objects appear closer to the bottom of the screen', () => { - const actual = translation(store.getState()); - expect(actual).toMatchInlineSnapshot(` - Array [ - 0, - -32.49906769231164, - ] - `); - }); - }); - describe('when user clicks on pan south button', () => { - beforeEach(() => { - const action: CameraAction = { type: 'userClickedPanControl', payload: 'south' }; - store.dispatch(action); - }); - it('moves the camera north so that objects appear closer to the top of the screen', () => { - const actual = translation(store.getState()); - expect(actual).toMatchInlineSnapshot(` - Array [ - 0, - 32.49906769231164, - ] - `); - }); - }); - describe('when user clicks on pan east button', () => { - beforeEach(() => { - const action: CameraAction = { type: 'userClickedPanControl', payload: 'east' }; - store.dispatch(action); - }); - it('moves the camera west so that objects appear closer to the left of the screen', () => { - const actual = translation(store.getState()); - expect(actual).toMatchInlineSnapshot(` - Array [ - -32.49906769231164, - 0, - ] - `); - }); + describe('when the user nudges the camera up', () => { + beforeEach(() => { + const action: CameraAction = { + type: 'userNudgedCamera', + payload: { direction: [0, 1], time }, + }; + store.dispatch(action); }); - describe('when user clicks on pan west button', () => { - beforeEach(() => { - const action: CameraAction = { type: 'userClickedPanControl', payload: 'west' }; - store.dispatch(action); - }); - it('moves the camera east so that objects appear closer to the right of the screen', () => { - const actual = translation(store.getState()); - expect(actual).toMatchInlineSnapshot(` - Array [ - 32.49906769231164, - 0, - ] - `); - }); + it('the camera eventually moves up so that objects appear closer to the bottom of the screen', () => { + const aBitIntoTheFuture = time + 100; + + /** + * Check the position once the animation has advanced 100ms + */ + const actual: Vector2 = translation(store.getState())(aBitIntoTheFuture); + expect(actual).toMatchInlineSnapshot(` + Array [ + 0, + 7.4074074074074066, + ] + `); }); }); }); 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 e21e3d1001794..e868424d06c94 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 @@ -18,11 +18,21 @@ describe('projectionMatrix', () => { beforeEach(() => { store = createStore(cameraReducer, undefined); compare = (worldPosition: [number, number], expectedRasterPosition: [number, number]) => { - const [rasterX, rasterY] = applyMatrix3(worldPosition, projectionMatrix(store.getState())); + // time isn't really relevant as we aren't testing animation + const time = 0; + const [rasterX, rasterY] = applyMatrix3( + worldPosition, + projectionMatrix(store.getState())(time) + ); expect(rasterX).toBeCloseTo(expectedRasterPosition[0]); expect(rasterY).toBeCloseTo(expectedRasterPosition[1]); }; }); + describe('when the raster size is 0 x 0 pixels (unpainted)', () => { + it('should convert 0,0 (center) in world space to 0,0 in raster space', () => { + compare([0, 0], [0, 0]); + }); + }); describe('when the raster size is 300 x 200 pixels', () => { beforeEach(() => { const action: CameraAction = { type: 'userSetRasterSize', payload: [300, 200] }; @@ -66,7 +76,7 @@ describe('projectionMatrix', () => { }); describe('when the user has panned to the right and up by 50', () => { beforeEach(() => { - const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [-50, -50] }; + const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [50, 50] }; store.dispatch(action); }); it('should convert 0,0 (center) in world space to 100,150 in raster space', () => { @@ -83,7 +93,7 @@ describe('projectionMatrix', () => { beforeEach(() => { const action: CameraAction = { type: 'userSetPositionOfCamera', - payload: [-350, -250], + payload: [350, 250], }; store.dispatch(action); }); 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 7c4678a4f1dc1..0f6ae1b7d904a 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 @@ -5,24 +5,32 @@ */ import { Reducer } from 'redux'; -import { applyMatrix3, subtract } from '../../lib/vector2'; -import { userIsPanning, translation, projectionMatrix, inverseProjectionMatrix } from './selectors'; +import { unitsPerNudge, nudgeAnimationDuration } from './scaling_constants'; +import { animatePanning } from './methods'; +import * as vector2 from '../../lib/vector2'; +import * as selectors from './selectors'; import { clamp } from '../../lib/math'; import { CameraState, ResolverAction, Vector2 } from '../../types'; import { scaleToZoom } from './scale_to_zoom'; -function initialState(): CameraState { - return { +/** + * Used in tests. + */ +export function cameraInitialState(): CameraState { + const state: CameraState = { scalingFactor: scaleToZoom(1), // Defaulted to 1 to 1 scale rasterSize: [0, 0] as const, translationNotCountingCurrentPanning: [0, 0] as const, latestFocusedWorldCoordinates: null, + animation: undefined, + panning: undefined, }; + return state; } export const cameraReducer: Reducer = ( - state = initialState(), + state = cameraInitialState(), action ) => { if (action.type === 'userSetZoomLevel') { @@ -30,10 +38,11 @@ export const cameraReducer: Reducer = ( * Handle the scale being explicitly set, for example by a 'reset zoom' feature, or by a range slider with exact scale values */ - return { + const nextState: CameraState = { ...state, scalingFactor: clamp(action.payload, 0, 1), }; + return nextState; } else if (action.type === 'userClickedZoomIn') { return { ...state, @@ -47,7 +56,7 @@ export const cameraReducer: Reducer = ( } else if (action.type === 'userZoomed') { const stateWithNewScaling: CameraState = { ...state, - scalingFactor: clamp(state.scalingFactor + action.payload, 0, 1), + scalingFactor: clamp(state.scalingFactor + action.payload.zoomChange, 0, 1), }; /** @@ -59,22 +68,41 @@ export const cameraReducer: Reducer = ( * using CTRL and the mousewheel, or by pinching the trackpad on a Mac. The node will stay under your mouse cursor and other things in the map will get * nearer or further from the mouse cursor. This lets you keep your context when changing zoom levels. */ - if (state.latestFocusedWorldCoordinates !== null) { - const rasterOfLastFocusedWorldCoordinates = applyMatrix3( + if ( + state.latestFocusedWorldCoordinates !== null && + !selectors.isAnimating(state)(action.payload.time) + ) { + const rasterOfLastFocusedWorldCoordinates = vector2.applyMatrix3( state.latestFocusedWorldCoordinates, - projectionMatrix(state) + selectors.projectionMatrix(state)(action.payload.time) + ); + const newWorldCoordinatesAtLastFocusedPosition = vector2.applyMatrix3( + rasterOfLastFocusedWorldCoordinates, + selectors.inverseProjectionMatrix(stateWithNewScaling)(action.payload.time) + ); + + /** + * The change in world position incurred by changing scale. + */ + const delta = vector2.subtract( + newWorldCoordinatesAtLastFocusedPosition, + state.latestFocusedWorldCoordinates ); - const matrix = inverseProjectionMatrix(stateWithNewScaling); - const worldCoordinateThereNow = applyMatrix3(rasterOfLastFocusedWorldCoordinates, matrix); - const delta = subtract(worldCoordinateThereNow, state.latestFocusedWorldCoordinates); - return { + /** + * Adjust for the change in position due to scale. + */ + const translationNotCountingCurrentPanning: Vector2 = vector2.subtract( + stateWithNewScaling.translationNotCountingCurrentPanning, + delta + ); + + const nextState: CameraState = { ...stateWithNewScaling, - translationNotCountingCurrentPanning: [ - stateWithNewScaling.translationNotCountingCurrentPanning[0] + delta[0], - stateWithNewScaling.translationNotCountingCurrentPanning[1] + delta[1], - ], + translationNotCountingCurrentPanning, }; + + return nextState; } else { return stateWithNewScaling; } @@ -82,83 +110,76 @@ export const cameraReducer: Reducer = ( /** * Handle the case where the position of the camera is explicitly set, for example by a 'back to center' feature. */ - return { + const nextState: CameraState = { ...state, + animation: undefined, translationNotCountingCurrentPanning: action.payload, }; + return nextState; } else if (action.type === 'userStartedPanning') { + if (selectors.isAnimating(state)(action.payload.time)) { + return state; + } /** * When the user begins panning with a mousedown event we mark the starting position for later comparisons. */ - return { + const nextState: CameraState = { ...state, + animation: undefined, panning: { - origin: action.payload, - currentOffset: action.payload, + origin: action.payload.screenCoordinates, + currentOffset: action.payload.screenCoordinates, }, }; + return nextState; } else if (action.type === 'userStoppedPanning') { /** * When the user stops panning (by letting up on the mouse) we calculate the new translation of the camera. */ - if (userIsPanning(state)) { - return { - ...state, - translationNotCountingCurrentPanning: translation(state), - panning: undefined, - }; - } else { - return state; - } - } else if (action.type === 'userClickedPanControl') { - const panDirection = action.payload; + const nextState: CameraState = { + ...state, + translationNotCountingCurrentPanning: selectors.translation(state)(action.payload.time), + panning: undefined, + }; + return nextState; + } else if (action.type === 'userNudgedCamera') { + const { direction, time } = action.payload; /** - * Delta amount will be in the range of 20 -> 40 depending on the scalingFactor + * Nudge less when zoomed in. */ - const deltaAmount = (1 + state.scalingFactor) * 20; - let delta: Vector2; - if (panDirection === 'north') { - delta = [0, -deltaAmount]; - } else if (panDirection === 'south') { - delta = [0, deltaAmount]; - } else if (panDirection === 'east') { - delta = [-deltaAmount, 0]; - } else if (panDirection === 'west') { - delta = [deltaAmount, 0]; - } else { - delta = [0, 0]; - } + const nudge = vector2.multiply( + vector2.divide([unitsPerNudge, unitsPerNudge], selectors.scale(state)(time)), + direction + ); - return { - ...state, - translationNotCountingCurrentPanning: [ - state.translationNotCountingCurrentPanning[0] + delta[0], - state.translationNotCountingCurrentPanning[1] + delta[1], - ], - }; + return animatePanning( + state, + time, + vector2.add(state.translationNotCountingCurrentPanning, nudge), + nudgeAnimationDuration + ); } else if (action.type === 'userSetRasterSize') { /** * Handle resizes of the Resolver component. We need to know the size in order to convert between screen * and world coordinates. */ - return { + const nextState: CameraState = { ...state, rasterSize: action.payload, }; + return nextState; } else if (action.type === 'userMovedPointer') { - const stateWithUpdatedPanning = { - ...state, - /** - * If the user is panning, adjust the panning offset - */ - panning: userIsPanning(state) - ? { - origin: state.panning ? state.panning.origin : action.payload, - currentOffset: action.payload, - } - : state.panning, - }; - return { + let stateWithUpdatedPanning: CameraState = state; + if (state.panning) { + stateWithUpdatedPanning = { + ...state, + panning: { + origin: state.panning.origin, + currentOffset: action.payload.screenCoordinates, + }, + }; + } + const nextState: CameraState = { ...stateWithUpdatedPanning, /** * keep track of the last world coordinates the user moved over. @@ -166,11 +187,12 @@ export const cameraReducer: Reducer = ( * to keep the same point under the pointer. * In order to do this, we need to know the position of the mouse when changing the scale. */ - latestFocusedWorldCoordinates: applyMatrix3( - action.payload, - inverseProjectionMatrix(stateWithUpdatedPanning) + latestFocusedWorldCoordinates: vector2.applyMatrix3( + action.payload.screenCoordinates, + selectors.inverseProjectionMatrix(stateWithUpdatedPanning)(action.payload.time) ), }; + return nextState; } else { return state; } 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 index 93c41fde64f0e..243d8877a8b0d 100644 --- 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 @@ -7,7 +7,7 @@ /** * 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; +export const minimum = 0.5; /** * The maximum allowed value for the camera scale. This is greatest scale that we will ever render something at. @@ -18,3 +18,13 @@ 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; + +/** + * The size, in world units, of a 'nudge' as caused by clicking the up, right, down, or left panning buttons. + */ +export const unitsPerNudge = 50; + +/** + * The duration a nudge animation lasts. + */ +export const nudgeAnimationDuration = 300; 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 53ffe6dd073fa..226e36f63d788 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 @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Vector2, CameraState, AABB, Matrix3 } from '../../types'; -import { subtract, divide, add, applyMatrix3 } from '../../lib/vector2'; +import { createSelector, defaultMemoize } from 'reselect'; +import { easing } from 'ts-easing'; +import { clamp, lerp } from '../../lib/math'; +import * as vector2 from '../../lib/vector2'; import { multiply, add as addMatrix } from '../../lib/matrix3'; import { inverseOrthographicProjection, @@ -13,7 +15,8 @@ import { orthographicProjection, translationTransformation, } from '../../lib/transformation'; -import { maximum, minimum, zoomCurveRate } from './scaling_constants'; +import * as scalingConstants from './scaling_constants'; +import { Vector2, CameraState, AABB, Matrix3, CameraAnimationState } from '../../types'; interface ClippingPlanes { renderWidth: number; @@ -24,77 +27,283 @@ interface ClippingPlanes { clippingPlaneBottom: number; } +function animationIsActive(animation: CameraAnimationState, time: number): boolean { + return animation.startTime + animation.duration >= time; +} + /** - * The viewable area in the Resolver map, in world coordinates. + * The scale by which world values are scaled when rendered. + * + * When the camera position (translation) is changed programatically, it may be animated. + * The duration of the animation is generally fixed for a given type of interaction. This way + * the user won't have to wait for a variable amount of time to complete their interaction. + * + * Since the duration is fixed and the amount that the camera position changes is variable, + * the speed at which the camera changes is also variable. If the distance the camera will move + * is very far, the camera will move very fast. + * + * When the camera moves fast, elements will move across the screen quickly. These + * quick moving elements can be distracting to the user. They may also hinder the quality of + * animation. + * + * The speed at which objects move across the screen is dependent on the speed of the camera + * as well as the scale. If the scale is high, the camera is zoomed in, and so objects move + * across the screen faster at a given camera speed. Think of looking into a telephoto lense + * and moving around only a few degrees: many things might pass through your sight. + * + * If the scale is low, the camera is zoomed out, objects look further away, and so they move + * across the screen slower at a given camera speed. Therefore we can control the speed at + * which objects move across the screen without changing the camera speed. We do this by changing scale. + * + * Changing the scale abruptly isn't acceptable because it would be visually jarring. Also, the + * change in scale should be temporary, and the original scale should be resumed after the animation. + * + * In order to change the scale to lower value, and then back, without being jarring to the user, + * we calculate a temporary target scale and animate to it. + * */ -export function viewableBoundingBox(state: CameraState): AABB { - const { renderWidth, renderHeight } = clippingPlanes(state); - const matrix = inverseProjectionMatrix(state); - const bottomLeftCorner: Vector2 = [0, renderHeight]; - const topRightCorner: Vector2 = [renderWidth, 0]; - return { - minimum: applyMatrix3(bottomLeftCorner, matrix), - maximum: applyMatrix3(topRightCorner, matrix), - }; -} +export const scale: (state: CameraState) => (time: number) => Vector2 = createSelector( + state => state.scalingFactor, + state => state.animation, + (scalingFactor, animation) => { + const scaleNotCountingAnimation = scaleFromScalingFactor(scalingFactor); + /** + * If `animation` is defined, an animation may be in progress when the returned function is called + */ + if (animation !== undefined) { + /** + * The distance the camera will move during the animation is used to determine the camera speed. + */ + const panningDistance = vector2.distance( + animation.targetTranslation, + animation.initialTranslation + ); + + const panningDistanceInPixels = panningDistance * scaleNotCountingAnimation; + + /** + * The speed at which pixels move across the screen during animation in pixels per millisecond. + */ + const speed = panningDistanceInPixels / animation.duration; + + /** + * The speed (in pixels per millisecond) at which an animation is triggered is a constant. + * If the camera isn't moving very fast, no change in scale is necessary. + */ + const speedThreshold = 0.4; + + /** + * Growth in speed beyond the threshold is taken to the power of a constant. This limits the + * rate of growth of speed. + */ + const speedGrowthFactor = 0.4; + + /* + * Limit the rate of growth of speed. If the speed is too great, the animation will be + * unpleasant and have poor performance. + * + * gnuplot> plot [x=0:10][y=0:3] threshold=0.4, growthFactor=0.4, x < threshold ? x : x ** growthFactor - (threshold ** growthFactor - threshold) + * + * + * 3 +----------------------------------------------------------------------------+ + * | target speed + + + | + * | | + * | ******* | + * | | + * | | + * 2.5 |-+ +-| + * | | + * | | + * | **| + * | ******* | + * | ****** | + * 2 |-+ ****** +-| + * | ***** | + * | ***** | + * | ***** | + * | ***** | + * 1.5 |-+ ***** +-| + * | **** | + * | **** | + * | **** | + * | *** | + * | *** | + * 1 |-+ ** +-| + * | *** | + * | *** | + * | * | + * | ** | + * | ** | + * 0.5 |-+ * +-| + * | ** | + * | * | + * | * | + * | * | + * |* + + + + | + * 0 +----------------------------------------------------------------------------+ + * 0 2 4 6 8 10 + * camera speed (pixels per ms) + * + **/ + const limitedSpeed = + speed < speedThreshold + ? speed + : speed ** speedGrowthFactor - (speedThreshold ** speedGrowthFactor - speedThreshold); + + /** + * The distance and duration of the animation are independent variables. If the speed was + * limited, only the scale can change. The lower the scale, the further the camera is + * away from things, and therefore the slower things move across the screen. Adjust the + * scale (within its own limits) to match the limited speed. + * + * This will cause the camera to zoom out if it would otherwise move too fast. + */ + const adjustedScale = clamp( + (limitedSpeed * animation.duration) / panningDistance, + scalingConstants.minimum, + scalingConstants.maximum + ); + + return time => { + /** + * If the animation has completed, return the `scaleNotCountingAnimation`, as + * the animation always completes with the scale set back at starting value. + */ + if (animationIsActive(animation, time) === false) { + return [scaleNotCountingAnimation, scaleNotCountingAnimation]; + } else { + /** + * + * Animation is defined by a starting time, duration, starting position, and ending position. The amount of time + * which has passed since the start time, compared to the duration, defines the progress of the animation. + * We represent this process with a number between 0 and 1. As the animation progresses, the value changes from 0 + * to 1, linearly. + */ + const x = animationProgress(animation, time); + /** + * The change in scale over the duration of the animation should not be linear. It should grow to the target value, + * then shrink back down to the original value. We adjust the animation progress so that it reaches its peak + * halfway through the animation and then returns to the beginning value by the end of the animation. + * + * We ease the value so that the change from not-animating-at-all to animating-at-full-speed isn't abrupt. + * See the graph: + * + * gnuplot> plot [x=-0:1][x=0:1.2] eased(t)=t<.5? 4*t**3 : (t-1)*(2*t-2)**2+1, progress(t)=-abs(2*t-1)+1, eased(progress(x)) + * + * + * 1.2 +--------------------------------------------------------------------------------------+ + * | + + + + | + * | e(t)=t<.5? 4*t**3 : (t-1)*(2*t-2)**2+1, t(x)=-abs(2*x-1)+1, e(t(x)) ******* | + * | | + * | | + * | | + * 1 |-+ **************** +-| + * | *** *** | + * | ** ** | + * | ** ** | + * | * * | + * | * * | + * 0.8 |-+ * * +-| + * | * * | + * | * * | + * | * * | + * | * * | + * 0.6 |-+ * * +-| + * | * * | + * | * * | + * | * * | + * | * * | + * | * * | + * 0.4 |-+ * * +-| + * | * * | + * | * * | + * | * * | + * | * * | + * | * * | + * 0.2 |-+ * * +-| + * | * * | + * | * * | + * | ** ** | + * | * * | + * | *** + + + + *** | + * 0 +--------------------------------------------------------------------------------------+ + * 0 0.2 0.4 0.6 0.8 1 + * animation progress + * + */ + const easedInOutAnimationProgress = easing.inOutCubic(-Math.abs(2 * x - 1) + 1); + + /** + * Linearly interpolate between these, using the bell-shaped easing value + */ + const lerpedScale = lerp( + scaleNotCountingAnimation, + adjustedScale, + easedInOutAnimationProgress + ); + + /** + * The scale should be the same in both axes. + */ + return [lerpedScale, lerpedScale]; + } + }; + } else { + /** + * The scale should be the same in both axes. + */ + return () => [scaleNotCountingAnimation, scaleNotCountingAnimation]; + } + + /** + * Interpolate between the minimum and maximum scale, + * using a curved ratio based on `factor`. + */ + function scaleFromScalingFactor(factor: number): number { + return lerp( + scalingConstants.minimum, + scalingConstants.maximum, + Math.pow(factor, scalingConstants.zoomCurveRate) + ); + } + } +); /** * The 2D clipping planes used for the orthographic projection. See https://en.wikipedia.org/wiki/Orthographic_projection */ -function clippingPlanes(state: CameraState): ClippingPlanes { - const renderWidth = state.rasterSize[0]; - const renderHeight = state.rasterSize[1]; - const clippingPlaneRight = renderWidth / 2 / scale(state)[0]; - const clippingPlaneTop = renderHeight / 2 / scale(state)[1]; - - return { - renderWidth, - renderHeight, - clippingPlaneRight, - clippingPlaneTop, - clippingPlaneLeft: -clippingPlaneRight, - clippingPlaneBottom: -clippingPlaneTop, - }; -} +export const clippingPlanes: ( + state: CameraState +) => (time: number) => ClippingPlanes = createSelector( + state => state.rasterSize, + scale, + (rasterSize, scaleAtTime) => (time: number) => { + const [scaleX, scaleY] = scaleAtTime(time); + const renderWidth = rasterSize[0]; + const renderHeight = rasterSize[1]; + const clippingPlaneRight = renderWidth / 2 / scaleX; + const clippingPlaneTop = renderHeight / 2 / scaleY; + + return { + renderWidth, + renderHeight, + clippingPlaneRight, + clippingPlaneTop, + clippingPlaneLeft: -clippingPlaneRight, + clippingPlaneBottom: -clippingPlaneTop, + }; + } +); /** - * A matrix that when applied to a Vector2 will convert it from world coordinates to screen coordinates. - * See https://en.wikipedia.org/wiki/Orthographic_projection + * Whether or not the camera is animating, at a given time. */ -export const projectionMatrix: (state: CameraState) => Matrix3 = state => { - const { - renderWidth, - renderHeight, - clippingPlaneRight, - clippingPlaneTop, - clippingPlaneLeft, - clippingPlaneBottom, - } = clippingPlanes(state); - - return multiply( - // 5. convert from 0->2 to 0->rasterWidth (or height) - scalingTransformation([renderWidth / 2, renderHeight / 2]), - addMatrix( - // 4. add one to change range from -1->1 to 0->2 - [0, 0, 1, 0, 0, 1, 0, 0, 0], - multiply( - // 3. invert y since CSS has inverted y - scalingTransformation([1, -1]), - multiply( - // 2. scale to clipping plane - orthographicProjection( - clippingPlaneTop, - clippingPlaneRight, - clippingPlaneBottom, - clippingPlaneLeft - ), - // 1. adjust for camera - translationTransformation(translation(state)) - ) - ) - ) - ); -}; +export const isAnimating: (state: CameraState) => (time: number) => boolean = createSelector( + state => state.animation, + animation => time => { + return animation !== undefined && animationIsActive(animation, time); + } +); /** * The camera has a translation value (not counting any current panning.) This is initialized to (0, 0) and @@ -108,79 +317,186 @@ export const projectionMatrix: (state: CameraState) => Matrix3 = state => { * * We could update the translation as the user moved the mouse but floating point drift (round-off error) could occur. */ -export function translation(state: CameraState): Vector2 { - if (state.panning) { - return add( - state.translationNotCountingCurrentPanning, - divide(subtract(state.panning.currentOffset, state.panning.origin), [ - scale(state)[0], - // Invert `y` since the `.panning` vectors are in screen coordinates and therefore have backwards `y` - -scale(state)[1], - ]) - ); - } else { - return state.translationNotCountingCurrentPanning; +export const translation: (state: CameraState) => (time: number) => Vector2 = createSelector( + state => state.panning, + state => state.translationNotCountingCurrentPanning, + scale, + state => state.animation, + (panning, translationNotCountingCurrentPanning, scaleAtTime, animation) => { + return (time: number) => { + const [scaleX, scaleY] = scaleAtTime(time); + if (animation !== undefined && animationIsActive(animation, time)) { + return vector2.lerp( + animation.initialTranslation, + animation.targetTranslation, + easing.inOutCubic(animationProgress(animation, time)) + ); + } else if (panning) { + const changeInPanningOffset = vector2.subtract(panning.currentOffset, panning.origin); + /** + * invert the vector since panning moves the perception of the screen, which is inverse of the + * translation of the camera. Inverse the `y` axis again, since `y` is inverted between + * world and screen coordinates. + */ + const changeInTranslation = vector2.divide(changeInPanningOffset, [-scaleX, scaleY]); + return vector2.add(translationNotCountingCurrentPanning, changeInTranslation); + } else { + return translationNotCountingCurrentPanning; + } + }; } -} +); /** * A matrix that when applied to a Vector2 converts it from screen coordinates to world coordinates. * See https://en.wikipedia.org/wiki/Orthographic_projection */ -export const inverseProjectionMatrix: (state: CameraState) => Matrix3 = state => { - const { - renderWidth, - renderHeight, - clippingPlaneRight, - clippingPlaneTop, - clippingPlaneLeft, - clippingPlaneBottom, - } = clippingPlanes(state); - - /* prettier-ignore */ - const screenToNDC = [ - 2 / renderWidth, 0, -1, - 0, 2 / renderHeight, -1, - 0, 0, 0 - ] as const - - const [translationX, translationY] = translation(state); - - return addMatrix( - // 4. Translate for the 'camera' - // prettier-ignore - [ - 0, 0, -translationX, - 0, 0, -translationY, - 0, 0, 0 - ] as const, - multiply( - // 3. make values in range of clipping planes - inverseOrthographicProjection( +export const inverseProjectionMatrix: ( + state: CameraState +) => (time: number) => Matrix3 = createSelector( + clippingPlanes, + translation, + (clippingPlanesAtTime, translationAtTime) => { + return (time: number) => { + const { + renderWidth, + renderHeight, + clippingPlaneRight, + clippingPlaneTop, + clippingPlaneLeft, + clippingPlaneBottom, + } = clippingPlanesAtTime(time); + + /** + * 1. Convert from 0<=n<=screenDimension to -1<=n<=1 + * e.g. for x-axis, divide by renderWidth then multiply by 2 and subtract by one so the value is in range of -1->1 + */ + // prettier-ignore + const screenToNDC: Matrix3 = [ + renderWidth === 0 ? 0 : 2 / renderWidth, 0, -1, + 0, renderHeight === 0 ? 0 : 2 / renderHeight, -1, + 0, 0, 0 + ]; + + /** + * 2. Invert Y since DOM positioning has inverted Y axis + */ + const invertY = scalingTransformation([1, -1]); + + const [translationX, translationY] = translationAtTime(time); + + /** + * 3. Scale values to the clipping plane dimensions. + */ + const scaleToClippingPlaneDimensions = inverseOrthographicProjection( clippingPlaneTop, clippingPlaneRight, clippingPlaneBottom, clippingPlaneLeft - ), - multiply( - // 2 Invert Y since CSS has inverted y - scalingTransformation([1, -1]), - // 1. convert screen coordinates to NDC - // e.g. for x-axis, divide by renderWidth then multiply by 2 and subtract by one so the value is in range of -1->1 - screenToNDC - ) - ) - ); -}; + ); + + /** + * Move the values to accomodate for the perspective of the camera (based on the camera's transform) + */ + const translateForCamera: Matrix3 = [0, 0, translationX, 0, 0, translationY, 0, 0, 0]; + + return addMatrix( + translateForCamera, + multiply(scaleToClippingPlaneDimensions, multiply(invertY, screenToNDC)) + ); + }; + } +); /** - * The scale by which world values are scaled when rendered. + * The viewable area in the Resolver map, in world coordinates. */ -export const scale = (state: CameraState): Vector2 => { - const delta = maximum - minimum; - const value = Math.pow(state.scalingFactor, zoomCurveRate) * delta + minimum; - return [value, value]; -}; +export const viewableBoundingBox: (state: CameraState) => (time: number) => AABB = createSelector( + clippingPlanes, + inverseProjectionMatrix, + (clippingPlanesAtTime, matrixAtTime) => { + return (time: number) => { + const { renderWidth, renderHeight } = clippingPlanesAtTime(time); + const matrix = matrixAtTime(time); + const bottomLeftCorner: Vector2 = [0, renderHeight]; + const topRightCorner: Vector2 = [renderWidth, 0]; + return { + minimum: vector2.applyMatrix3(bottomLeftCorner, matrix), + maximum: vector2.applyMatrix3(topRightCorner, matrix), + }; + }; + } +); + +/** + * A matrix that when applied to a Vector2 will convert it from world coordinates to screen coordinates. + * See https://en.wikipedia.org/wiki/Orthographic_projection + */ +export const projectionMatrix: (state: CameraState) => (time: number) => Matrix3 = createSelector( + clippingPlanes, + translation, + (clippingPlanesAtTime, translationAtTime) => { + return defaultMemoize((time: number) => { + const { + renderWidth, + renderHeight, + clippingPlaneRight, + clippingPlaneTop, + clippingPlaneLeft, + clippingPlaneBottom, + } = clippingPlanesAtTime(time); + + /** + * 1. adjust for camera by subtracting its translation. The closer the camera is to a point, the closer that point + * should be to the center of the screen. + */ + const adjustForCameraPosition = translationTransformation( + vector2.scale(translationAtTime(time), -1) + ); + + /** + * 2. Scale the values based on the dimsension of Resolver on the screen. + */ + const screenToNDC = orthographicProjection( + clippingPlaneTop, + clippingPlaneRight, + clippingPlaneBottom, + clippingPlaneLeft + ); + + /** + * 3. invert y since CSS has inverted y + */ + const invertY = scalingTransformation([1, -1]); + + /** + * 3. Convert values from the scale of -1<=n<=1 to 0<=n<=2 + */ + // prettier-ignore + const fromNDCtoZeroToTwo: Matrix3 = [ + 0, 0, 1, + 0, 0, 1, + 0, 0, 0 + ] + + /** + * 4. convert from 0->2 to 0->rasterDimension by multiplying by rasterDimension/2 + */ + const fromZeroToTwoToRasterDimensions = scalingTransformation([ + renderWidth / 2, + renderHeight / 2, + ]); + + return multiply( + fromZeroToTwoToRasterDimensions, + addMatrix( + fromNDCtoZeroToTwo, + multiply(invertY, multiply(screenToNDC, adjustForCameraPosition)) + ) + ); + }); + } +); /** * Scales the coordinate system, used for zooming. Should always be between 0 and 1 @@ -193,3 +509,12 @@ export const scalingFactor = (state: CameraState): CameraState['scalingFactor'] * Whether or not the user is current panning the map. */ export const userIsPanning = (state: CameraState): boolean => state.panning !== undefined; + +/** + * Returns a number 0<=n<=1 where: + * 0 meaning it just started, + * 1 meaning it is done. + */ +function animationProgress(animation: CameraAnimationState, time: number): number { + return clamp((time - animation.startTime) / animation.duration, 0, 1); +} 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 abc113d5999ff..fb38c2f526e0b 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 @@ -15,12 +15,13 @@ import { applyMatrix3 } from '../../lib/vector2'; describe('zooming', () => { let store: Store; + let time: number; const cameraShouldBeBoundBy = (expectedViewableBoundingBox: AABB): [string, () => void] => { return [ `the camera view should be bound by an AABB with a minimum point of ${expectedViewableBoundingBox.minimum} and a maximum point of ${expectedViewableBoundingBox.maximum}`, () => { - const actual = viewableBoundingBox(store.getState()); + const actual = viewableBoundingBox(store.getState())(time); expect(actual.minimum[0]).toBeCloseTo(expectedViewableBoundingBox.minimum[0]); expect(actual.minimum[1]).toBeCloseTo(expectedViewableBoundingBox.minimum[1]); expect(actual.maximum[0]).toBeCloseTo(expectedViewableBoundingBox.maximum[0]); @@ -29,6 +30,8 @@ describe('zooming', () => { ]; }; beforeEach(() => { + // Time isn't relevant as we aren't testing animation + time = 0; store = createStore(cameraReducer, undefined); }); describe('when the raster size is 300 x 200 pixels', () => { @@ -58,12 +61,12 @@ describe('zooming', () => { beforeEach(() => { const action: CameraAction = { type: 'userZoomed', - payload: 1, + payload: { zoomChange: 1, time }, }; store.dispatch(action); }); it('should zoom to maximum scale factor', () => { - const actual = viewableBoundingBox(store.getState()); + const actual = viewableBoundingBox(store.getState())(time); expect(actual).toMatchInlineSnapshot(` Object { "maximum": Array [ @@ -79,16 +82,16 @@ describe('zooming', () => { }); }); it('the raster position 200, 50 should map to the world position 50, 50', () => { - expectVectorsToBeClose(applyMatrix3([200, 50], inverseProjectionMatrix(store.getState())), [ - 50, - 50, - ]); + expectVectorsToBeClose( + applyMatrix3([200, 50], inverseProjectionMatrix(store.getState())(time)), + [50, 50] + ); }); describe('when the user has moved their mouse to the raster position 200, 50', () => { beforeEach(() => { const action: CameraAction = { type: 'userMovedPointer', - payload: [200, 50], + payload: { screenCoordinates: [200, 50], time }, }; store.dispatch(action); }); @@ -104,13 +107,13 @@ describe('zooming', () => { beforeEach(() => { const action: CameraAction = { type: 'userZoomed', - payload: 0.5, + payload: { zoomChange: 0.5, time }, }; store.dispatch(action); }); it('the raster position 200, 50 should map to the world position 50, 50', () => { expectVectorsToBeClose( - applyMatrix3([200, 50], inverseProjectionMatrix(store.getState())), + applyMatrix3([200, 50], inverseProjectionMatrix(store.getState())(time)), [50, 50] ); }); @@ -118,7 +121,7 @@ describe('zooming', () => { }); describe('when the user pans right by 100 pixels', () => { beforeEach(() => { - const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [-100, 0] }; + const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [100, 0] }; store.dispatch(action); }); it( @@ -130,7 +133,7 @@ describe('zooming', () => { it('should be centered on 100, 0', () => { const worldCenterPoint = applyMatrix3( [150, 100], - inverseProjectionMatrix(store.getState()) + inverseProjectionMatrix(store.getState())(time) ); expect(worldCenterPoint[0]).toBeCloseTo(100); expect(worldCenterPoint[1]).toBeCloseTo(0); @@ -143,7 +146,7 @@ describe('zooming', () => { it('should be centered on 100, 0', () => { const worldCenterPoint = applyMatrix3( [150, 100], - inverseProjectionMatrix(store.getState()) + inverseProjectionMatrix(store.getState())(time) ); expect(worldCenterPoint[0]).toBeCloseTo(100); expect(worldCenterPoint[1]).toBeCloseTo(0); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap index 261ca7e0a7bba..1dc17054b9f47 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap @@ -18,6 +18,7 @@ Object { "node_id": 0, "process_name": "", "process_path": "", + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, @@ -172,6 +173,7 @@ Object { "node_id": 0, "process_name": "", "process_path": "", + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, @@ -188,6 +190,7 @@ Object { "process_name": "", "process_path": "", "source_id": 0, + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, @@ -204,6 +207,7 @@ Object { "process_name": "", "process_path": "", "source_id": 0, + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, @@ -220,6 +224,7 @@ Object { "process_name": "", "process_path": "", "source_id": 1, + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, @@ -236,6 +241,7 @@ Object { "process_name": "", "process_path": "", "source_id": 1, + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, @@ -252,6 +258,7 @@ Object { "process_name": "", "process_path": "", "source_id": 2, + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, @@ -268,6 +275,7 @@ Object { "process_name": "", "process_path": "", "source_id": 2, + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, @@ -284,6 +292,7 @@ Object { "process_name": "", "process_path": "", "source_id": 6, + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, @@ -318,6 +327,7 @@ Object { "node_id": 0, "process_name": "", "process_path": "", + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, @@ -334,6 +344,7 @@ Object { "process_name": "", "process_path": "", "source_id": 0, + "timestamp_utc": "2019-09-24 01:47:47Z", }, "event_timestamp": 1, "event_type": 1, diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts index 745bd125c151d..75b477dd7c7fc 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts @@ -57,11 +57,17 @@ const isometricTransformMatrix: Matrix3 = [ /** * The distance in pixels (at scale 1) between nodes. Change this to space out nodes more */ -export const distanceBetweenNodes = distanceBetweenNodesInUnits * unit; +const distanceBetweenNodes = distanceBetweenNodesInUnits * unit; -export function graphableProcesses(state: DataState) { - return state.results.filter(isGraphableProcess); -} +/** + * Process events that will be graphed. + */ +export const graphableProcesses = createSelector( + ({ results }: DataState) => results, + function(results: DataState['results']) { + return results.filter(isGraphableProcess); + } +); /** * In laying out the graph, we precalculate the 'width' of each subtree. The 'width' of the subtree is determined by its diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/index.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/index.ts index d043453a8e4cd..b17572bbc4ab4 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/index.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/index.ts @@ -4,43 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createStore, StoreEnhancer } from 'redux'; -import { ResolverAction } from '../types'; +import { createStore, applyMiddleware, Store } from 'redux'; +import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; +import { ResolverAction, ResolverState } from '../types'; import { resolverReducer } from './reducer'; -export const storeFactory = () => { - /** - * Redux Devtools extension exposes itself via a property on the global object. - * This interface can be used to cast `window` to a type that may expose Redux Devtools. - */ - interface SomethingThatMightHaveReduxDevTools { - __REDUX_DEVTOOLS_EXTENSION__?: (options?: PartialReduxDevToolsOptions) => StoreEnhancer; - } +export const storeFactory = (): { store: Store } => { + const actionsBlacklist: Array = ['userMovedPointer']; + const composeEnhancers = composeWithDevTools({ + name: 'Resolver', + actionsBlacklist, + }); - /** - * Some of the options that can be passed when configuring Redux Devtools. - */ - interface PartialReduxDevToolsOptions { - /** - * A name for this store - */ - name?: string; - /** - * A list of action types to ignore. This is used to ignore high frequency events created by a mousemove handler - */ - actionsBlacklist?: readonly string[]; - } - const windowWhichMightHaveReduxDevTools = window as SomethingThatMightHaveReduxDevTools; - // Make sure blacklisted action types are valid - const actionsBlacklist: ReadonlyArray = ['userMovedPointer']; - const store = createStore( - resolverReducer, - windowWhichMightHaveReduxDevTools.__REDUX_DEVTOOLS_EXTENSION__ && - windowWhichMightHaveReduxDevTools.__REDUX_DEVTOOLS_EXTENSION__({ - name: 'Resolver', - actionsBlacklist, - }) - ); + const middlewareEnhancer = applyMiddleware(); + + const store = createStore(resolverReducer, composeEnhancers(middlewareEnhancer)); return { store, }; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts new file mode 100644 index 0000000000000..8808160c9c631 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts @@ -0,0 +1,30 @@ +/* + * 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 { animatePanning } from './camera/methods'; +import { processNodePositionsAndEdgeLineSegments } from './selectors'; +import { ResolverState, ProcessEvent } from '../types'; + +const animationDuration = 1000; + +/** + * Return new `ResolverState` with the camera animating to focus on `process`. + */ +export function animateProcessIntoView( + state: ResolverState, + startTime: number, + process: ProcessEvent +): ResolverState { + const { processNodePositions } = processNodePositionsAndEdgeLineSegments(state); + const position = processNodePositions.get(process); + if (position) { + return { + ...state, + camera: animatePanning(state.camera, startTime, position, animationDuration), + }; + } + return state; +} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts index 97ab51cbd6dea..20c490b8998f9 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts @@ -4,11 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ import { Reducer, combineReducers } from 'redux'; +import { animateProcessIntoView } from './methods'; import { cameraReducer } from './camera/reducer'; import { dataReducer } from './data/reducer'; import { ResolverState, ResolverAction } from '../types'; -export const resolverReducer: Reducer = combineReducers({ +const concernReducers = combineReducers({ camera: cameraReducer, data: dataReducer, }); + +export const resolverReducer: Reducer = (state, action) => { + const nextState = concernReducers(state, action); + if (action.type === 'userBroughtProcessIntoView') { + return animateProcessIntoView(nextState, action.payload.time, action.payload.process); + } else { + return nextState; + } +}; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts index eb1c1fec36995..4d12e656205fa 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts @@ -17,6 +17,9 @@ export const projectionMatrix = composeSelectors( cameraSelectors.projectionMatrix ); +export const clippingPlanes = composeSelectors(cameraStateSelector, cameraSelectors.clippingPlanes); +export const translation = composeSelectors(cameraStateSelector, cameraSelectors.translation); + /** * A matrix that when applied to a Vector2 converts it from screen coordinates to world coordinates. * See https://en.wikipedia.org/wiki/Orthographic_projection @@ -28,6 +31,7 @@ export const inverseProjectionMatrix = composeSelectors( /** * The scale by which world values are scaled when rendered. + * TODO make it a number */ export const scale = composeSelectors(cameraStateSelector, cameraSelectors.scale); @@ -41,6 +45,11 @@ export const scalingFactor = composeSelectors(cameraStateSelector, cameraSelecto */ export const userIsPanning = composeSelectors(cameraStateSelector, cameraSelectors.userIsPanning); +/** + * Whether or not the camera is animating, at a given time. + */ +export const isAnimating = composeSelectors(cameraStateSelector, cameraSelectors.isAnimating); + export const processNodePositionsAndEdgeLineSegments = composeSelectors( dataStateSelector, dataSelectors.processNodePositionsAndEdgeLineSegments diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts index f2ae9785446f7..6c6936d377dea 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ResolverAction } from './actions'; +import { Store } from 'redux'; + +import { ResolverAction } from './store/actions'; +export { ResolverAction } from './store/actions'; /** * Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`. @@ -21,27 +24,34 @@ export interface ResolverState { readonly data: DataState; } -interface PanningState { +/** + * Piece of redux state that models an animation for the camera. + */ +export interface CameraAnimationState { + /** + * The time when the animation began. + */ + readonly startTime: number; /** - * Screen coordinate vector representing the starting point when panning. + * The final translation when the animation is complete. */ - readonly origin: Vector2; + readonly targetTranslation: Vector2; + /** + * The effective camera position (including an in-progress user panning) at the time + * when the animation began. + */ + readonly initialTranslation: Vector2; /** - * Screen coordinate vector representing the current point when panning. + * The duration, in milliseconds, that the animation should last. Should be > 0 */ - readonly currentOffset: Vector2; + readonly duration: number; } /** - * Redux state for the virtual 'camera' used by Resolver. + * The redux state for the `useCamera` hook. */ -export interface CameraState { - /** - * Contains the starting and current position of the pointer when the user is panning the map. - */ - readonly panning?: PanningState; - +export type CameraState = { /** * Scales the coordinate system, used for zooming. Should always be between 0 and 1 */ @@ -54,7 +64,7 @@ export interface CameraState { /** * The camera world transform not counting any change from panning. When panning finishes, this value is updated to account for it. - * Use the `transform` selector to get the transform adjusted for panning. + * Use the `translation` selector to get the effective translation adjusted for panning. */ readonly translationNotCountingCurrentPanning: Vector2; @@ -62,7 +72,43 @@ export interface CameraState { * The world coordinates that the pointing device was last over. This is used during mousewheel zoom. */ readonly latestFocusedWorldCoordinates: Vector2 | null; -} +} & ( + | { + /** + * Contains the animation start time and target translation. This doesn't model the instantaneous + * progress of an animation. Instead, animation is model as functions-of-time. + */ + readonly animation: CameraAnimationState; + /** + * If the camera is animating, it must not be panning. + */ + readonly panning: undefined; + } + | { + /** + * If the camera is panning, it must not be animating. + */ + readonly animation: undefined; + /** + * Contains the starting and current position of the pointer when the user is panning the map. + */ + readonly panning: { + /** + * Screen coordinate vector representing the starting point when panning. + */ + readonly origin: Vector2; + + /** + * Screen coordinate vector representing the current point when panning. + */ + readonly currentOffset: Vector2; + }; + } + | { + readonly animation: undefined; + readonly panning: undefined; + } +); /** * State for `data` reducer which handles receiving Resolver data from the backend. @@ -73,8 +119,6 @@ export interface DataState { export type Vector2 = readonly [number, number]; -export type Vector3 = readonly [number, number, number]; - /** * A rectangle with sides that align with the `x` and `y` axises. */ @@ -121,6 +165,7 @@ export interface ProcessEvent { readonly event_type: number; readonly machine_id: string; readonly data_buffer: { + timestamp_utc: string; event_subtype_full: eventSubtypeFull; event_type_full: eventTypeFull; node_id: number; @@ -184,6 +229,48 @@ export type ProcessWithWidthMetadata = { ); /** - * String that represents the direction in which Resolver can be panned + * The constructor for a ResizeObserver */ -export type PanDirection = 'north' | 'south' | 'east' | 'west'; +interface ResizeObserverConstructor { + prototype: ResizeObserver; + new (callback: ResizeObserverCallback): ResizeObserver; +} + +/** + * Functions that introduce side effects. A React context provides these, and they may be mocked in tests. + */ +export interface SideEffectors { + /** + * A function which returns the time since epoch in milliseconds. Injected because mocking Date is tedious. + */ + timestamp: () => number; + requestAnimationFrame: typeof window.requestAnimationFrame; + cancelAnimationFrame: typeof window.cancelAnimationFrame; + ResizeObserver: ResizeObserverConstructor; +} + +export interface SideEffectSimulator { + /** + * Control the mock `SideEffectors`. + */ + controls: { + /** + * Set or get the `time` number used for `timestamp` and `requestAnimationFrame` callbacks. + */ + time: number; + /** + * Call any pending `requestAnimationFrame` callbacks. + */ + provideAnimationFrame: () => void; + /** + * Trigger `ResizeObserver` callbacks for `element` and update the mocked value for `getBoundingClientRect`. + */ + simulateElementResize: (element: Element, contentRect: DOMRect) => void; + }; + /** + * Mocked `SideEffectors`. + */ + mock: jest.Mocked> & Pick; +} + +export type ResolverStore = Store; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx index cdecd3e02bde1..3386ed4a448d5 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx @@ -6,10 +6,8 @@ import React from 'react'; import styled from 'styled-components'; -import { useSelector } from 'react-redux'; import { applyMatrix3, distance, angle } from '../lib/vector2'; -import { Vector2 } from '../types'; -import * as selectors from '../store/selectors'; +import { Vector2, Matrix3 } from '../types'; /** * A placeholder line segment view that connects process nodes. @@ -20,6 +18,7 @@ export const EdgeLine = styled( className, startPosition, endPosition, + projectionMatrix, }: { /** * A className string provided by `styled` @@ -33,12 +32,15 @@ export const EdgeLine = styled( * The postion of second point in the line segment. In 'world' coordinates. */ endPosition: Vector2; + /** + * projectionMatrix which can be used to convert `startPosition` and `endPosition` to screen coordinates. + */ + projectionMatrix: Matrix3; }) => { /** * Convert the start and end positions, which are in 'world' coordinates, * to `left` and `top` css values. */ - const projectionMatrix = useSelector(selectors.projectionMatrix); const screenStart = applyMatrix3(startPosition, projectionMatrix); const screenEnd = applyMatrix3(endPosition, projectionMatrix); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/graph_controls.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/graph_controls.tsx index 3170f8bdf867e..a1cd003949a22 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/graph_controls.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/graph_controls.tsx @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo, useContext } from 'react'; import styled from 'styled-components'; import { EuiRange, EuiPanel, EuiIcon } from '@elastic/eui'; import { useSelector, useDispatch } from 'react-redux'; -import { ResolverAction, PanDirection } from '../types'; +import { SideEffectContext } from './side_effect_context'; +import { ResolverAction, Vector2 } from '../types'; import * as selectors from '../store/selectors'; /** @@ -26,6 +27,7 @@ export const GraphControls = styled( }) => { const dispatch: (action: ResolverAction) => unknown = useDispatch(); const scalingFactor = useSelector(selectors.scalingFactor); + const { timestamp } = useContext(SideEffectContext); const handleZoomAmountChange = useCallback( (event: React.ChangeEvent | React.MouseEvent) => { @@ -61,36 +63,45 @@ export const GraphControls = styled( }); }, [dispatch]); - const handlePanClick = (panDirection: PanDirection) => { - return () => { - dispatch({ - type: 'userClickedPanControl', - payload: panDirection, - }); - }; - }; + const [handleNorth, handleEast, handleSouth, handleWest] = useMemo(() => { + const directionVectors: readonly Vector2[] = [ + [0, 1], + [1, 0], + [0, -1], + [-1, 0], + ]; + return directionVectors.map(direction => { + return () => { + const action: ResolverAction = { + type: 'userNudgedCamera', + payload: { direction, time: timestamp() }, + }; + dispatch(action); + }; + }); + }, [dispatch, timestamp]); return (
-
- -
-
@@ -116,10 +127,6 @@ export const GraphControls = styled( } ) )` - position: absolute; - top: 5px; - left: 5px; - z-index: 1; background-color: #d4d4d4; color: #333333; 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 a69504e3a5db1..d71a4d87b7eab 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx @@ -4,151 +4,62 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useState, useEffect } from 'react'; -import { Store } from 'redux'; -import { Provider, useSelector, useDispatch } from 'react-redux'; +import React from 'react'; +import { useSelector } from 'react-redux'; import styled from 'styled-components'; -import { ResolverState, ResolverAction } from '../types'; import * as selectors from '../store/selectors'; -import { useAutoUpdatingClientRect } from './use_autoupdating_client_rect'; -import { useNonPassiveWheelHandler } from './use_nonpassive_wheel_handler'; -import { ProcessEventDot } from './process_event_dot'; import { EdgeLine } from './edge_line'; +import { Panel } from './panel'; import { GraphControls } from './graph_controls'; +import { ProcessEventDot } from './process_event_dot'; +import { useCamera } from './use_camera'; + +const StyledPanel = styled(Panel)` + position: absolute; + left: 1em; + top: 1em; + max-height: calc(100% - 2em); + overflow: auto; + width: 25em; + max-width: 50%; +`; -export const AppRoot = React.memo(({ store }: { store: Store }) => { - return ( - - - - ); -}); - -const Resolver = styled( - React.memo(({ className }: { className?: string }) => { - const dispatch: (action: ResolverAction) => unknown = useDispatch(); +const StyledGraphControls = styled(GraphControls)` + position: absolute; + top: 5px; + right: 5px; +`; +export const Resolver = styled( + React.memo(function Resolver({ className }: { className?: string }) { const { processNodePositions, edgeLineSegments } = useSelector( selectors.processNodePositionsAndEdgeLineSegments ); - const [ref, setRef] = useState(null); - - const userIsPanning = useSelector(selectors.userIsPanning); - - const [elementBoundingClientRect, clientRectCallback] = useAutoUpdatingClientRect(); - - const relativeCoordinatesFromMouseEvent = useCallback( - (event: { clientX: number; clientY: number }): null | [number, number] => { - if (elementBoundingClientRect === null) { - return null; - } - return [ - event.clientX - elementBoundingClientRect.x, - event.clientY - elementBoundingClientRect.y, - ]; - }, - [elementBoundingClientRect] - ); - - useEffect(() => { - if (elementBoundingClientRect !== null) { - dispatch({ - type: 'userSetRasterSize', - payload: [elementBoundingClientRect.width, elementBoundingClientRect.height], - }); - } - }, [dispatch, elementBoundingClientRect]); - - const handleMouseDown = useCallback( - (event: React.MouseEvent) => { - const maybeCoordinates = relativeCoordinatesFromMouseEvent(event); - if (maybeCoordinates !== null) { - dispatch({ - type: 'userStartedPanning', - payload: maybeCoordinates, - }); - } - }, - [dispatch, relativeCoordinatesFromMouseEvent] - ); - - const handleMouseMove = useCallback( - (event: MouseEvent) => { - const maybeCoordinates = relativeCoordinatesFromMouseEvent(event); - if (maybeCoordinates) { - dispatch({ - type: 'userMovedPointer', - payload: maybeCoordinates, - }); - } - }, - [dispatch, relativeCoordinatesFromMouseEvent] - ); - - const handleMouseUp = useCallback(() => { - if (userIsPanning) { - dispatch({ - type: 'userStoppedPanning', - }); - } - }, [dispatch, userIsPanning]); - - const handleWheel = useCallback( - (event: WheelEvent) => { - if ( - elementBoundingClientRect !== null && - event.ctrlKey && - event.deltaY !== 0 && - event.deltaMode === 0 - ) { - event.preventDefault(); - dispatch({ - type: 'userZoomed', - // 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, - }); - } - }, - [elementBoundingClientRect, dispatch] - ); - - useEffect(() => { - window.addEventListener('mouseup', handleMouseUp, { passive: true }); - return () => { - window.removeEventListener('mouseup', handleMouseUp); - }; - }, [handleMouseUp]); - - useEffect(() => { - window.addEventListener('mousemove', handleMouseMove, { passive: true }); - return () => { - window.removeEventListener('mousemove', handleMouseMove); - }; - }, [handleMouseMove]); - - const refCallback = useCallback( - (node: null | HTMLDivElement) => { - setRef(node); - clientRectCallback(node); - }, - [clientRectCallback] - ); - - useNonPassiveWheelHandler(handleWheel, ref); + const { projectionMatrix, ref, onMouseDown } = useCamera(); return (
- -
+
{Array.from(processNodePositions).map(([processEvent, position], index) => ( - + ))} {edgeLineSegments.map(([startPosition, endPosition], index) => ( - + ))}
+ +
); }) @@ -156,8 +67,11 @@ const Resolver = styled( /** * Take up all availble space */ - display: flex; - flex-grow: 1; + &, + .resolver-graph { + display: flex; + flex-grow: 1; + } /** * The placeholder components use absolute positioning. */ @@ -166,9 +80,4 @@ const Resolver = styled( * Prevent partially visible components from showing up outside the bounds of Resolver. */ overflow: hidden; - - .resolver-graph { - display: flex; - flex-grow: 1; - } `; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx new file mode 100644 index 0000000000000..c75b73b4bceaf --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx @@ -0,0 +1,165 @@ +/* + * 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 React, { memo, useCallback, useMemo, useContext } from 'react'; +import { EuiPanel, EuiBadge, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiTitle } from '@elastic/eui'; +import { EuiHorizontalRule, EuiInMemoryTable } from '@elastic/eui'; +import euiVars from '@elastic/eui/dist/eui_theme_light.json'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { SideEffectContext } from './side_effect_context'; +import { ProcessEvent } from '../types'; +import { useResolverDispatch } from './use_resolver_dispatch'; +import * as selectors from '../store/selectors'; + +const HorizontalRule = memo(function HorizontalRule() { + return ( + + ); +}); + +export const Panel = memo(function Event({ className }: { className?: string }) { + interface ProcessTableView { + name: string; + timestamp?: Date; + event: ProcessEvent; + } + + const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments); + const { timestamp } = useContext(SideEffectContext); + + const processTableView: ProcessTableView[] = useMemo( + () => + [...processNodePositions.keys()].map(processEvent => { + const { data_buffer } = processEvent; + const date = new Date(data_buffer.timestamp_utc); + return { + name: data_buffer.process_name, + timestamp: isFinite(date.getTime()) ? date : undefined, + event: processEvent, + }; + }), + [processNodePositions] + ); + + const formatter = new Intl.DateTimeFormat(i18n.getLocale(), { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + const dispatch = useResolverDispatch(); + + const handleBringIntoViewClick = useCallback( + processTableViewItem => { + dispatch({ + type: 'userBroughtProcessIntoView', + payload: { + time: timestamp(), + process: processTableViewItem.event, + }, + }); + }, + [dispatch, timestamp] + ); + + const columns = useMemo>>( + () => [ + { + field: 'name', + name: i18n.translate('xpack.endpoint.resolver.panel.tabel.row.processNameTitle', { + defaultMessage: 'Process Name', + }), + sortable: true, + truncateText: true, + render(name: string) { + return name === '' ? ( + + {i18n.translate('xpack.endpoint.resolver.panel.table.row.valueMissingDescription', { + defaultMessage: 'Value is missing', + })} + + ) : ( + name + ); + }, + }, + { + field: 'timestamp', + name: i18n.translate('xpack.endpoint.resolver.panel.tabel.row.timestampTitle', { + defaultMessage: 'Timestamp', + }), + dataType: 'date', + sortable: true, + render(eventTimestamp?: Date) { + return eventTimestamp ? ( + formatter.format(eventTimestamp) + ) : ( + + {i18n.translate('xpack.endpoint.resolver.panel.tabel.row.timestampInvalidLabel', { + defaultMessage: 'invalid', + })} + + ); + }, + }, + { + name: i18n.translate('xpack.endpoint.resolver.panel.tabel.row.actionsTitle', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate( + 'xpack.endpoint.resolver.panel.tabel.row.actions.bringIntoViewButtonLabel', + { + defaultMessage: 'Bring into view', + } + ), + description: i18n.translate( + 'xpack.endpoint.resolver.panel.tabel.row.bringIntoViewLabel', + { + defaultMessage: 'Bring the process into view on the map.', + } + ), + type: 'icon', + icon: 'flag', + onClick: handleBringIntoViewClick, + }, + ], + }, + ], + [formatter, handleBringIntoViewClick] + ); + return ( + + +

+ {i18n.translate('xpack.endpoint.resolver.panel.title', { + defaultMessage: 'Processes', + })} +

+
+ + items={processTableView} columns={columns} sorting /> +
+ ); +}); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx index 5c3a253d619ef..384fbf90ed984 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx @@ -6,10 +6,8 @@ import React from 'react'; import styled from 'styled-components'; -import { useSelector } from 'react-redux'; import { applyMatrix3 } from '../lib/vector2'; -import { Vector2, ProcessEvent } from '../types'; -import * as selectors from '../store/selectors'; +import { Vector2, ProcessEvent, Matrix3 } from '../types'; /** * A placeholder view for a process node. @@ -20,6 +18,7 @@ export const ProcessEventDot = styled( className, position, event, + projectionMatrix, }: { /** * A `className` string provided by `styled` @@ -33,12 +32,16 @@ export const ProcessEventDot = styled( * An event which contains details about the process node. */ event: ProcessEvent; + /** + * projectionMatrix which can be used to convert `position` to screen coordinates. + */ + projectionMatrix: Matrix3; }) => { /** * Convert the position, which is in 'world' coordinates, to screen coordinates. */ - const projectionMatrix = useSelector(selectors.projectionMatrix); const [left, top] = applyMatrix3(position, projectionMatrix); + const style = { left: (left - 20).toString() + 'px', top: (top - 20).toString() + 'px', diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/side_effect_context.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/view/side_effect_context.ts new file mode 100644 index 0000000000000..ab7f41d815026 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/side_effect_context.ts @@ -0,0 +1,27 @@ +/* + * 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 { createContext, Context } from 'react'; +import ResizeObserver from 'resize-observer-polyfill'; +import { SideEffectors } from '../types'; + +/** + * React context that provides 'side-effectors' which we need to mock during testing. + */ +const sideEffectors: SideEffectors = { + timestamp: () => Date.now(), + requestAnimationFrame(...args) { + return window.requestAnimationFrame(...args); + }, + cancelAnimationFrame(...args) { + return window.cancelAnimationFrame(...args); + }, + ResizeObserver, +}; + +/** + * The default values are used in production, tests can provide mock values using `SideEffectSimulator`. + */ +export const SideEffectContext: Context = createContext(sideEffectors); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/side_effect_simulator.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/view/side_effect_simulator.ts new file mode 100644 index 0000000000000..3e80b6a8459f7 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/side_effect_simulator.ts @@ -0,0 +1,170 @@ +/* + * 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 { act } from '@testing-library/react'; +import { SideEffectSimulator } from '../types'; + +/** + * Create mock `SideEffectors` for `SideEffectContext.Provider`. The `control` + * object is used to control the mocks. + */ +export const sideEffectSimulator: () => SideEffectSimulator = () => { + // The set of mock `ResizeObserver` instances that currently exist + const resizeObserverInstances: Set = new Set(); + + // A map of `Element`s to their fake `DOMRect`s + const contentRects: Map = new Map(); + + /** + * Simulate an element's size changing. This will trigger any `ResizeObserverCallback`s which + * are listening for this element's size changes. It will also cause `element.getBoundingClientRect` to + * return `contentRect` + */ + const simulateElementResize: (target: Element, contentRect: DOMRect) => void = ( + target, + contentRect + ) => { + contentRects.set(target, contentRect); + for (const instance of resizeObserverInstances) { + instance.simulateElementResize(target, contentRect); + } + }; + + /** + * Get the simulate `DOMRect` for `element`. + */ + const contentRectForElement: (target: Element) => DOMRect = target => { + if (contentRects.has(target)) { + return contentRects.get(target)!; + } + const domRect: DOMRect = { + x: 0, + y: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + width: 0, + height: 0, + toJSON() { + return this; + }, + }; + return domRect; + }; + + /** + * Change `Element.prototype.getBoundingClientRect` to return our faked values. + */ + jest + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function(this: Element) { + return contentRectForElement(this); + }); + + /** + * A mock implementation of `ResizeObserver` that works with our fake `getBoundingClientRect` and `simulateElementResize` + */ + class MockResizeObserver implements ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) { + resizeObserverInstances.add(this); + } + private elements: Set = new Set(); + /** + * Simulate `target` changing it size to `contentRect`. + */ + simulateElementResize(target: Element, contentRect: DOMRect) { + if (this.elements.has(target)) { + const entries: ResizeObserverEntry[] = [{ target, contentRect }]; + this.callback(entries, this); + } + } + observe(target: Element) { + this.elements.add(target); + } + unobserve(target: Element) { + this.elements.delete(target); + } + disconnect() { + this.elements.clear(); + } + } + + /** + * milliseconds since epoch, faked. + */ + let mockTime: number = 0; + + /** + * A counter allowing us to give a unique ID for each call to `requestAnimationFrame`. + */ + let frameRequestedCallbacksIDCounter: number = 0; + + /** + * A map of requestAnimationFrame IDs to the related callbacks. + */ + const frameRequestedCallbacks: Map = new Map(); + + /** + * Trigger any pending `requestAnimationFrame` callbacks. Passes `mockTime` as the timestamp. + */ + const provideAnimationFrame: () => void = () => { + act(() => { + // Iterate the values, and clear the data set before calling the callbacks because the callbacks will repopulate the dataset synchronously in this testing framework. + const values = [...frameRequestedCallbacks.values()]; + frameRequestedCallbacks.clear(); + for (const callback of values) { + callback(mockTime); + } + }); + }; + + /** + * Provide a fake ms timestamp + */ + const timestamp = jest.fn(() => mockTime); + + /** + * Fake `requestAnimationFrame`. + */ + const requestAnimationFrame = jest.fn((callback: FrameRequestCallback): number => { + const id = frameRequestedCallbacksIDCounter++; + frameRequestedCallbacks.set(id, callback); + return id; + }); + + /** + * fake `cancelAnimationFrame`. + */ + const cancelAnimationFrame = jest.fn((id: number) => { + frameRequestedCallbacks.delete(id); + }); + + const retval: SideEffectSimulator = { + controls: { + provideAnimationFrame, + + /** + * Change the mock time value + */ + set time(nextTime: number) { + mockTime = nextTime; + }, + get time() { + return mockTime; + }, + + simulateElementResize, + }, + mock: { + requestAnimationFrame, + cancelAnimationFrame, + timestamp, + ResizeObserver: MockResizeObserver, + }, + }; + return retval; +}; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_autoupdating_client_rect.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_autoupdating_client_rect.tsx deleted file mode 100644 index 5f13995de1c2a..0000000000000 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_autoupdating_client_rect.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 { useCallback, useState, useEffect, useRef } from 'react'; -import ResizeObserver from 'resize-observer-polyfill'; - -/** - * Returns a nullable DOMRect and a ref callback. Pass the refCallback to the - * `ref` property of a native element and this hook will return a DOMRect for - * it by calling `getBoundingClientRect`. This hook will observe the element - * with a resize observer and call getBoundingClientRect again after resizes. - * - * Note that the changes to the position of the element aren't automatically - * tracked. So if the element's position moves for some reason, be sure to - * handle that. - */ -export function useAutoUpdatingClientRect(): [DOMRect | null, (node: Element | null) => void] { - const [rect, setRect] = useState(null); - const nodeRef = useRef(null); - const ref = useCallback((node: Element | null) => { - nodeRef.current = node; - if (node !== null) { - setRect(node.getBoundingClientRect()); - } - }, []); - useEffect(() => { - if (nodeRef.current !== null) { - const resizeObserver = new ResizeObserver(entries => { - if (nodeRef.current !== null && nodeRef.current === entries[0].target) { - setRect(nodeRef.current.getBoundingClientRect()); - } - }); - resizeObserver.observe(nodeRef.current); - return () => { - resizeObserver.disconnect(); - }; - } - }, [nodeRef]); - return [rect, ref]; -} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx new file mode 100644 index 0000000000000..85e1d4e694b15 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx @@ -0,0 +1,197 @@ +/* + * 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. + */ + +/** + * This import must be hoisted as it uses `jest.mock`. Is there a better way? Mocking is not good. + */ +import React from 'react'; +import { render, act, RenderResult, fireEvent } from '@testing-library/react'; +import { useCamera } from './use_camera'; +import { Provider } from 'react-redux'; +import * as selectors from '../store/selectors'; +import { storeFactory } from '../store'; +import { + Matrix3, + ResolverAction, + ResolverStore, + ProcessEvent, + SideEffectSimulator, +} from '../types'; +import { SideEffectContext } from './side_effect_context'; +import { applyMatrix3 } from '../lib/vector2'; +import { sideEffectSimulator } from './side_effect_simulator'; + +describe('useCamera on an unpainted element', () => { + let element: HTMLElement; + let projectionMatrix: Matrix3; + const testID = 'camera'; + let reactRenderResult: RenderResult; + let store: ResolverStore; + let simulator: SideEffectSimulator; + beforeEach(async () => { + ({ store } = storeFactory()); + + const Test = function Test() { + const camera = useCamera(); + const { ref, onMouseDown } = camera; + projectionMatrix = camera.projectionMatrix; + return
; + }; + + simulator = sideEffectSimulator(); + + reactRenderResult = render( + + + + + + ); + + const { findByTestId } = reactRenderResult; + element = await findByTestId(testID); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should be usable in React', async () => { + expect(element).toBeInTheDocument(); + }); + test('returns a projectionMatrix that changes everything to 0', () => { + expect(applyMatrix3([0, 0], projectionMatrix)).toEqual([0, 0]); + }); + describe('which has been resized to 800x600', () => { + const width = 800; + const height = 600; + const leftMargin = 20; + const topMargin = 20; + const centerX = width / 2 + leftMargin; + const centerY = height / 2 + topMargin; + beforeEach(() => { + act(() => { + simulator.controls.simulateElementResize(element, { + width, + height, + left: leftMargin, + top: topMargin, + right: leftMargin + width, + bottom: topMargin + height, + x: leftMargin, + y: topMargin, + toJSON() { + return this; + }, + }); + }); + }); + test('provides a projection matrix that inverts the y axis and translates 400,300 (center of the element)', () => { + expect(applyMatrix3([0, 0], projectionMatrix)).toEqual([400, 300]); + }); + describe('when the user presses the mousedown button in the middle of the element', () => { + beforeEach(() => { + fireEvent.mouseDown(element, { + clientX: centerX, + clientY: centerY, + }); + }); + describe('when the user moves the mouse 50 pixels to the right', () => { + beforeEach(() => { + fireEvent.mouseMove(element, { + clientX: centerX + 50, + clientY: centerY, + }); + }); + it('should project [0, 0] in world corrdinates 50 pixels to the right of the center of the element', () => { + expect(applyMatrix3([0, 0], projectionMatrix)).toEqual([450, 300]); + }); + }); + }); + + describe('when the user uses the mousewheel w/ ctrl held down', () => { + beforeEach(() => { + fireEvent.wheel(element, { + ctrlKey: true, + deltaY: -10, + deltaMode: 0, + }); + }); + it('should zoom in', () => { + expect(projectionMatrix).toMatchInlineSnapshot(` + Array [ + 1.0635255481707058, + 0, + 400, + 0, + -1.0635255481707058, + 300, + 0, + 0, + 0, + ] + `); + }); + }); + + it('should not initially request an animation frame', () => { + expect(simulator.mock.requestAnimationFrame).not.toHaveBeenCalled(); + }); + describe('when the camera begins animation', () => { + let process: ProcessEvent; + beforeEach(() => { + // At this time, processes are provided via mock data. In the future, this test will have to provide those mocks. + const processes: ProcessEvent[] = [ + ...selectors + .processNodePositionsAndEdgeLineSegments(store.getState()) + .processNodePositions.keys(), + ]; + process = processes[processes.length - 1]; + simulator.controls.time = 0; + const action: ResolverAction = { + type: 'userBroughtProcessIntoView', + payload: { + time: simulator.controls.time, + process, + }, + }; + act(() => { + store.dispatch(action); + }); + }); + + it('should request animation frames in a loop', () => { + const animationDuration = 1000; + // When the animation begins, the camera should request an animation frame. + expect(simulator.mock.requestAnimationFrame).toHaveBeenCalledTimes(1); + + // Update the time so that the animation is partially complete. + simulator.controls.time = animationDuration / 5; + // Provide the animation frame, allowing the camera to rerender. + simulator.controls.provideAnimationFrame(); + + // The animation is not complete, so the camera should request another animation frame. + expect(simulator.mock.requestAnimationFrame).toHaveBeenCalledTimes(2); + + // Update the camera so that the animation is nearly complete. + simulator.controls.time = (animationDuration / 10) * 9; + + // Provide the animation frame + simulator.controls.provideAnimationFrame(); + + // Since the animation isn't complete, it should request another frame + expect(simulator.mock.requestAnimationFrame).toHaveBeenCalledTimes(3); + + // Animation lasts 1000ms, so this should end it + simulator.controls.time = animationDuration * 1.1; + + // Provide the last frame + simulator.controls.provideAnimationFrame(); + + // Since animation is complete, it should not have requseted another frame + expect(simulator.mock.requestAnimationFrame).toHaveBeenCalledTimes(3); + }); + }); + }); +}); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.ts new file mode 100644 index 0000000000000..54940b8383f7a --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.ts @@ -0,0 +1,307 @@ +/* + * 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 React, { + useCallback, + useState, + useEffect, + useRef, + useLayoutEffect, + useContext, +} from 'react'; +import { useSelector } from 'react-redux'; +import { SideEffectContext } from './side_effect_context'; +import { Matrix3 } from '../types'; +import { useResolverDispatch } from './use_resolver_dispatch'; +import * as selectors from '../store/selectors'; + +export function useCamera(): { + /** + * A function to pass to a React element's `ref` property. Used to attach + * native event listeners and to measure the DOM node. + */ + ref: (node: HTMLDivElement | null) => void; + onMouseDown: React.MouseEventHandler; + /** + * A 3x3 transformation matrix used to convert a `vector2` from 'world' coordinates + * to screen coordinates. + */ + projectionMatrix: Matrix3; +} { + const dispatch = useResolverDispatch(); + const sideEffectors = useContext(SideEffectContext); + + const [ref, setRef] = useState(null); + + /** + * The position of a thing, as a `Vector2`, is multiplied by the projection matrix + * to determine where it belongs on the screen. + * The projection matrix changes over time if the camera is currently animating. + */ + const projectionMatrixAtTime = useSelector(selectors.projectionMatrix); + + /** + * Use a ref to refer to the `projectionMatrixAtTime` function. The rAF loop + * accesses this and sets state during the rAF cycle. If the rAF loop + * effect read this directly from the selector, the rAF loop would need to + * be re-inited each time this function changed. The `projectionMatrixAtTime` function + * changes each frame during an animation, so the rAF loop would be causing + * itself to reinit on each frame. This would necessarily cause a drop in FPS as there + * would be a dead zone between when the rAF loop stopped and restarted itself. + */ + const projectionMatrixAtTimeRef = useRef(); + + /** + * The projection matrix is stateful, depending on the current time. + * When the projection matrix changes, the component should be rerendered. + */ + const [projectionMatrix, setProjectionMatrix] = useState( + projectionMatrixAtTime(sideEffectors.timestamp()) + ); + + const userIsPanning = useSelector(selectors.userIsPanning); + const isAnimatingAtTime = useSelector(selectors.isAnimating); + + const [elementBoundingClientRect, clientRectCallback] = useAutoUpdatingClientRect(); + + /** + * For an event with clientX and clientY, return [clientX, clientY] - the top left corner of the `ref` element + */ + const relativeCoordinatesFromMouseEvent = useCallback( + (event: { clientX: number; clientY: number }): null | [number, number] => { + if (elementBoundingClientRect === null) { + return null; + } + return [ + event.clientX - elementBoundingClientRect.x, + event.clientY - elementBoundingClientRect.y, + ]; + }, + [elementBoundingClientRect] + ); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + const maybeCoordinates = relativeCoordinatesFromMouseEvent(event); + if (maybeCoordinates !== null) { + dispatch({ + type: 'userStartedPanning', + payload: { screenCoordinates: maybeCoordinates, time: sideEffectors.timestamp() }, + }); + } + }, + [dispatch, relativeCoordinatesFromMouseEvent, sideEffectors] + ); + + const handleMouseMove = useCallback( + (event: MouseEvent) => { + const maybeCoordinates = relativeCoordinatesFromMouseEvent(event); + if (maybeCoordinates) { + dispatch({ + type: 'userMovedPointer', + payload: { + screenCoordinates: maybeCoordinates, + time: sideEffectors.timestamp(), + }, + }); + } + }, + [dispatch, relativeCoordinatesFromMouseEvent, sideEffectors] + ); + + const handleMouseUp = useCallback(() => { + if (userIsPanning) { + dispatch({ + type: 'userStoppedPanning', + payload: { + time: sideEffectors.timestamp(), + }, + }); + } + }, [dispatch, sideEffectors, userIsPanning]); + + const handleWheel = useCallback( + (event: WheelEvent) => { + if ( + elementBoundingClientRect !== null && + event.ctrlKey && + event.deltaY !== 0 && + event.deltaMode === 0 + ) { + event.preventDefault(); + dispatch({ + type: 'userZoomed', + payload: { + /** + * 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 + */ + zoomChange: event.deltaY / -elementBoundingClientRect.height, + time: sideEffectors.timestamp(), + }, + }); + } + }, + [elementBoundingClientRect, dispatch, sideEffectors] + ); + + const refCallback = useCallback( + (node: null | HTMLDivElement) => { + setRef(node); + clientRectCallback(node); + }, + [clientRectCallback] + ); + + useEffect(() => { + window.addEventListener('mouseup', handleMouseUp, { passive: true }); + return () => { + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [handleMouseUp]); + + useEffect(() => { + window.addEventListener('mousemove', handleMouseMove, { passive: true }); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + }; + }, [handleMouseMove]); + + /** + * Register an event handler directly on `elementRef` for the `wheel` event, with no options + * React sets native event listeners on the `window` and calls provided handlers via event propagation. + * As of Chrome 73, `'wheel'` events on `window` are automatically treated as 'passive'. + * If you don't need to call `event.preventDefault` then you should use regular React event handling instead. + */ + useEffect(() => { + if (ref !== null) { + ref.addEventListener('wheel', handleWheel); + return () => { + ref.removeEventListener('wheel', handleWheel); + }; + } + }, [ref, handleWheel]); + + /** + * Allow rAF loop to indirectly read projectionMatrixAtTime via a ref. Since it also + * sets projectionMatrixAtTime, relying directly on it causes considerable jank. + */ + useLayoutEffect(() => { + projectionMatrixAtTimeRef.current = projectionMatrixAtTime; + }, [projectionMatrixAtTime]); + + /** + * Keep the projection matrix state in sync with the selector. + * This isn't needed during animation. + */ + useLayoutEffect(() => { + // Update the projection matrix that we return, rerendering any component that uses this. + setProjectionMatrix(projectionMatrixAtTime(sideEffectors.timestamp())); + }, [projectionMatrixAtTime, sideEffectors]); + + /** + * When animation is happening, run a rAF loop, when it is done, stop. + */ + useLayoutEffect( + () => { + const startDate = sideEffectors.timestamp(); + if (isAnimatingAtTime(startDate)) { + let rafRef: null | number = null; + const handleFrame = () => { + // Get the current timestamp, now that the frame is ready + const date = sideEffectors.timestamp(); + if (projectionMatrixAtTimeRef.current !== undefined) { + // Update the projection matrix, triggering a rerender + setProjectionMatrix(projectionMatrixAtTimeRef.current(date)); + } + // If we are still animating, request another frame, continuing the loop + if (isAnimatingAtTime(date)) { + rafRef = sideEffectors.requestAnimationFrame(handleFrame); + } else { + /** + * `isAnimatingAtTime` was false, meaning that the animation is complete. + * Do not request another animation frame. + */ + rafRef = null; + } + }; + // Kick off the loop by requestion an animation frame + rafRef = sideEffectors.requestAnimationFrame(handleFrame); + + /** + * This function cancels the animation frame request. The cancel function + * will occur when the component is unmounted. It will also occur when a dependency + * changes. + * + * The `isAnimatingAtTime` dependency will be changed if the animation state changes. The animation + * state only changes when the user animates again (e.g. brings a different node into view, or nudges the + * camera.) + */ + return () => { + // Cancel the animation frame. + if (rafRef !== null) { + sideEffectors.cancelAnimationFrame(rafRef); + } + }; + } + }, + /** + * `isAnimatingAtTime` is a function created with `reselect`. The function reference will be changed when + * the animation state changes. When the function reference has changed, you *might* be animating. + */ + [isAnimatingAtTime, sideEffectors] + ); + + useEffect(() => { + if (elementBoundingClientRect !== null) { + dispatch({ + type: 'userSetRasterSize', + payload: [elementBoundingClientRect.width, elementBoundingClientRect.height], + }); + } + }, [dispatch, elementBoundingClientRect]); + + return { + ref: refCallback, + onMouseDown: handleMouseDown, + projectionMatrix, + }; +} + +/** + * Returns a nullable DOMRect and a ref callback. Pass the refCallback to the + * `ref` property of a native element and this hook will return a DOMRect for + * it by calling `getBoundingClientRect`. This hook will observe the element + * with a resize observer and call getBoundingClientRect again after resizes. + * + * Note that the changes to the position of the element aren't automatically + * tracked. So if the element's position moves for some reason, be sure to + * handle that. + */ +function useAutoUpdatingClientRect(): [DOMRect | null, (node: Element | null) => void] { + const [rect, setRect] = useState(null); + const nodeRef = useRef(null); + const ref = useCallback((node: Element | null) => { + nodeRef.current = node; + if (node !== null) { + setRect(node.getBoundingClientRect()); + } + }, []); + const { ResizeObserver } = useContext(SideEffectContext); + useEffect(() => { + if (nodeRef.current !== null) { + const resizeObserver = new ResizeObserver(entries => { + if (nodeRef.current !== null && nodeRef.current === entries[0].target) { + setRect(nodeRef.current.getBoundingClientRect()); + } + }); + resizeObserver.observe(nodeRef.current); + return () => { + resizeObserver.disconnect(); + }; + } + }, [ResizeObserver, nodeRef]); + return [rect, ref]; +} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_nonpassive_wheel_handler.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_nonpassive_wheel_handler.tsx deleted file mode 100644 index a0738bcf4d14c..0000000000000 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_nonpassive_wheel_handler.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { useEffect } from 'react'; -/** - * Register an event handler directly on `elementRef` for the `wheel` event, with no options - * React sets native event listeners on the `window` and calls provided handlers via event propagation. - * As of Chrome 73, `'wheel'` events on `window` are automatically treated as 'passive'. - * If you don't need to call `event.preventDefault` then you should use regular React event handling instead. - */ -export function useNonPassiveWheelHandler( - handler: (event: WheelEvent) => void, - elementRef: HTMLElement | null -) { - useEffect(() => { - if (elementRef !== null) { - elementRef.addEventListener('wheel', handler); - return () => { - elementRef.removeEventListener('wheel', handler); - }; - } - }, [elementRef, handler]); -} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_resolver_dispatch.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_resolver_dispatch.ts new file mode 100644 index 0000000000000..a993a4ed595e1 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_resolver_dispatch.ts @@ -0,0 +1,13 @@ +/* + * 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 { useDispatch } from 'react-redux'; +import { ResolverAction } from '../types'; + +/** + * Call `useDispatch`, but only accept `ResolverAction` actions. + */ +export const useResolverDispatch: () => (action: ResolverAction) => unknown = useDispatch; diff --git a/yarn.lock b/yarn.lock index 1158fce12829e..0a55e3d7c7850 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25277,6 +25277,11 @@ redux-actions@2.6.5: reduce-reducers "^0.4.3" to-camel-case "^1.0.0" +redux-devtools-extension@^2.13.8: + version "2.13.8" + resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz#37b982688626e5e4993ff87220c9bbb7cd2d96e1" + integrity sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg== + redux-observable@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/redux-observable/-/redux-observable-1.0.0.tgz#780ff2455493eedcef806616fe286b454fd15d91"