aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/graph/ZoomCanvas.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/graph/ZoomCanvas.tsx')
-rw-r--r--subprojects/frontend/src/graph/ZoomCanvas.tsx177
1 files changed, 177 insertions, 0 deletions
diff --git a/subprojects/frontend/src/graph/ZoomCanvas.tsx b/subprojects/frontend/src/graph/ZoomCanvas.tsx
new file mode 100644
index 00000000..eb3e9285
--- /dev/null
+++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx
@@ -0,0 +1,177 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import Box from '@mui/material/Box';
8import * as d3 from 'd3';
9import { zoom as d3Zoom } from 'd3-zoom';
10import React, { useCallback, useRef, useState } from 'react';
11
12import ZoomButtons from './ZoomButtons';
13
14declare module 'd3-zoom' {
15 // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Redeclaring type parameters.
16 interface ZoomBehavior<ZoomRefElement extends Element, Datum> {
17 // `@types/d3-zoom` does not contain the `center` function, because it is
18 // only available as a pull request for `d3-zoom`.
19 center(callback: (event: MouseEvent | Touch) => [number, number]): this;
20
21 // Custom `centroid` method added via patch.
22 centroid(centroid: [number, number]): this;
23 }
24}
25
26interface Transform {
27 x: number;
28 y: number;
29 k: number;
30}
31
32export default function ZoomCanvas({
33 children,
34 fitPadding,
35 transitionTime,
36}: {
37 children?: React.ReactNode;
38 fitPadding?: number;
39 transitionTime?: number;
40}): JSX.Element {
41 const canvasRef = useRef<HTMLDivElement | undefined>();
42 const elementRef = useRef<HTMLDivElement | undefined>();
43 const zoomRef = useRef<
44 d3.ZoomBehavior<HTMLDivElement, unknown> | undefined
45 >();
46 const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding;
47 const transitionTimeOrDefault =
48 transitionTime ?? ZoomCanvas.defaultProps.transitionTime;
49
50 const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 });
51
52 const setCanvas = useCallback(
53 (canvas: HTMLDivElement | null) => {
54 canvasRef.current = canvas ?? undefined;
55 if (canvas === null) {
56 return;
57 }
58 const zoomBehavior = d3Zoom<HTMLDivElement, unknown>()
59 .duration(transitionTimeOrDefault)
60 .center((event) => {
61 const { width, height } = canvas.getBoundingClientRect();
62 const [x, y] = d3.pointer(event, canvas);
63 return [x - width / 2, y - height / 2];
64 })
65 .centroid([0, 0]);
66 zoomBehavior.on(
67 'zoom',
68 (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) =>
69 setZoom(event.transform),
70 );
71 d3.select(canvas).call(zoomBehavior);
72 zoomRef.current = zoomBehavior;
73 },
74 [transitionTimeOrDefault],
75 );
76
77 const makeTransition = useCallback(
78 (element: HTMLDivElement) =>
79 d3.select(element).transition().duration(transitionTimeOrDefault),
80 [transitionTimeOrDefault],
81 );
82
83 const changeZoom = useCallback(
84 (event: React.MouseEvent, factor: number) => {
85 if (canvasRef.current === undefined || zoomRef.current === undefined) {
86 return;
87 }
88 const zoomTransition = makeTransition(canvasRef.current);
89 const center: [number, number] = [0, 0];
90 zoomRef.current.scaleBy(zoomTransition, factor, center);
91 event.preventDefault();
92 event.stopPropagation();
93 },
94 [makeTransition],
95 );
96
97 const fitZoom = useCallback(
98 (event: React.MouseEvent) => {
99 if (
100 canvasRef.current === undefined ||
101 zoomRef.current === undefined ||
102 elementRef.current === undefined
103 ) {
104 return;
105 }
106 const { width: canvasWidth, height: canvasHeight } =
107 canvasRef.current.getBoundingClientRect();
108 const { width: scaledWidth, height: scaledHeight } =
109 elementRef.current.getBoundingClientRect();
110 const currentFactor = d3.zoomTransform(canvasRef.current).k;
111 const width = scaledWidth / currentFactor;
112 const height = scaledHeight / currentFactor;
113 if (width > 0 && height > 0) {
114 const factor = Math.min(
115 1.0,
116 (canvasWidth - fitPaddingOrDefault) / width,
117 (canvasHeight - fitPaddingOrDefault) / height,
118 );
119 const zoomTransition = makeTransition(canvasRef.current);
120 zoomRef.current.transform(
121 zoomTransition,
122 d3.zoomIdentity.scale(factor),
123 );
124 }
125 event.preventDefault();
126 event.stopPropagation();
127 },
128 [fitPaddingOrDefault, makeTransition],
129 );
130
131 return (
132 <Box
133 sx={{
134 width: '100%',
135 height: '100%',
136 position: 'relative',
137 overflow: 'hidden',
138 }}
139 >
140 <Box
141 sx={{
142 position: 'absolute',
143 overflow: 'hidden',
144 top: 0,
145 left: 0,
146 right: 0,
147 bottom: 0,
148 }}
149 ref={setCanvas}
150 >
151 <Box
152 sx={{
153 position: 'absolute',
154 top: '50%',
155 left: '50%',
156 transform: `
157 translate(${zoom.x}px, ${zoom.y}px)
158 scale(${zoom.k})
159 translate(-50%, -50%)
160 `,
161 transformOrigin: '0 0',
162 }}
163 ref={elementRef}
164 >
165 {children}
166 </Box>
167 </Box>
168 <ZoomButtons changeZoom={changeZoom} fitZoom={fitZoom} />
169 </Box>
170 );
171}
172
173ZoomCanvas.defaultProps = {
174 children: undefined,
175 fitPadding: 64,
176 transitionTime: 250,
177};