From 7eb934e80c29447591e9a7735f85fbaba9b9c521 Mon Sep 17 00:00:00 2001 From: Pedro Jaramillo Date: Tue, 21 Jan 2020 16:00:52 -0500 Subject: [PATCH] Resolver zoom, pan, and center controls (#55221) * Resolver zoom, pan, and center controls * add tests, fix north panning * fix type issue * update west and east panning to behave like google maps --- .../resolver/store/camera/action.ts | 23 ++- .../resolver/store/camera/panning.test.ts | 62 ++++++++ .../resolver/store/camera/reducer.ts | 38 ++++- .../resolver/store/camera/selectors.ts | 7 + .../resolver/store/camera/zooming.test.ts | 27 +++- .../embeddables/resolver/store/selectors.ts | 5 + .../public/embeddables/resolver/types.ts | 5 + .../resolver/view/graph_controls.tsx | 148 ++++++++++++++++++ .../embeddables/resolver/view/index.tsx | 28 ++-- 9 files changed, 327 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/endpoint/public/embeddables/resolver/view/graph_controls.tsx 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 090d5de901318e..4153070ab04e7e 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,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Vector2 } from '../../types'; +import { Vector2, PanDirection } from '../../types'; interface UserSetZoomLevel { readonly type: 'userSetZoomLevel'; @@ -14,6 +14,14 @@ interface UserSetZoomLevel { readonly payload: number; } +interface UserClickedZoomOut { + readonly type: 'userClickedZoomOut'; +} + +interface UserClickedZoomIn { + readonly type: 'userClickedZoomIn'; +} + interface UserZoomed { readonly type: 'userZoomed'; /** @@ -56,6 +64,14 @@ interface UserStoppedPanning { readonly type: 'userStoppedPanning'; } +interface UserClickedPanControl { + readonly type: 'userClickedPanControl'; + /** + * String that represents the direction in which Resolver can be panned + */ + readonly payload: PanDirection; +} + interface UserMovedPointer { readonly type: 'userMovedPointer'; /** @@ -72,4 +88,7 @@ export type CameraAction = | UserStartedPanning | UserStoppedPanning | UserZoomed - | UserMovedPointer; + | UserMovedPointer + | UserClickedZoomOut + | UserClickedZoomIn + | UserClickedPanControl; 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 c09320182e3be0..17401a63b5ae8f 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 @@ -58,4 +58,66 @@ describe('panning interaction', () => { }); }); }); + 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 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, + ] + `); + }); + }); + }); }); 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 b7229240684f15..7c4678a4f1dc13 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 @@ -9,7 +9,7 @@ import { applyMatrix3, subtract } from '../../lib/vector2'; import { userIsPanning, translation, projectionMatrix, inverseProjectionMatrix } from './selectors'; import { clamp } from '../../lib/math'; -import { CameraState, ResolverAction } from '../../types'; +import { CameraState, ResolverAction, Vector2 } from '../../types'; import { scaleToZoom } from './scale_to_zoom'; function initialState(): CameraState { @@ -34,6 +34,16 @@ export const cameraReducer: Reducer = ( ...state, scalingFactor: clamp(action.payload, 0, 1), }; + } else if (action.type === 'userClickedZoomIn') { + return { + ...state, + scalingFactor: clamp(state.scalingFactor + 0.1, 0, 1), + }; + } else if (action.type === 'userClickedZoomOut') { + return { + ...state, + scalingFactor: clamp(state.scalingFactor - 0.1, 0, 1), + }; } else if (action.type === 'userZoomed') { const stateWithNewScaling: CameraState = { ...state, @@ -100,6 +110,32 @@ export const cameraReducer: Reducer = ( } else { return state; } + } else if (action.type === 'userClickedPanControl') { + const panDirection = action.payload; + /** + * Delta amount will be in the range of 20 -> 40 depending on the scalingFactor + */ + 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]; + } + + return { + ...state, + translationNotCountingCurrentPanning: [ + state.translationNotCountingCurrentPanning[0] + delta[0], + state.translationNotCountingCurrentPanning[1] + delta[1], + ], + }; } else if (action.type === 'userSetRasterSize') { /** * Handle resizes of the Resolver component. We need to know the size in order to convert between screen 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 a7b0bbf66052d0..53ffe6dd073fa6 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 @@ -182,6 +182,13 @@ export const scale = (state: CameraState): Vector2 => { return [value, value]; }; +/** + * Scales the coordinate system, used for zooming. Should always be between 0 and 1 + */ +export const scalingFactor = (state: CameraState): CameraState['scalingFactor'] => { + return state.scalingFactor; +}; + /** * Whether or not the user is current panning the map. */ 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 4b0915282e86f2..abc113d5999ffe 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 @@ -8,7 +8,7 @@ import { CameraAction } from './action'; import { cameraReducer } from './reducer'; import { createStore, Store } from 'redux'; import { CameraState, AABB } from '../../types'; -import { viewableBoundingBox, inverseProjectionMatrix } from './selectors'; +import { viewableBoundingBox, inverseProjectionMatrix, scalingFactor } from './selectors'; import { expectVectorsToBeClose } from './test_helpers'; import { scaleToZoom } from './scale_to_zoom'; import { applyMatrix3 } from '../../lib/vector2'; @@ -151,4 +151,29 @@ describe('zooming', () => { }); }); }); + describe('zoom controls', () => { + let previousScalingFactor: CameraState['scalingFactor']; + describe('when user clicks on zoom in button', () => { + beforeEach(() => { + previousScalingFactor = scalingFactor(store.getState()); + const action: CameraAction = { type: 'userClickedZoomIn' }; + store.dispatch(action); + }); + it('the scaling factor should increase by 0.1 units', () => { + const actual = scalingFactor(store.getState()); + expect(actual).toEqual(previousScalingFactor + 0.1); + }); + }); + describe('when user clicks on zoom out button', () => { + beforeEach(() => { + previousScalingFactor = scalingFactor(store.getState()); + const action: CameraAction = { type: 'userClickedZoomOut' }; + store.dispatch(action); + }); + it('the scaling factor should decrease by 0.1 units', () => { + const actual = scalingFactor(store.getState()); + expect(actual).toEqual(previousScalingFactor - 0.1); + }); + }); + }); }); 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 30adf172030969..eb1c1fec369957 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,11 @@ export const inverseProjectionMatrix = composeSelectors( */ export const scale = composeSelectors(cameraStateSelector, cameraSelectors.scale); +/** + * Scales the coordinate system, used for zooming. Should always be between 0 and 1 + */ +export const scalingFactor = composeSelectors(cameraStateSelector, cameraSelectors.scalingFactor); + /** * Whether or not the user is current panning the map. */ diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts index eae9ebf9ee9a63..f2ae9785446f7a 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts @@ -182,3 +182,8 @@ export type ProcessWithWidthMetadata = { firstChildWidth: null; } ); + +/** + * String that represents the direction in which Resolver can be panned + */ +export type PanDirection = 'north' | 'south' | 'east' | 'west'; 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 new file mode 100644 index 00000000000000..3170f8bdf867ea --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/graph_controls.tsx @@ -0,0 +1,148 @@ +/* + * 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 } 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 * as selectors from '../store/selectors'; + +/** + * Controls for zooming, panning, and centering in Resolver + */ +export const GraphControls = styled( + React.memo( + ({ + className, + }: { + /** + * A className string provided by `styled` + */ + className?: string; + }) => { + const dispatch: (action: ResolverAction) => unknown = useDispatch(); + const scalingFactor = useSelector(selectors.scalingFactor); + + const handleZoomAmountChange = useCallback( + (event: React.ChangeEvent | React.MouseEvent) => { + const valueAsNumber = parseFloat( + (event as React.ChangeEvent).target.value + ); + if (isNaN(valueAsNumber) === false) { + dispatch({ + type: 'userSetZoomLevel', + payload: valueAsNumber, + }); + } + }, + [dispatch] + ); + + const handleCenterClick = useCallback(() => { + dispatch({ + type: 'userSetPositionOfCamera', + payload: [0, 0], + }); + }, [dispatch]); + + const handleZoomOutClick = useCallback(() => { + dispatch({ + type: 'userClickedZoomOut', + }); + }, [dispatch]); + + const handleZoomInClick = useCallback(() => { + dispatch({ + type: 'userClickedZoomIn', + }); + }, [dispatch]); + + const handlePanClick = (panDirection: PanDirection) => { + return () => { + dispatch({ + type: 'userClickedPanControl', + payload: panDirection, + }); + }; + }; + + return ( +
+ +
+ +
+
+ + + +
+
+ +
+
+ + + + + +
+ ); + } + ) +)` + position: absolute; + top: 5px; + left: 5px; + z-index: 1; + background-color: #d4d4d4; + color: #333333; + + .zoom-controls { + display: flex; + flex-direction: column; + align-items: center; + padding: 5px 0px; + + .zoom-slider { + width: 20px; + height: 150px; + margin: 5px 0px 2px 0px; + + input[type='range'] { + width: 150px; + height: 20px; + transform-origin: 75px 75px; + transform: rotate(-90deg); + } + } + } + .panning-controls { + text-align: center; + } +`; 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 d252988d0cdcc0..a69504e3a5db12 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx @@ -14,6 +14,7 @@ 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 { GraphControls } from './graph_controls'; export const AppRoot = React.memo(({ store }: { store: Store }) => { return ( @@ -138,18 +139,16 @@ const Resolver = styled( useNonPassiveWheelHandler(handleWheel, ref); return ( -
- {Array.from(processNodePositions).map(([processEvent, position], index) => ( - - ))} - {edgeLineSegments.map(([startPosition, endPosition], index) => ( - - ))} +
+ +
+ {Array.from(processNodePositions).map(([processEvent, position], index) => ( + + ))} + {edgeLineSegments.map(([startPosition, endPosition], index) => ( + + ))} +
); }) @@ -167,4 +166,9 @@ const Resolver = styled( * Prevent partially visible components from showing up outside the bounds of Resolver. */ overflow: hidden; + + .resolver-graph { + display: flex; + flex-grow: 1; + } `;