Skip to content

Commit 7eb934e

Browse files
authored
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
1 parent 0cd1733 commit 7eb934e

File tree

9 files changed

+327
-16
lines changed

9 files changed

+327
-16
lines changed

x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/action.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

7-
import { Vector2 } from '../../types';
7+
import { Vector2, PanDirection } from '../../types';
88

99
interface UserSetZoomLevel {
1010
readonly type: 'userSetZoomLevel';
@@ -14,6 +14,14 @@ interface UserSetZoomLevel {
1414
readonly payload: number;
1515
}
1616

17+
interface UserClickedZoomOut {
18+
readonly type: 'userClickedZoomOut';
19+
}
20+
21+
interface UserClickedZoomIn {
22+
readonly type: 'userClickedZoomIn';
23+
}
24+
1725
interface UserZoomed {
1826
readonly type: 'userZoomed';
1927
/**
@@ -56,6 +64,14 @@ interface UserStoppedPanning {
5664
readonly type: 'userStoppedPanning';
5765
}
5866

67+
interface UserClickedPanControl {
68+
readonly type: 'userClickedPanControl';
69+
/**
70+
* String that represents the direction in which Resolver can be panned
71+
*/
72+
readonly payload: PanDirection;
73+
}
74+
5975
interface UserMovedPointer {
6076
readonly type: 'userMovedPointer';
6177
/**
@@ -72,4 +88,7 @@ export type CameraAction =
7288
| UserStartedPanning
7389
| UserStoppedPanning
7490
| UserZoomed
75-
| UserMovedPointer;
91+
| UserMovedPointer
92+
| UserClickedZoomOut
93+
| UserClickedZoomIn
94+
| UserClickedPanControl;

x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/panning.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,66 @@ describe('panning interaction', () => {
5858
});
5959
});
6060
});
61+
describe('panning controls', () => {
62+
describe('when user clicks on pan north button', () => {
63+
beforeEach(() => {
64+
const action: CameraAction = { type: 'userClickedPanControl', payload: 'north' };
65+
store.dispatch(action);
66+
});
67+
it('moves the camera south so that objects appear closer to the bottom of the screen', () => {
68+
const actual = translation(store.getState());
69+
expect(actual).toMatchInlineSnapshot(`
70+
Array [
71+
0,
72+
-32.49906769231164,
73+
]
74+
`);
75+
});
76+
});
77+
describe('when user clicks on pan south button', () => {
78+
beforeEach(() => {
79+
const action: CameraAction = { type: 'userClickedPanControl', payload: 'south' };
80+
store.dispatch(action);
81+
});
82+
it('moves the camera north so that objects appear closer to the top of the screen', () => {
83+
const actual = translation(store.getState());
84+
expect(actual).toMatchInlineSnapshot(`
85+
Array [
86+
0,
87+
32.49906769231164,
88+
]
89+
`);
90+
});
91+
});
92+
describe('when user clicks on pan east button', () => {
93+
beforeEach(() => {
94+
const action: CameraAction = { type: 'userClickedPanControl', payload: 'east' };
95+
store.dispatch(action);
96+
});
97+
it('moves the camera west so that objects appear closer to the left of the screen', () => {
98+
const actual = translation(store.getState());
99+
expect(actual).toMatchInlineSnapshot(`
100+
Array [
101+
-32.49906769231164,
102+
0,
103+
]
104+
`);
105+
});
106+
});
107+
describe('when user clicks on pan west button', () => {
108+
beforeEach(() => {
109+
const action: CameraAction = { type: 'userClickedPanControl', payload: 'west' };
110+
store.dispatch(action);
111+
});
112+
it('moves the camera east so that objects appear closer to the right of the screen', () => {
113+
const actual = translation(store.getState());
114+
expect(actual).toMatchInlineSnapshot(`
115+
Array [
116+
32.49906769231164,
117+
0,
118+
]
119+
`);
120+
});
121+
});
122+
});
61123
});

x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/reducer.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { applyMatrix3, subtract } from '../../lib/vector2';
99
import { userIsPanning, translation, projectionMatrix, inverseProjectionMatrix } from './selectors';
1010
import { clamp } from '../../lib/math';
1111

12-
import { CameraState, ResolverAction } from '../../types';
12+
import { CameraState, ResolverAction, Vector2 } from '../../types';
1313
import { scaleToZoom } from './scale_to_zoom';
1414

1515
function initialState(): CameraState {
@@ -34,6 +34,16 @@ export const cameraReducer: Reducer<CameraState, ResolverAction> = (
3434
...state,
3535
scalingFactor: clamp(action.payload, 0, 1),
3636
};
37+
} else if (action.type === 'userClickedZoomIn') {
38+
return {
39+
...state,
40+
scalingFactor: clamp(state.scalingFactor + 0.1, 0, 1),
41+
};
42+
} else if (action.type === 'userClickedZoomOut') {
43+
return {
44+
...state,
45+
scalingFactor: clamp(state.scalingFactor - 0.1, 0, 1),
46+
};
3747
} else if (action.type === 'userZoomed') {
3848
const stateWithNewScaling: CameraState = {
3949
...state,
@@ -100,6 +110,32 @@ export const cameraReducer: Reducer<CameraState, ResolverAction> = (
100110
} else {
101111
return state;
102112
}
113+
} else if (action.type === 'userClickedPanControl') {
114+
const panDirection = action.payload;
115+
/**
116+
* Delta amount will be in the range of 20 -> 40 depending on the scalingFactor
117+
*/
118+
const deltaAmount = (1 + state.scalingFactor) * 20;
119+
let delta: Vector2;
120+
if (panDirection === 'north') {
121+
delta = [0, -deltaAmount];
122+
} else if (panDirection === 'south') {
123+
delta = [0, deltaAmount];
124+
} else if (panDirection === 'east') {
125+
delta = [-deltaAmount, 0];
126+
} else if (panDirection === 'west') {
127+
delta = [deltaAmount, 0];
128+
} else {
129+
delta = [0, 0];
130+
}
131+
132+
return {
133+
...state,
134+
translationNotCountingCurrentPanning: [
135+
state.translationNotCountingCurrentPanning[0] + delta[0],
136+
state.translationNotCountingCurrentPanning[1] + delta[1],
137+
],
138+
};
103139
} else if (action.type === 'userSetRasterSize') {
104140
/**
105141
* Handle resizes of the Resolver component. We need to know the size in order to convert between screen

x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/selectors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ export const scale = (state: CameraState): Vector2 => {
182182
return [value, value];
183183
};
184184

185+
/**
186+
* Scales the coordinate system, used for zooming. Should always be between 0 and 1
187+
*/
188+
export const scalingFactor = (state: CameraState): CameraState['scalingFactor'] => {
189+
return state.scalingFactor;
190+
};
191+
185192
/**
186193
* Whether or not the user is current panning the map.
187194
*/

x-pack/plugins/endpoint/public/embeddables/resolver/store/camera/zooming.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CameraAction } from './action';
88
import { cameraReducer } from './reducer';
99
import { createStore, Store } from 'redux';
1010
import { CameraState, AABB } from '../../types';
11-
import { viewableBoundingBox, inverseProjectionMatrix } from './selectors';
11+
import { viewableBoundingBox, inverseProjectionMatrix, scalingFactor } from './selectors';
1212
import { expectVectorsToBeClose } from './test_helpers';
1313
import { scaleToZoom } from './scale_to_zoom';
1414
import { applyMatrix3 } from '../../lib/vector2';
@@ -151,4 +151,29 @@ describe('zooming', () => {
151151
});
152152
});
153153
});
154+
describe('zoom controls', () => {
155+
let previousScalingFactor: CameraState['scalingFactor'];
156+
describe('when user clicks on zoom in button', () => {
157+
beforeEach(() => {
158+
previousScalingFactor = scalingFactor(store.getState());
159+
const action: CameraAction = { type: 'userClickedZoomIn' };
160+
store.dispatch(action);
161+
});
162+
it('the scaling factor should increase by 0.1 units', () => {
163+
const actual = scalingFactor(store.getState());
164+
expect(actual).toEqual(previousScalingFactor + 0.1);
165+
});
166+
});
167+
describe('when user clicks on zoom out button', () => {
168+
beforeEach(() => {
169+
previousScalingFactor = scalingFactor(store.getState());
170+
const action: CameraAction = { type: 'userClickedZoomOut' };
171+
store.dispatch(action);
172+
});
173+
it('the scaling factor should decrease by 0.1 units', () => {
174+
const actual = scalingFactor(store.getState());
175+
expect(actual).toEqual(previousScalingFactor - 0.1);
176+
});
177+
});
178+
});
154179
});

