From f20474c728e97af79a6d63783619c2515549b107 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Thu, 24 Aug 2023 17:12:16 +0200 Subject: feat(frontend): automatic fit zoom --- subprojects/frontend/src/graph/ZoomCanvas.tsx | 172 ++++++++++++++++---------- 1 file changed, 107 insertions(+), 65 deletions(-) (limited to 'subprojects/frontend/src/graph/ZoomCanvas.tsx') 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'; import * as d3 from 'd3'; import { zoom as d3Zoom } from 'd3-zoom'; import React, { useCallback, useRef, useState } from 'react'; +import { useResizeDetector } from 'react-resize-detector'; import ZoomButtons from './ZoomButtons'; @@ -29,103 +30,138 @@ interface Transform { k: number; } +export type ChangeZoomCallback = (factor: number) => void; + +export type SetFitZoomCallback = (fitZoom: boolean) => void; + +export type FitZoomCallback = (newSize?: { + width: number; + height: number; +}) => void; + export default function ZoomCanvas({ children, fitPadding, transitionTime, }: { - children?: React.ReactNode; + children?: React.ReactNode | ((fitZoom: FitZoomCallback) => React.ReactNode); fitPadding?: number; transitionTime?: number; }): JSX.Element { + const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding; + const transitionTimeOrDefault = + transitionTime ?? ZoomCanvas.defaultProps.transitionTime; + const canvasRef = useRef(); const elementRef = useRef(); const zoomRef = useRef< d3.ZoomBehavior | undefined >(); - const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding; - const transitionTimeOrDefault = - transitionTime ?? ZoomCanvas.defaultProps.transitionTime; - const [zoom, setZoom] = useState({ x: 0, y: 0, k: 1 }); + const [fitZoom, setFitZoom] = useState(true); + const fitZoomRef = useRef(fitZoom); - const setCanvas = useCallback( - (canvas: HTMLDivElement | null) => { - canvasRef.current = canvas ?? undefined; - if (canvas === null) { + const makeTransition = useCallback( + (element: HTMLDivElement) => + d3.select(element).transition().duration(transitionTimeOrDefault), + [transitionTimeOrDefault], + ); + + const fitZoomCallback = useCallback( + (newSize) => { + if ( + !fitZoomRef.current || + canvasRef.current === undefined || + zoomRef.current === undefined || + elementRef.current === undefined + ) { return; } - const zoomBehavior = d3Zoom() - .duration(transitionTimeOrDefault) - .center((event) => { - const { width, height } = canvas.getBoundingClientRect(); - const [x, y] = d3.pointer(event, canvas); - return [x - width / 2, y - height / 2]; - }) - .centroid([0, 0]); - zoomBehavior.on( - 'zoom', - (event: d3.D3ZoomEvent) => - setZoom(event.transform), + let width = 0; + let height = 0; + if (newSize === undefined) { + const elementRect = elementRef.current.getBoundingClientRect(); + const currentFactor = d3.zoomTransform(canvasRef.current).k; + width = elementRect.width / currentFactor; + height = elementRect.height / currentFactor; + } else { + ({ width, height } = newSize); + } + if (width === 0 || height === 0) { + return; + } + const canvasRect = canvasRef.current.getBoundingClientRect(); + const factor = Math.min( + 1.0, + (canvasRect.width - 2 * fitPaddingOrDefault) / width, + (canvasRect.height - 2 * fitPaddingOrDefault) / height, ); - d3.select(canvas).call(zoomBehavior); - zoomRef.current = zoomBehavior; + const zoomTransition = makeTransition(canvasRef.current); + zoomRef.current.transform(zoomTransition, d3.zoomIdentity.scale(factor)); }, - [transitionTimeOrDefault], + [fitPaddingOrDefault, makeTransition], ); - const makeTransition = useCallback( - (element: HTMLDivElement) => - d3.select(element).transition().duration(transitionTimeOrDefault), - [transitionTimeOrDefault], + const setFitZoomCallback = useCallback( + (newFitZoom) => { + setFitZoom(newFitZoom); + fitZoomRef.current = newFitZoom; + if (newFitZoom) { + fitZoomCallback(); + } + }, + [fitZoomCallback], ); - const changeZoom = useCallback( - (event: React.MouseEvent, factor: number) => { + const changeZoomCallback = useCallback( + (factor) => { + setFitZoomCallback(false); if (canvasRef.current === undefined || zoomRef.current === undefined) { return; } const zoomTransition = makeTransition(canvasRef.current); const center: [number, number] = [0, 0]; zoomRef.current.scaleBy(zoomTransition, factor, center); - event.preventDefault(); - event.stopPropagation(); }, - [makeTransition], + [makeTransition, setFitZoomCallback], ); - const fitZoom = useCallback( - (event: React.MouseEvent) => { - if ( - canvasRef.current === undefined || - zoomRef.current === undefined || - elementRef.current === undefined - ) { + const onResize = useCallback(() => fitZoomCallback(), [fitZoomCallback]); + + const { ref: resizeRef } = useResizeDetector({ + onResize, + refreshMode: 'debounce', + refreshRate: transitionTimeOrDefault, + }); + + const setCanvas = useCallback( + (canvas: HTMLDivElement | null) => { + canvasRef.current = canvas ?? undefined; + resizeRef(canvas); + if (canvas === null) { return; } - const { width: canvasWidth, height: canvasHeight } = - canvasRef.current.getBoundingClientRect(); - const { width: scaledWidth, height: scaledHeight } = - elementRef.current.getBoundingClientRect(); - const currentFactor = d3.zoomTransform(canvasRef.current).k; - const width = scaledWidth / currentFactor; - const height = scaledHeight / currentFactor; - if (width > 0 && height > 0) { - const factor = Math.min( - 1.0, - (canvasWidth - fitPaddingOrDefault) / width, - (canvasHeight - fitPaddingOrDefault) / height, - ); - const zoomTransition = makeTransition(canvasRef.current); - zoomRef.current.transform( - zoomTransition, - d3.zoomIdentity.scale(factor), - ); - } - event.preventDefault(); - event.stopPropagation(); + const zoomBehavior = d3Zoom() + .duration(transitionTimeOrDefault) + .center((event) => { + const { width, height } = canvas.getBoundingClientRect(); + const [x, y] = d3.pointer(event, canvas); + return [x - width / 2, y - height / 2]; + }) + .centroid([0, 0]); + zoomBehavior.on( + 'zoom', + (event: d3.D3ZoomEvent) => { + setZoom(event.transform); + if (event.sourceEvent) { + setFitZoomCallback(false); + } + }, + ); + d3.select(canvas).call(zoomBehavior); + zoomRef.current = zoomBehavior; }, - [fitPaddingOrDefault, makeTransition], + [transitionTimeOrDefault, setFitZoomCallback, resizeRef], ); return ( @@ -162,16 +198,22 @@ export default function ZoomCanvas({ }} ref={elementRef} > - {children} + {typeof children === 'function' + ? children(fitZoomCallback) + : children} - + ); } ZoomCanvas.defaultProps = { children: undefined, - fitPadding: 64, + fitPadding: 16, transitionTime: 250, }; -- cgit v1.2.3-54-g00ecf