/*
* SPDX-FileCopyrightText: 2023 The Refinery Authors
*
* SPDX-License-Identifier: EPL-2.0
*/
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';
declare module 'd3-zoom' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Redeclaring type parameters.
interface ZoomBehavior {
// `@types/d3-zoom` does not contain the `center` function, because it is
// only available as a pull request for `d3-zoom`.
center(callback: (event: MouseEvent | Touch) => [number, number]): this;
// Custom `centroid` method added via patch.
centroid(centroid: [number, number]): this;
}
}
interface Transform {
x: number;
y: number;
k: number;
}
export type ChangeZoomCallback = (factor: number) => void;
export type SetFitZoomCallback = (fitZoom: boolean) => void;
export type FitZoomCallback = ((newSize?: {
width: number;
height: number;
}) => void) &
((newSize: boolean) => void);
export default function ZoomCanvas({
children,
fitPadding,
transitionTime,
}: {
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 [zoom, setZoom] = useState({ x: 0, y: 0, k: 1 });
const [fitZoom, setFitZoom] = useState(true);
const fitZoomRef = useRef(fitZoom);
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;
}
let width = 0;
let height = 0;
if (newSize === undefined || typeof newSize === 'boolean') {
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,
);
const target =
newSize === false
? d3.select(canvasRef.current)
: makeTransition(canvasRef.current);
zoomRef.current.transform(target, d3.zoomIdentity.scale(factor));
},
[fitPaddingOrDefault, makeTransition],
);
const setFitZoomCallback = useCallback(
(newFitZoom) => {
setFitZoom(newFitZoom);
fitZoomRef.current = newFitZoom;
if (newFitZoom) {
fitZoomCallback();
}
},
[fitZoomCallback],
);
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);
},
[makeTransition, setFitZoomCallback],
);
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 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])
.scaleExtent([1 / 32, 8]);
zoomBehavior.on(
'zoom',
(event: d3.D3ZoomEvent) => {
setZoom(event.transform);
if (event.sourceEvent) {
setFitZoomCallback(false);
}
},
);
d3.select(canvas).call(zoomBehavior);
zoomRef.current = zoomBehavior;
},
[transitionTimeOrDefault, setFitZoomCallback, resizeRef],
);
return (
{typeof children === 'function'
? children(fitZoomCallback)
: children}
);
}
ZoomCanvas.defaultProps = {
children: undefined,
fitPadding: 8,
transitionTime: 250,
};