diff options
Diffstat (limited to 'subprojects/frontend/src/graph/ZoomCanvas.tsx')
-rw-r--r-- | subprojects/frontend/src/graph/ZoomCanvas.tsx | 224 |
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 | |||
7 | import Box from '@mui/material/Box'; | ||
8 | import * as d3 from 'd3'; | ||
9 | import { zoom as d3Zoom } from 'd3-zoom'; | ||
10 | import React, { useCallback, useRef, useState } from 'react'; | ||
11 | import { useResizeDetector } from 'react-resize-detector'; | ||
12 | |||
13 | import ZoomButtons from './ZoomButtons'; | ||
14 | |||
15 | declare 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 | |||
27 | interface Transform { | ||
28 | x: number; | ||
29 | y: number; | ||
30 | k: number; | ||
31 | } | ||
32 | |||
33 | export type ChangeZoomCallback = (factor: number) => void; | ||
34 | |||
35 | export type SetFitZoomCallback = (fitZoom: boolean) => void; | ||
36 | |||
37 | export type FitZoomCallback = ((newSize?: { | ||
38 | width: number; | ||
39 | height: number; | ||
40 | }) => void) & | ||
41 | ((newSize: boolean) => void); | ||
42 | |||
43 | export 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 | |||
220 | ZoomCanvas.defaultProps = { | ||
221 | children: undefined, | ||
222 | fitPadding: 8, | ||
223 | transitionTime: 250, | ||
224 | }; | ||