Refactored ZoomPanWrapper

*Refactored ZoomPanWrapper code to be cleaner and more efficient. It now uses refs instead of state and performs camera translation / zooming using transform svg attribute instead of changing the viewbox
This commit is contained in:
izawartka 2025-07-06 21:51:22 +02:00
parent 245d64b03a
commit 68c4a4bfa8
4 changed files with 104 additions and 105 deletions

View File

@ -1,65 +1,56 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useRef, useEffect, useCallback } from 'react';
import './ZoomPanWrapper.css';
import Constants from '../../helpers/constants';
import { useZoomPanSubscriber, viewBox$, clientRect$ } from '../../hooks/useZoomPubSub';
import { useZoomPanSubscriber, viewBox$, clientRect$, camera$ } from '../../hooks/useZoomPubSub';
export default function ZoomPanWrapper({children}) {
const [viewBox, setViewBox] = useState({x: 0, y: 0, w: 100, h: 100}); // Initial viewBox
const wrapperRef = useRef(null);
const svgRef = useRef(null);
const isPanning = useRef(false);
const panStart = useRef({ x: 0, y: 0 });
const vbStart = useRef(viewBox);
const clientRect = useRef(null);
const pendingViewBoxRef = useRef(viewBox);
const cameraWrapperRef = useRef(null);
const viewBoxRef = useRef({ x: -50, y: -50, w: 100, h: 100 });
const cameraRef = useRef({ x: 0, y: 0, zoom: 1 });
const clientRectRef = useRef(null);
const rafScheduledRef = useRef(false);
const isMouseDownRef = useRef(false);
const getViewBoxString = () => {
const { x, y, w, h } = viewBoxRef.current;
return `${x} ${y} ${w} ${h}`;
};
const scheduleViewBoxUpdate = useCallback((newVB) => {
pendingViewBoxRef.current = newVB;
if (!rafScheduledRef.current) {
rafScheduledRef.current = true;
requestAnimationFrame(() => {
setViewBox(pendingViewBoxRef.current);
rafScheduledRef.current = false;
});
}
const updateViewBox = useCallback((viewBox) => {
viewBoxRef.current = viewBox;
svgRef.current.setAttribute('viewBox', getViewBoxString());
viewBox$.next(viewBoxRef.current);
}, []);
const getCameraTransformString = () => {
const { x, y, zoom } = cameraRef.current;
return `scale(${zoom}) translate(${-x},${-y})`;
};
const scheduleCameraUpdate = useCallback(() => {
if (rafScheduledRef.current) return;
rafScheduledRef.current = true;
requestAnimationFrame(() => {
rafScheduledRef.current = false;
cameraWrapperRef.current.setAttribute('transform', getCameraTransformString());
camera$.next(cameraRef.current);
});
}, []);
const handleResize = useCallback(() => {
const rect = wrapperRef.current?.getBoundingClientRect();
if (!rect) return;
clientRect$.next(rect); // Update clientRect BehaviorSubject
if (!clientRect.current) {
// First-time: initialize viewBox so SVG fits wrapper
const initialVB = {
x: -rect.width / 2,
y: -rect.height / 2,
w: rect.width,
h: rect.height
};
clientRect.current = rect;
scheduleViewBoxUpdate(initialVB);
return;
}
const prev = pendingViewBoxRef.current;
const scaleX = rect.width / clientRect.current.width;
const scaleY = rect.height / clientRect.current.height;
const newX = prev.x + (prev.w / 2) * (1 - scaleX);
const newY = prev.y + (prev.h / 2) * (1 - scaleY);
clientRect.current = rect;
scheduleViewBoxUpdate({
x: newX,
y: newY,
w: prev.w * scaleX,
h: prev.h * scaleY,
});
}, [scheduleViewBoxUpdate]);
updateViewBox({ x: -rect.width/2, y: -rect.height/2, w: rect.width, h: rect.height });
clientRect$.next(rect);
clientRectRef.current = rect;
}, [updateViewBox]);
// change viewbox when the window resizes
// update viewbox when the window resizes
useEffect(() => {
window.addEventListener('resize', handleResize);
handleResize(); // Initial call to set the viewBox
@ -68,80 +59,80 @@ export default function ZoomPanWrapper({children}) {
};
}, [handleResize]);
// Whenever viewBox state changes, publish to viewBox$
useEffect(() => {
// Push new value to BehaviorSubject
viewBox$.next(viewBox);
}, [viewBox]);
const centerOn = useCallback((cx, cy) => {
const { w, h } = pendingViewBoxRef.current;
const newX = cx - w / 2;
const newY = cy - h / 2;
scheduleViewBoxUpdate({ x: newX, y: newY, w, h });
}, [scheduleViewBoxUpdate]);
cameraRef.current.x = cx;
cameraRef.current.y = cy;
scheduleCameraUpdate();
}, [scheduleCameraUpdate]);
// Subscribe to any external "center" calls
useZoomPanSubscriber(centerOn);
const onWheel = (e) => {
const { x, y, w, h } = pendingViewBoxRef.current;
const svgRect = svgRef.current.getBoundingClientRect();
const offsetX = e.clientX - svgRect.left;
const offsetY = e.clientY - svgRect.top;
// mouse position in svg coordinates
const mx = (offsetX / svgRect.width) * w + x;
const my = (offsetY / svgRect.height) * h + y;
// zoom factor
const scale = 1.0 + e.deltaY * Constants.map.zoomSensitivity;
const newW = w * scale;
const newH = h * scale;
// adjust x,y so zoom centers at mouse
const newX = mx - (offsetX / svgRect.width) * newW;
const newY = my - (offsetY / svgRect.height) * newH;
scheduleViewBoxUpdate({ x: newX, y: newY, w: newW, h: newH });
const { left, top } = clientRectRef.current;
const { x, y, zoom } = cameraRef.current;
const { w, h } = viewBoxRef.current;
const newZoom = cameraRef.current.zoom * (1.0 - (e.deltaY * Constants.map.zoomSensitivity));
// screen space cursor pos
const cx = e.clientX - left;
const cy = e.clientY - top;
// svg space cursor pos
const scx = x - (w / 2 - cx) / zoom;
const scy = y - (h / 2 - cy) / zoom;
// "new" screen space cursor pos
const ncx = w/2 - (x - scx) * newZoom;
const ncy = h/2 - (y - scy) * newZoom;
// difference in screen space cursor pos
const offX = (ncx - cx) / newZoom;
const offY = (ncy - cy) / newZoom;
cameraRef.current.x += offX;
cameraRef.current.y += offY;
cameraRef.current.zoom = newZoom;
scheduleCameraUpdate();
};
const onMouseDown = (e) => {
e.preventDefault();
isPanning.current = true;
panStart.current = { x: e.clientX, y: e.clientY };
vbStart.current = pendingViewBoxRef.current;
isMouseDownRef.current = true;
};
const onMouseMove = (e) => {
if (!isPanning.current) return;
if (!isMouseDownRef.current) return;
e.preventDefault();
const dx = ((e.clientX - panStart.current.x) / svgRef.current.clientWidth) * vbStart.current.w;
const dy = ((e.clientY - panStart.current.y) / svgRef.current.clientHeight) * vbStart.current.h;
scheduleViewBoxUpdate({
x: vbStart.current.x - dx,
y: vbStart.current.y - dy,
w: vbStart.current.w,
h: vbStart.current.h,
});
cameraRef.current.x -= e.movementX / cameraRef.current.zoom;
cameraRef.current.y -= e.movementY / cameraRef.current.zoom;
scheduleCameraUpdate();
};
const onMouseUp = () => {
isPanning.current = false;
const onMouseUp = (e) => {
e.preventDefault();
isMouseDownRef.current = false;
};
return (
<div className="zoom-pan-wrapper" ref={wrapperRef}>
<svg
ref={svgRef}
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`}
viewBox={getViewBoxString()}
onWheel={onWheel}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
>
{children}
<g ref={cameraWrapperRef} transform={getCameraTransformString()}>
{children}
</g>
</svg>
</div>
);

View File

@ -1,14 +1,17 @@
import { getCurrentClientRect, getCurrentViewBox } from "../../../hooks/useZoomPubSub";
import { getCurrentCamera, getCurrentClientRect, getCurrentViewBox } from "../../../hooks/useZoomPubSub";
export function getSVGCoords(event) {
const rect = getCurrentClientRect();
const viewBox = getCurrentViewBox();
if (!rect || !viewBox) return null;
const { left, top } = getCurrentClientRect();
const { x, y, zoom } = getCurrentCamera();
const { w, h } = getCurrentViewBox();
// screen space cursor pos
const cx = event.clientX - left;
const cy = event.clientY - top;
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const adjustedX = viewBox.x + (x / rect.width) * viewBox.w;
const adjustedY = viewBox.y + (y / rect.height) * viewBox.h;
// svg space cursor pos
const scx = x - (w / 2 - cx) / zoom;
const scy = y - (h / 2 - cy) / zoom;
return [adjustedX, adjustedY];
return [scx, scy];
}

View File

@ -1,5 +1,4 @@
const Constants = {
buildVersion: '1.2.0',
buildVersion: '1.2.1',
map: {
zoomSensitivity: 0.002,

View File

@ -3,11 +3,10 @@ import { Subject, BehaviorSubject } from 'rxjs';
const zoomCenter$ = new Subject();
// BehaviorSubject to store current viewBox
// BehaviorSubjects to store current viewBox, clientRect and camera transform
export const viewBox$ = new BehaviorSubject(null);
// BehaviorSubject to store the clientRect
export const clientRect$ = new BehaviorSubject(null);
export const camera$ = new BehaviorSubject(0);
/**
* Returns the current viewBox value synchronously.
@ -23,6 +22,13 @@ export function getCurrentClientRect() {
return clientRect$.getValue();
}
/**
* Returns the current camera transform value synchronously.
*/
export function getCurrentCamera() {
return camera$.getValue();
}
/**
* useZoomPanSubscriber
*