From 68c4a4bfa852c9f2a656dcc93483365432683502 Mon Sep 17 00:00:00 2001 From: izawartka <59137928+izawartka@users.noreply.github.com> Date: Sun, 6 Jul 2025 21:51:22 +0200 Subject: [PATCH] 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 --- src/components/map/ZoomPanWrapper.js | 175 +++++++++--------- .../map/distance-meter/get-svg-coords.js | 21 ++- src/helpers/constants.js | 1 - src/hooks/useZoomPubSub.js | 12 +- 4 files changed, 104 insertions(+), 105 deletions(-) diff --git a/src/components/map/ZoomPanWrapper.js b/src/components/map/ZoomPanWrapper.js index 9580899..e9b02d2 100644 --- a/src/components/map/ZoomPanWrapper.js +++ b/src/components/map/ZoomPanWrapper.js @@ -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 (
- {children} + + {children} +
); diff --git a/src/components/map/distance-meter/get-svg-coords.js b/src/components/map/distance-meter/get-svg-coords.js index 74729de..27262d7 100644 --- a/src/components/map/distance-meter/get-svg-coords.js +++ b/src/components/map/distance-meter/get-svg-coords.js @@ -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]; } diff --git a/src/helpers/constants.js b/src/helpers/constants.js index 1625d1a..0467dae 100644 --- a/src/helpers/constants.js +++ b/src/helpers/constants.js @@ -1,5 +1,4 @@ const Constants = { - buildVersion: '1.2.0', buildVersion: '1.2.1', map: { zoomSensitivity: 0.002, diff --git a/src/hooks/useZoomPubSub.js b/src/hooks/useZoomPubSub.js index 307d428..fa500be 100644 --- a/src/hooks/useZoomPubSub.js +++ b/src/hooks/useZoomPubSub.js @@ -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 *