Skip to content

Commit

Permalink
THATS IT
Browse files Browse the repository at this point in the history
  • Loading branch information
oatkiller committed Feb 12, 2020
1 parent 4d09b70 commit ce6dc98
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 46 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion x-pack/plugins/endpoint/public/embeddables/resolver/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<SideEffectors> = createContext(sideEffectors);
Original file line number Diff line number Diff line change
Expand Up @@ -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<MockResizeObserver> = new Set();

// A map of `Element`s to their fake `DOMRect`s
const contentRects: Map<Element, DOMRect> = 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
Expand All @@ -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)!;
Expand All @@ -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<Element> = new Set();
/**
* Simulate `target` changing it size to `contentRect`.
*/
simulateElementResize(target: Element, contentRect: DOMRect) {
if (this.elements.has(target)) {
const entries: ResizeObserverEntry[] = [{ target, contentRect }];
Expand All @@ -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<number, FrameRequestCallback> = 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.
Expand All @@ -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);
});
Expand All @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit ce6dc98

Please sign in to comment.