aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/graph
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2023-08-23 12:45:34 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2023-08-23 13:16:48 +0200
commit4fb115979ad67183cbc3236c70d975bc1949b44a (patch)
treeec015e704d1181069feb8e80b1f4624be8d6a6bd /subprojects/frontend/src/graph
parentrefactor: d3 zoom centering (diff)
downloadrefinery-4fb115979ad67183cbc3236c70d975bc1949b44a.tar.gz
refinery-4fb115979ad67183cbc3236c70d975bc1949b44a.tar.zst
refinery-4fb115979ad67183cbc3236c70d975bc1949b44a.zip
feat(web): zoom controls
Diffstat (limited to 'subprojects/frontend/src/graph')
-rw-r--r--subprojects/frontend/src/graph/GraphArea.tsx84
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
7import AddIcon from '@mui/icons-material/Add';
8import CropFreeIcon from '@mui/icons-material/CropFree';
9import RemoveIcon from '@mui/icons-material/Remove';
7import Box from '@mui/material/Box'; 10import Box from '@mui/material/Box';
11import IconButton from '@mui/material/IconButton';
12import Stack from '@mui/material/Stack';
13import { useTheme } from '@mui/material/styles';
8import * as d3 from 'd3'; 14import * as d3 from 'd3';
9import { type Graphviz, graphviz } from 'd3-graphviz'; 15import { type Graphviz, graphviz } from 'd3-graphviz';
10import type { BaseType, Selection } from 'd3-selection'; 16import type { BaseType, Selection } from 'd3-selection';
@@ -79,11 +85,13 @@ interface Transform {
79 85
80export default function GraphArea(): JSX.Element { 86export 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}