diff options
Diffstat (limited to 'subprojects/frontend/src/graph/ZoomCanvas.tsx')
-rw-r--r-- | subprojects/frontend/src/graph/ZoomCanvas.tsx | 172 |
1 files changed, 107 insertions, 65 deletions
diff --git a/subprojects/frontend/src/graph/ZoomCanvas.tsx b/subprojects/frontend/src/graph/ZoomCanvas.tsx index eb3e9285..b8faae27 100644 --- a/subprojects/frontend/src/graph/ZoomCanvas.tsx +++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx | |||
@@ -8,6 +8,7 @@ import Box from '@mui/material/Box'; | |||
8 | import * as d3 from 'd3'; | 8 | import * as d3 from 'd3'; |
9 | import { zoom as d3Zoom } from 'd3-zoom'; | 9 | import { zoom as d3Zoom } from 'd3-zoom'; |
10 | import React, { useCallback, useRef, useState } from 'react'; | 10 | import React, { useCallback, useRef, useState } from 'react'; |
11 | import { useResizeDetector } from 'react-resize-detector'; | ||
11 | 12 | ||
12 | import ZoomButtons from './ZoomButtons'; | 13 | import ZoomButtons from './ZoomButtons'; |
13 | 14 | ||
@@ -29,103 +30,138 @@ interface Transform { | |||
29 | k: number; | 30 | k: number; |
30 | } | 31 | } |
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 | |||
32 | export default function ZoomCanvas({ | 42 | export default function ZoomCanvas({ |
33 | children, | 43 | children, |
34 | fitPadding, | 44 | fitPadding, |
35 | transitionTime, | 45 | transitionTime, |
36 | }: { | 46 | }: { |
37 | children?: React.ReactNode; | 47 | children?: React.ReactNode | ((fitZoom: FitZoomCallback) => React.ReactNode); |
38 | fitPadding?: number; | 48 | fitPadding?: number; |
39 | transitionTime?: number; | 49 | transitionTime?: number; |
40 | }): JSX.Element { | 50 | }): JSX.Element { |
51 | const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding; | ||
52 | const transitionTimeOrDefault = | ||
53 | transitionTime ?? ZoomCanvas.defaultProps.transitionTime; | ||
54 | |||
41 | const canvasRef = useRef<HTMLDivElement | undefined>(); | 55 | const canvasRef = useRef<HTMLDivElement | undefined>(); |
42 | const elementRef = useRef<HTMLDivElement | undefined>(); | 56 | const elementRef = useRef<HTMLDivElement | undefined>(); |
43 | const zoomRef = useRef< | 57 | const zoomRef = useRef< |
44 | d3.ZoomBehavior<HTMLDivElement, unknown> | undefined | 58 | d3.ZoomBehavior<HTMLDivElement, unknown> | undefined |
45 | >(); | 59 | >(); |
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 }); | 60 | const [zoom, setZoom] = useState<Transform>({ x: 0, y: 0, k: 1 }); |
61 | const [fitZoom, setFitZoom] = useState(true); | ||
62 | const fitZoomRef = useRef(fitZoom); | ||
51 | 63 | ||
52 | const setCanvas = useCallback( | 64 | const makeTransition = useCallback( |
53 | (canvas: HTMLDivElement | null) => { | 65 | (element: HTMLDivElement) => |
54 | canvasRef.current = canvas ?? undefined; | 66 | d3.select(element).transition().duration(transitionTimeOrDefault), |
55 | if (canvas === null) { | 67 | [transitionTimeOrDefault], |
68 | ); | ||
69 | |||
70 | const fitZoomCallback = useCallback<FitZoomCallback>( | ||
71 | (newSize) => { | ||
72 | if ( | ||
73 | !fitZoomRef.current || | ||
74 | canvasRef.current === undefined || | ||
75 | zoomRef.current === undefined || | ||
76 | elementRef.current === undefined | ||
77 | ) { | ||
56 | return; | 78 | return; |
57 | } | 79 | } |
58 | const zoomBehavior = d3Zoom<HTMLDivElement, unknown>() | 80 | let width = 0; |
59 | .duration(transitionTimeOrDefault) | 81 | let height = 0; |
60 | .center((event) => { | 82 | if (newSize === undefined) { |
61 | const { width, height } = canvas.getBoundingClientRect(); | 83 | const elementRect = elementRef.current.getBoundingClientRect(); |
62 | const [x, y] = d3.pointer(event, canvas); | 84 | const currentFactor = d3.zoomTransform(canvasRef.current).k; |
63 | return [x - width / 2, y - height / 2]; | 85 | width = elementRect.width / currentFactor; |
64 | }) | 86 | height = elementRect.height / currentFactor; |
65 | .centroid([0, 0]); | 87 | } else { |
66 | zoomBehavior.on( | 88 | ({ width, height } = newSize); |
67 | 'zoom', | 89 | } |
68 | (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => | 90 | if (width === 0 || height === 0) { |
69 | setZoom(event.transform), | 91 | return; |
92 | } | ||
93 | const canvasRect = canvasRef.current.getBoundingClientRect(); | ||
94 | const factor = Math.min( | ||
95 | 1.0, | ||
96 | (canvasRect.width - 2 * fitPaddingOrDefault) / width, | ||
97 | (canvasRect.height - 2 * fitPaddingOrDefault) / height, | ||
70 | ); | 98 | ); |
71 | d3.select(canvas).call(zoomBehavior); | 99 | const zoomTransition = makeTransition(canvasRef.current); |
72 | zoomRef.current = zoomBehavior; | 100 | zoomRef.current.transform(zoomTransition, d3.zoomIdentity.scale(factor)); |
73 | }, | 101 | }, |
74 | [transitionTimeOrDefault], | 102 | [fitPaddingOrDefault, makeTransition], |
75 | ); | 103 | ); |
76 | 104 | ||
77 | const makeTransition = useCallback( | 105 | const setFitZoomCallback = useCallback<SetFitZoomCallback>( |
78 | (element: HTMLDivElement) => | 106 | (newFitZoom) => { |
79 | d3.select(element).transition().duration(transitionTimeOrDefault), | 107 | setFitZoom(newFitZoom); |
80 | [transitionTimeOrDefault], | 108 | fitZoomRef.current = newFitZoom; |
109 | if (newFitZoom) { | ||
110 | fitZoomCallback(); | ||
111 | } | ||
112 | }, | ||
113 | [fitZoomCallback], | ||
81 | ); | 114 | ); |
82 | 115 | ||
83 | const changeZoom = useCallback( | 116 | const changeZoomCallback = useCallback<ChangeZoomCallback>( |
84 | (event: React.MouseEvent, factor: number) => { | 117 | (factor) => { |
118 | setFitZoomCallback(false); | ||
85 | if (canvasRef.current === undefined || zoomRef.current === undefined) { | 119 | if (canvasRef.current === undefined || zoomRef.current === undefined) { |
86 | return; | 120 | return; |
87 | } | 121 | } |
88 | const zoomTransition = makeTransition(canvasRef.current); | 122 | const zoomTransition = makeTransition(canvasRef.current); |
89 | const center: [number, number] = [0, 0]; | 123 | const center: [number, number] = [0, 0]; |
90 | zoomRef.current.scaleBy(zoomTransition, factor, center); | 124 | zoomRef.current.scaleBy(zoomTransition, factor, center); |
91 | event.preventDefault(); | ||
92 | event.stopPropagation(); | ||
93 | }, | 125 | }, |
94 | [makeTransition], | 126 | [makeTransition, setFitZoomCallback], |
95 | ); | 127 | ); |
96 | 128 | ||
97 | const fitZoom = useCallback( | 129 | const onResize = useCallback(() => fitZoomCallback(), [fitZoomCallback]); |
98 | (event: React.MouseEvent) => { | 130 | |
99 | if ( | 131 | const { ref: resizeRef } = useResizeDetector({ |
100 | canvasRef.current === undefined || | 132 | onResize, |
101 | zoomRef.current === undefined || | 133 | refreshMode: 'debounce', |
102 | elementRef.current === undefined | 134 | refreshRate: transitionTimeOrDefault, |
103 | ) { | 135 | }); |
136 | |||
137 | const setCanvas = useCallback( | ||
138 | (canvas: HTMLDivElement | null) => { | ||
139 | canvasRef.current = canvas ?? undefined; | ||
140 | resizeRef(canvas); | ||
141 | if (canvas === null) { | ||
104 | return; | 142 | return; |
105 | } | 143 | } |
106 | const { width: canvasWidth, height: canvasHeight } = | 144 | const zoomBehavior = d3Zoom<HTMLDivElement, unknown>() |
107 | canvasRef.current.getBoundingClientRect(); | 145 | .duration(transitionTimeOrDefault) |
108 | const { width: scaledWidth, height: scaledHeight } = | 146 | .center((event) => { |
109 | elementRef.current.getBoundingClientRect(); | 147 | const { width, height } = canvas.getBoundingClientRect(); |
110 | const currentFactor = d3.zoomTransform(canvasRef.current).k; | 148 | const [x, y] = d3.pointer(event, canvas); |
111 | const width = scaledWidth / currentFactor; | 149 | return [x - width / 2, y - height / 2]; |
112 | const height = scaledHeight / currentFactor; | 150 | }) |
113 | if (width > 0 && height > 0) { | 151 | .centroid([0, 0]); |
114 | const factor = Math.min( | 152 | zoomBehavior.on( |
115 | 1.0, | 153 | 'zoom', |
116 | (canvasWidth - fitPaddingOrDefault) / width, | 154 | (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => { |
117 | (canvasHeight - fitPaddingOrDefault) / height, | 155 | setZoom(event.transform); |
118 | ); | 156 | if (event.sourceEvent) { |
119 | const zoomTransition = makeTransition(canvasRef.current); | 157 | setFitZoomCallback(false); |
120 | zoomRef.current.transform( | 158 | } |
121 | zoomTransition, | 159 | }, |
122 | d3.zoomIdentity.scale(factor), | 160 | ); |
123 | ); | 161 | d3.select(canvas).call(zoomBehavior); |
124 | } | 162 | zoomRef.current = zoomBehavior; |
125 | event.preventDefault(); | ||
126 | event.stopPropagation(); | ||
127 | }, | 163 | }, |
128 | [fitPaddingOrDefault, makeTransition], | 164 | [transitionTimeOrDefault, setFitZoomCallback, resizeRef], |
129 | ); | 165 | ); |
130 | 166 | ||
131 | return ( | 167 | return ( |
@@ -162,16 +198,22 @@ export default function ZoomCanvas({ | |||
162 | }} | 198 | }} |
163 | ref={elementRef} | 199 | ref={elementRef} |
164 | > | 200 | > |
165 | {children} | 201 | {typeof children === 'function' |
202 | ? children(fitZoomCallback) | ||
203 | : children} | ||
166 | </Box> | 204 | </Box> |
167 | </Box> | 205 | </Box> |
168 | <ZoomButtons changeZoom={changeZoom} fitZoom={fitZoom} /> | 206 | <ZoomButtons |
207 | changeZoom={changeZoomCallback} | ||
208 | fitZoom={fitZoom} | ||
209 | setFitZoom={setFitZoomCallback} | ||
210 | /> | ||
169 | </Box> | 211 | </Box> |
170 | ); | 212 | ); |
171 | } | 213 | } |
172 | 214 | ||
173 | ZoomCanvas.defaultProps = { | 215 | ZoomCanvas.defaultProps = { |
174 | children: undefined, | 216 | children: undefined, |
175 | fitPadding: 64, | 217 | fitPadding: 16, |
176 | transitionTime: 250, | 218 | transitionTime: 250, |
177 | }; | 219 | }; |