-
-
+
{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"