From f20474c728e97af79a6d63783619c2515549b107 Mon Sep 17 00:00:00 2001
From: Kristóf Marussy <kristof@marussy.com>
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<HTMLDivElement | undefined>();
   const elementRef = useRef<HTMLDivElement | undefined>();
   const zoomRef = useRef<
     d3.ZoomBehavior<HTMLDivElement, unknown> | undefined
   >();
-  const fitPaddingOrDefault = fitPadding ?? ZoomCanvas.defaultProps.fitPadding;
-  const transitionTimeOrDefault =
-    transitionTime ?? ZoomCanvas.defaultProps.transitionTime;
-
   const [zoom, setZoom] = useState<Transform>({ 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<FitZoomCallback>(
+    (newSize) => {
+      if (
+        !fitZoomRef.current ||
+        canvasRef.current === undefined ||
+        zoomRef.current === undefined ||
+        elementRef.current === undefined
+      ) {
         return;
       }
-      const zoomBehavior = d3Zoom<HTMLDivElement, unknown>()
-        .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<HTMLDivElement, unknown>) =>
-          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<SetFitZoomCallback>(
+    (newFitZoom) => {
+      setFitZoom(newFitZoom);
+      fitZoomRef.current = newFitZoom;
+      if (newFitZoom) {
+        fitZoomCallback();
+      }
+    },
+    [fitZoomCallback],
   );
 
-  const changeZoom = useCallback(
-    (event: React.MouseEvent, factor: number) => {
+  const changeZoomCallback = useCallback<ChangeZoomCallback>(
+    (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<HTMLDivElement, unknown>()
+        .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<HTMLDivElement, unknown>) => {
+          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}
         </Box>
       </Box>
-      <ZoomButtons changeZoom={changeZoom} fitZoom={fitZoom} />
+      <ZoomButtons
+        changeZoom={changeZoomCallback}
+        fitZoom={fitZoom}
+        setFitZoom={setFitZoomCallback}
+      />
     </Box>
   );
 }
 
 ZoomCanvas.defaultProps = {
   children: undefined,
-  fitPadding: 64,
+  fitPadding: 16,
   transitionTime: 250,
 };
-- 
cgit v1.2.3-54-g00ecf

