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.tsx224
1 files changed, 224 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..0254bc59
--- /dev/null
+++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx
@@ -0,0 +1,224 @@
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';
11import { useResizeDetector } from 'react-resize-detector';
12
13import ZoomButtons from './ZoomButtons';
14
15declare module 'd3-zoom' {
16 // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Redeclaring type parameters.
17 interface ZoomBehavior<ZoomRefElement extends Element, Datum> {
18 // `@types/d3-zoom` does not contain the `center` function, because it is
19 // only available as a pull request for `d3-zoom`.
20 center(callback: (event: MouseEvent | Touch) => [number, number]): this;
21
22 // Custom `centroid` method added via patch.
23 centroid(centroid: [number, number]): this;
24 }
25}
26
27interface Transform {
28 x: number;
29 y: number;
30 k: number;
31}
32
33export type ChangeZoomCallback = (factor: number) => void;
34
35export type SetFitZoomCallback = (fitZoom: boolean) => void;
36
37export type FitZoomCallback = ((newSize?: {
38 width: number;
39 height: number;
40}) => void) &
41 ((newSize: boolean) => void);
42
43export default function ZoomCanvas({
44 children,
45 fitPadding,
46 transitionTime,
47}: {
48 children?: React.ReactNode | ((fitZoom: FitZoomCallback) => React.ReactNode);
49 fitPadding?: number;
50 transitionTime?: number;
51}): JSX.Element {
52 const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding;
53 const transitionTimeOrDefault =
54 transitionTime ?? ZoomCanvas.defaultProps.transitionTime;
55
56 const canvasRef = useRef<HTMLDivElement | undefined>();
57 const elementRef = useRef<HTMLDivElement | undefined>();
58 const zoomRef = useRef<
59 d3.ZoomBehavior<HTMLDivElement, unknown> | undefined
60 >();
61 const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 });
62 const [fitZoom, setFitZoom] = useState(true);
63 const fitZoomRef = useRef(fitZoom);
64
65 const makeTransition = useCallback(
66 (element: HTMLDivElement) =>
67 d3.select(element).transition().duration(transitionTimeOrDefault),
68 [transitionTimeOrDefault],
69 );
70
71 const fitZoomCallback = useCallback<FitZoomCallback>(
72 (newSize) => {
73 if (
74 !fitZoomRef.current ||
75 canvasRef.current === undefined ||
76 zoomRef.current === undefined ||
77 elementRef.current === undefined
78 ) {
79 return;
80 }
81 let width = 0;
82 let height = 0;
83 if (newSize === undefined || typeof newSize === 'boolean') {
84 const elementRect = elementRef.current.getBoundingClientRect();
85 const currentFactor = d3.zoomTransform(canvasRef.current).k;
86 width = elementRect.width / currentFactor;
87 height = elementRect.height / currentFactor;
88 } else {
89 ({ width, height } = newSize);
90 }
91 if (width === 0 || height === 0) {
92 return;
93 }
94 const canvasRect = canvasRef.current.getBoundingClientRect();
95 const factor = Math.min(
96 1.0,
97 (canvasRect.width - 2 * fitPaddingOrDefault) / width,
98 (canvasRect.height - 2 * fitPaddingOrDefault) / height,
99 );
100 const target =
101 newSize === false
102 ? d3.select(canvasRef.current)
103 : makeTransition(canvasRef.current);
104 zoomRef.current.transform(target, d3.zoomIdentity.scale(factor));
105 },
106 [fitPaddingOrDefault, makeTransition],
107 );
108
109 const setFitZoomCallback = useCallback<SetFitZoomCallback>(
110 (newFitZoom) => {
111 setFitZoom(newFitZoom);
112 fitZoomRef.current = newFitZoom;
113 if (newFitZoom) {
114 fitZoomCallback();
115 }
116 },
117 [fitZoomCallback],
118 );
119
120 const changeZoomCallback = useCallback<ChangeZoomCallback>(
121 (factor) => {
122 setFitZoomCallback(false);
123 if (canvasRef.current === undefined || zoomRef.current === undefined) {
124 return;
125 }
126 const zoomTransition = makeTransition(canvasRef.current);
127 const center: [number, number] = [0, 0];
128 zoomRef.current.scaleBy(zoomTransition, factor, center);
129 },
130 [makeTransition, setFitZoomCallback],
131 );
132
133 const onResize = useCallback(() => fitZoomCallback(), [fitZoomCallback]);
134
135 const { ref: resizeRef } = useResizeDetector({
136 onResize,
137 refreshMode: 'debounce',
138 refreshRate: transitionTimeOrDefault,
139 });
140
141 const setCanvas = useCallback(
142 (canvas: HTMLDivElement | null) => {
143 canvasRef.current = canvas ?? undefined;
144 resizeRef(canvas);
145 if (canvas === null) {
146 return;
147 }
148 const zoomBehavior = d3Zoom<HTMLDivElement, unknown>()
149 .duration(transitionTimeOrDefault)
150 .center((event) => {
151 const { width, height } = canvas.getBoundingClientRect();
152 const [x, y] = d3.pointer(event, canvas);
153 return [x - width / 2, y - height / 2];
154 })
155 .centroid([0, 0])
156 .scaleExtent([1 / 32, 8]);
157 zoomBehavior.on(
158 'zoom',
159 (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => {
160 setZoom(event.transform);
161 if (event.sourceEvent) {
162 setFitZoomCallback(false);
163 }
164 },
165 );
166 d3.select(canvas).call(zoomBehavior);
167 zoomRef.current = zoomBehavior;
168 },
169 [transitionTimeOrDefault, setFitZoomCallback, resizeRef],
170 );
171
172 return (
173 <Box
174 sx={{
175 width: '100%',
176 height: '100%',
177 position: 'relative',
178 overflow: 'hidden',
179 }}
180 >
181 <Box
182 sx={{
183 position: 'absolute',
184 overflow: 'hidden',
185 top: 0,
186 left: 0,
187 right: 0,
188 bottom: 0,
189 }}
190 ref={setCanvas}
191 >
192 <Box
193 sx={{
194 position: 'absolute',
195 top: '50%',
196 left: '50%',
197 transform: `
198 translate(${zoom.x}px, ${zoom.y}px)
199 scale(${zoom.k})
200 translate(-50%, -50%)
201 `,
202 transformOrigin: '0 0',
203 }}
204 ref={elementRef}
205 >
206 {typeof children === 'function'
207 ? children(fitZoomCallback)
208 : children}
209 </Box>
210 </Box>
211 <ZoomButtons
212 changeZoom={changeZoomCallback}
213 fitZoom={fitZoom}
214 setFitZoom={setFitZoomCallback}
215 />
216 </Box>
217 );
218}
219
220ZoomCanvas.defaultProps = {
221 children: undefined,
222 fitPadding: 8,
223 transitionTime: 250,
224};