x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ export const inverseProjectionMatrix = composeSelectors(
3131
*/
3232
export const scale = composeSelectors(cameraStateSelector, cameraSelectors.scale);
3333

34+
/**
35+
* Scales the coordinate system, used for zooming. Should always be between 0 and 1
36+
*/
37+
export const scalingFactor = composeSelectors(cameraStateSelector, cameraSelectors.scalingFactor);
38+
3439
/**
3540
* Whether or not the user is current panning the map.
3641
*/

x-pack/plugins/endpoint/public/embeddables/resolver/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,8 @@ export type ProcessWithWidthMetadata = {
182182
firstChildWidth: null;
183183
}
184184
);
185+
186+
/**
187+
* String that represents the direction in which Resolver can be panned
188+
*/
189+
export type PanDirection = 'north' | 'south' | 'east' | 'west';
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import React, { useCallback } from 'react';
8+
import styled from 'styled-components';
9+
import { EuiRange, EuiPanel, EuiIcon } from '@elastic/eui';
10+
import { useSelector, useDispatch } from 'react-redux';
11+
import { ResolverAction, PanDirection } from '../types';
12+
import * as selectors from '../store/selectors';
13+
14+
/**
15+
* Controls for zooming, panning, and centering in Resolver
16+
*/
17+
export const GraphControls = styled(
18+
React.memo(
19+
({
20+
className,
21+
}: {
22+
/**
23+
* A className string provided by `styled`
24+
*/
25+
className?: string;
26+
}) => {
27+
const dispatch: (action: ResolverAction) => unknown = useDispatch();
28+
const scalingFactor = useSelector(selectors.scalingFactor);
29+
30+
const handleZoomAmountChange = useCallback(
31+
(event: React.ChangeEvent<HTMLInputElement> | React.MouseEvent<HTMLButtonElement>) => {
32+
const valueAsNumber = parseFloat(
33+
(event as React.ChangeEvent<HTMLInputElement>).target.value
34+
);
35+
if (isNaN(valueAsNumber) === false) {
36+
dispatch({
37+
type: 'userSetZoomLevel',
38+
payload: valueAsNumber,
39+
});
40+
}
41+
},
42+
[dispatch]
43+
);
44+
45+
const handleCenterClick = useCallback(() => {
46+
dispatch({
47+
type: 'userSetPositionOfCamera',
48+
payload: [0, 0],
49+
});
50+
}, [dispatch]);
51+
52+
const handleZoomOutClick = useCallback(() => {
53+
dispatch({
54+
type: 'userClickedZoomOut',
55+
});
56+
}, [dispatch]);
57+
58+
const handleZoomInClick = useCallback(() => {
59+
dispatch({
60+
type: 'userClickedZoomIn',
61+
});
62+
}, [dispatch]);
63+
64+
const handlePanClick = (panDirection: PanDirection) => {
65+
return () => {
66+
dispatch({
67+
type: 'userClickedPanControl',
68+
payload: panDirection,
69+
});
70+
};
71+
};
72+
73+
return (
74+
<div className={className}>
75+
<EuiPanel className="panning-controls" paddingSize="none" hasShadow>
76+
<div className="panning-controls-top">
77+
<button className="north-button" title="North" onClick={handlePanClick('north')}>
78+
<EuiIcon type="arrowUp" />
79+
</button>
80+
</div>
81+
<div className="panning-controls-middle">
82+
<button className="west-button" title="West" onClick={handlePanClick('west')}>
83+
<EuiIcon type="arrowLeft" />
84+
</button>
85+
<button className="center-button" title="Center" onClick={handleCenterClick}>
86+
<EuiIcon type="bullseye" />
87+
</button>
88+
<button className="east-button" title="East" onClick={handlePanClick('east')}>
89+
<EuiIcon type="arrowRight" />
90+
</button>
91+
</div>
92+
<div className="panning-controls-bottom">
93+
<button className="south-button" title="South" onClick={handlePanClick('south')}>
94+
<EuiIcon type="arrowDown" />
95+
</button>
96+
</div>
97+
</EuiPanel>
98+
<EuiPanel className="zoom-controls" paddingSize="none" hasShadow>
99+
<button title="Zoom In" onClick={handleZoomInClick}>
100+
<EuiIcon type="plusInCircle" />
101+
</button>
102+
<EuiRange
103+
className="zoom-slider"
104+
min={0}
105+
max={1}
106+
step={0.01}
107+
value={scalingFactor}
108+
onChange={handleZoomAmountChange}
109+
/>
110+
<button title="Zoom Out" onClick={handleZoomOutClick}>
111+
<EuiIcon type="minusInCircle" />
112+
</button>
113+
</EuiPanel>
114+
</div>
115+
);
116+
}
117+
)
118+
)`
119+
position: absolute;
120+
top: 5px;
121+
left: 5px;
122+
z-index: 1;
123+
background-color: #d4d4d4;
124+
color: #333333;
125+
126+
.zoom-controls {
127+
display: flex;
128+
flex-direction: column;
129+
align-items: center;
130+
padding: 5px 0px;
131+
132+
.zoom-slider {
133+
width: 20px;
134+
height: 150px;
135+
margin: 5px 0px 2px 0px;
136+
137+
input[type='range'] {
138+
width: 150px;
139+
height: 20px;
140+
transform-origin: 75px 75px;
141+
transform: rotate(-90deg);
142+
}
143+
}
144+
}
145+
.panning-controls {
146+
text-align: center;
147+
}
148+
`;

0 commit comments

Comments
 (0)