diff options
author | Kristóf Marussy <kristof@marussy.com> | 2023-08-23 12:45:34 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2023-08-23 13:16:48 +0200 |
commit | 4fb115979ad67183cbc3236c70d975bc1949b44a (patch) | |
tree | ec015e704d1181069feb8e80b1f4624be8d6a6bd | |
parent | refactor: d3 zoom centering (diff) | |
download | refinery-4fb115979ad67183cbc3236c70d975bc1949b44a.tar.gz refinery-4fb115979ad67183cbc3236c70d975bc1949b44a.tar.zst refinery-4fb115979ad67183cbc3236c70d975bc1949b44a.zip |
feat(web): zoom controls
-rw-r--r-- | subprojects/frontend/src/graph/GraphArea.tsx | 84 |
1 files changed, 81 insertions, 3 deletions
diff --git a/subprojects/frontend/src/graph/GraphArea.tsx b/subprojects/frontend/src/graph/GraphArea.tsx index 1da7eaef..aec5d1c6 100644 --- a/subprojects/frontend/src/graph/GraphArea.tsx +++ b/subprojects/frontend/src/graph/GraphArea.tsx | |||
@@ -4,7 +4,13 @@ | |||
4 | * SPDX-License-Identifier: EPL-2.0 | 4 | * SPDX-License-Identifier: EPL-2.0 |
5 | */ | 5 | */ |
6 | 6 | ||
7 | import AddIcon from '@mui/icons-material/Add'; | ||
8 | import CropFreeIcon from '@mui/icons-material/CropFree'; | ||
9 | import RemoveIcon from '@mui/icons-material/Remove'; | ||
7 | import Box from '@mui/material/Box'; | 10 | import Box from '@mui/material/Box'; |
11 | import IconButton from '@mui/material/IconButton'; | ||
12 | import Stack from '@mui/material/Stack'; | ||
13 | import { useTheme } from '@mui/material/styles'; | ||
8 | import * as d3 from 'd3'; | 14 | import * as d3 from 'd3'; |
9 | import { type Graphviz, graphviz } from 'd3-graphviz'; | 15 | import { type Graphviz, graphviz } from 'd3-graphviz'; |
10 | import type { BaseType, Selection } from 'd3-selection'; | 16 | import type { BaseType, Selection } from 'd3-selection'; |
@@ -79,11 +85,13 @@ interface Transform { | |||
79 | 85 | ||
80 | export default function GraphArea(): JSX.Element { | 86 | export default function GraphArea(): JSX.Element { |
81 | const { editorStore } = useRootStore(); | 87 | const { editorStore } = useRootStore(); |
88 | const theme = useTheme(); | ||
82 | const disposerRef = useRef<IReactionDisposer | undefined>(); | 89 | const disposerRef = useRef<IReactionDisposer | undefined>(); |
83 | const graphvizRef = useRef< | 90 | const graphvizRef = useRef< |
84 | Graphviz<BaseType, unknown, null, undefined> | undefined | 91 | Graphviz<BaseType, unknown, null, undefined> | undefined |
85 | >(); | 92 | >(); |
86 | const canvasRef = useRef<HTMLDivElement | undefined>(); | 93 | const canvasRef = useRef<HTMLDivElement | undefined>(); |
94 | const elementRef = useRef<HTMLDivElement | undefined>(); | ||
87 | const zoomRef = useRef< | 95 | const zoomRef = useRef< |
88 | d3.ZoomBehavior<HTMLDivElement, unknown> | undefined | 96 | d3.ZoomBehavior<HTMLDivElement, unknown> | undefined |
89 | >(); | 97 | >(); |
@@ -109,12 +117,13 @@ export default function GraphArea(): JSX.Element { | |||
109 | zoomBehavior.on('zoom', (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => | 117 | zoomBehavior.on('zoom', (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => |
110 | setZoom(event.transform), | 118 | setZoom(event.transform), |
111 | ); | 119 | ); |
112 | d3.select(element).call(zoomBehavior); | 120 | d3.select(element).call(zoomBehavior).on('dblclick.zoom', null); |
113 | zoomRef.current = zoomBehavior; | 121 | zoomRef.current = zoomBehavior; |
114 | }, []); | 122 | }, []); |
115 | 123 | ||
116 | const setElement = useCallback( | 124 | const setElement = useCallback( |
117 | (element: HTMLDivElement | null) => { | 125 | (element: HTMLDivElement | null) => { |
126 | elementRef.current = element ?? undefined; | ||
118 | if (disposerRef.current !== undefined) { | 127 | if (disposerRef.current !== undefined) { |
119 | disposerRef.current(); | 128 | disposerRef.current(); |
120 | disposerRef.current = undefined; | 129 | disposerRef.current = undefined; |
@@ -204,9 +213,57 @@ export default function GraphArea(): JSX.Element { | |||
204 | [editorStore], | 213 | [editorStore], |
205 | ); | 214 | ); |
206 | 215 | ||
216 | const { | ||
217 | transitions: { | ||
218 | duration: { short: zoomDuration }, | ||
219 | }, | ||
220 | } = theme; | ||
221 | const changeZoom = useCallback( | ||
222 | (event: React.MouseEvent, factor: number) => { | ||
223 | if (canvasRef.current === undefined || zoomRef.current === undefined) { | ||
224 | return; | ||
225 | } | ||
226 | const selection = d3.select(canvasRef.current); | ||
227 | const zoomTransition = selection.transition().duration(zoomDuration); | ||
228 | const center: [number, number] = [0, 0]; | ||
229 | zoomRef.current.scaleBy(zoomTransition, factor, center); | ||
230 | event.preventDefault(); | ||
231 | event.stopPropagation(); | ||
232 | }, | ||
233 | [zoomDuration], | ||
234 | ); | ||
235 | |||
236 | const fitZoom = useCallback((event: React.MouseEvent) => { | ||
237 | if ( | ||
238 | canvasRef.current === undefined || | ||
239 | zoomRef.current === undefined || | ||
240 | elementRef.current === undefined | ||
241 | ) { | ||
242 | return; | ||
243 | } | ||
244 | const { width: canvasWidth, height: canvasHeight } = | ||
245 | canvasRef.current.getBoundingClientRect(); | ||
246 | const { width: scaledWidth, height: scaledHeight } = | ||
247 | elementRef.current.getBoundingClientRect(); | ||
248 | const currentFactor = d3.zoomTransform(canvasRef.current).k; | ||
249 | const width = scaledWidth / currentFactor; | ||
250 | const height = scaledHeight / currentFactor; | ||
251 | if (width > 0 && height > 0) { | ||
252 | const factor = Math.min( | ||
253 | 1.0, | ||
254 | (canvasWidth - 64) / width, | ||
255 | (canvasHeight - 64) / height, | ||
256 | ); | ||
257 | const selection = d3.select(canvasRef.current); | ||
258 | zoomRef.current.transform(selection, d3.zoomIdentity.scale(factor)); | ||
259 | } | ||
260 | event.preventDefault(); | ||
261 | event.stopPropagation(); | ||
262 | }, []); | ||
263 | |||
207 | return ( | 264 | return ( |
208 | <Box | 265 | <Box |
209 | sx={(theme) => ({ | 266 | sx={{ |
210 | width: '100%', | 267 | width: '100%', |
211 | height: '100%', | 268 | height: '100%', |
212 | position: 'relative', | 269 | position: 'relative', |
@@ -267,7 +324,7 @@ export default function GraphArea(): JSX.Element { | |||
267 | }, | 324 | }, |
268 | }, | 325 | }, |
269 | }, | 326 | }, |
270 | })} | 327 | }} |
271 | ref={setCanvas} | 328 | ref={setCanvas} |
272 | > | 329 | > |
273 | <Box | 330 | <Box |
@@ -284,6 +341,27 @@ export default function GraphArea(): JSX.Element { | |||
284 | }} | 341 | }} |
285 | ref={setElement} | 342 | ref={setElement} |
286 | /> | 343 | /> |
344 | <Stack | ||
345 | direction="column" | ||
346 | p={1} | ||
347 | sx={{ position: 'absolute', bottom: 0, right: 0 }} | ||
348 | > | ||
349 | <IconButton | ||
350 | aria-label="Zoom in" | ||
351 | onClick={(event) => changeZoom(event, 2)} | ||
352 | > | ||
353 | <AddIcon fontSize="small" /> | ||
354 | </IconButton> | ||
355 | <IconButton | ||
356 | aria-label="Zoom out" | ||
357 | onClick={(event) => changeZoom(event, 0.5)} | ||
358 | > | ||
359 | <RemoveIcon fontSize="small" /> | ||
360 | </IconButton> | ||
361 | <IconButton aria-label="Fit screen" onClick={fitZoom}> | ||
362 | <CropFreeIcon fontSize="small" /> | ||
363 | </IconButton> | ||
364 | </Stack> | ||
287 | </Box> | 365 | </Box> |
288 | ); | 366 | ); |
289 | } | 367 | } |