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.tsx172
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';
8import * as d3 from 'd3'; 8import * as d3 from 'd3';
9import { zoom as d3Zoom } from 'd3-zoom'; 9import { zoom as d3Zoom } from 'd3-zoom';
10import React, { useCallback, useRef, useState } from 'react'; 10import React, { useCallback, useRef, useState } from 'react';
11import { useResizeDetector } from 'react-resize-detector';
11 12
12import ZoomButtons from './ZoomButtons'; 13import ZoomButtons from './ZoomButtons';
13 14
@@ -29,103 +30,138 @@ interface Transform {
29 k: number; 30 k: number;
30} 31}
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
32export default function ZoomCanvas({ 42export 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
173ZoomCanvas.defaultProps = { 215ZoomCanvas.defaultProps = {
174 children: undefined, 216 children: undefined,
175 fitPadding: 64, 217 fitPadding: 16,
176 transitionTime: 250, 218 transitionTime: 250,
177}; 219};