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 index 4b0d4213b2bc60e..795344d8af09259 100644 --- 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 @@ -73,8 +73,8 @@ describe('when the camera is created', () => { store.dispatch(action); }); describe('when the animation is in progress', () => { - let translationAtIntervals: readonly Vector2[]; - let scaleAtIntervals: readonly Vector2[]; + let translationAtIntervals: Vector2[]; + let scaleAtIntervals: Vector2[]; beforeEach(() => { translationAtIntervals = []; scaleAtIntervals = []; 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 702bc6863e4f8e8..226e36f63d788eb 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 @@ -34,32 +34,32 @@ function animationIsActive(animation: CameraAnimationState, time: number): boole /** * 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. + * 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. + * 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. + * 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. + * 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. + * 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. + * 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. + * 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 const scale: (state: CameraState) => (time: number) => Vector2 = createSelector( 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 25d08a8c347ed5e..4d12e656205faec 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts @@ -31,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); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts index 3ebf2cbc4ed89ca..6c6936d377deace 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts @@ -75,7 +75,8 @@ export type CameraState = { } & ( | { /** - * Contains the animation start time and target translation. + * 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; /** 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 index f8899dd6afed8c5..ab7f41d8150268d 100644 --- 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 @@ -7,6 +7,9 @@ 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) { @@ -17,4 +20,8 @@ const sideEffectors: SideEffectors = { }, 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 index 7920dd05066093b..3e80b6a8459f7cf 100644 --- 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 @@ -7,9 +7,22 @@ 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 @@ -19,6 +32,10 @@ export const sideEffectSimulator: () => SideEffectSimulator = () => { instance.simulateElementResize(target, contentRect); } }; + + /** + * Get the simulate `DOMRect` for `element`. + */ const contentRectForElement: (target: Element) => DOMRect = target => { if (contentRects.has(target)) { return contentRects.get(target)!; @@ -38,16 +55,27 @@ export const sideEffectSimulator: () => SideEffectSimulator = () => { }; 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 }]; @@ -64,9 +92,25 @@ export const sideEffectSimulator: () => SideEffectSimulator = () => { 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. @@ -78,12 +122,23 @@ export const sideEffectSimulator: () => SideEffectSimulator = () => { }); }; + /** + * 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); }); @@ -92,12 +147,16 @@ export const sideEffectSimulator: () => SideEffectSimulator = () => { controls: { provideAnimationFrame, + /** + * Change the mock time value + */ set time(nextTime: number) { mockTime = nextTime; }, get time() { return mockTime; }, + simulateElementResize, }, mock: { 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 index d41346b7003d883..54940b8383f7a83 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.ts @@ -197,32 +197,62 @@ export function useCamera(): { * 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]); - useLayoutEffect(() => { - const startDate = sideEffectors.timestamp(); - if (isAnimatingAtTime(startDate)) { - let rafRef: null | number = null; - const handleFrame = () => { - const date = sideEffectors.timestamp(); - if (projectionMatrixAtTimeRef.current !== undefined) { - setProjectionMatrix(projectionMatrixAtTimeRef.current(date)); - } - if (isAnimatingAtTime(date)) { - rafRef = sideEffectors.requestAnimationFrame(handleFrame); - } else { - rafRef = null; - } - }; - rafRef = sideEffectors.requestAnimationFrame(handleFrame); - return () => { - if (rafRef !== null) { - sideEffectors.cancelAnimationFrame(rafRef); - } - }; - } - }, [isAnimatingAtTime, 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) {