diff --git a/src/components/map/ZoomPanWrapper.css b/src/components/map/ZoomPanWrapper.css index 0c3db32..ffc150e 100644 --- a/src/components/map/ZoomPanWrapper.css +++ b/src/components/map/ZoomPanWrapper.css @@ -1,6 +1,8 @@ .zoom-pan-wrapper { flex: 1; position: relative; + touch-action: none; + user-select: none; } .zoom-pan-wrapper svg { diff --git a/src/components/map/ZoomPanWrapper.jsx b/src/components/map/ZoomPanWrapper.jsx index 597daf5..0109263 100644 --- a/src/components/map/ZoomPanWrapper.jsx +++ b/src/components/map/ZoomPanWrapper.jsx @@ -10,6 +10,13 @@ function boundZoom(zoom) { return Math.max(Constants.map.zoomMin, Math.min(Constants.map.zoomMax, zoom)); } +const getDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); +const getAngle = (a, b) => Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI; +const getMidpoint = (a, b) => ({ + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2 +}); + export default function ZoomPanWrapper({children}) { const wrapperRef = useRef(null); const svgRef = useRef(null); @@ -18,7 +25,9 @@ export default function ZoomPanWrapper({children}) { const cameraRef = useRef({ x: 0, y: 0, zoom: 1, rotation: 0 }); const clientRectRef = useRef(null); const rafScheduledRef = useRef(false); - const isMouseDownRef = useRef(false); + const pointersRef = useRef(new Map()); + const lastPointerRef = useRef(null); + const twoFingerStartRef = useRef(null); const getViewBoxString = () => { const { x, y, w, h } = viewBoxRef.current; @@ -59,28 +68,157 @@ export default function ZoomPanWrapper({children}) { clientRectRef.current = rect; }, [updateViewBox]); - const handleRotation = (e) => { - const deltaAngle = e.movementX * Constants.map.rotationSensitivity; - if(deltaAngle === 0) return; + const applyScreenPan = useCallback((dxScreen, dyScreen) => { + const { zoom, rotation } = cameraRef.current; + const dx = dxScreen / zoom; + const dy = dyScreen / zoom; + const rad = AngleHelper.degToRad(rotation); + const cos = Math.cos(rad); + const sin = Math.sin(rad); - cameraRef.current.rotation = cameraRef.current.rotation + deltaAngle + cameraRef.current.x -= dx * cos + dy * sin; + cameraRef.current.y -= dy * cos - dx * sin; + + scheduleCameraUpdate(); + }, [scheduleCameraUpdate]); + + const getScreenPoint = (clientX, clientY) => { + const rect = clientRectRef.current; + return { + x: clientX - rect.left - rect.width / 2, + y: clientY - rect.top - rect.height / 2 + }; + }; + + const screenPointToWorld = (point, camera) => { + const rad = AngleHelper.degToRad(camera.rotation); + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const sx = point.x / camera.zoom; + const sy = point.y / camera.zoom; + + return { + x: camera.x + sx * cos + sy * sin, + y: camera.y - sx * sin + sy * cos + }; + }; + + const startTwoFingerGesture = (pointers) => { + const [first, second] = pointers; + const distance = getDistance(first, second); + const angle = getAngle(first, second); + const midpoint = getMidpoint(first, second); + const screenPoint = getScreenPoint(midpoint.x, midpoint.y); + const worldPoint = screenPointToWorld(screenPoint, cameraRef.current); + + twoFingerStartRef.current = { + distance, + angle, + zoom: cameraRef.current.zoom, + rotation: cameraRef.current.rotation, + worldPoint + }; + }; + + const updateTwoFingerGesture = (pointers) => { + const start = twoFingerStartRef.current; + if (!start) return; + + const [first, second] = pointers; + const distance = getDistance(first, second); + const angle = getAngle(first, second); + const midpoint = getMidpoint(first, second); + + const newZoom = boundZoom(start.zoom * (distance / start.distance)); + const deltaAngle = AngleHelper.normalizeAngleDelta(angle - start.angle); + const newRotation = AngleHelper.normalizeDegAngle(start.rotation + deltaAngle); + + const screenPoint = getScreenPoint(midpoint.x, midpoint.y); + const rad = AngleHelper.degToRad(newRotation); + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const sx = screenPoint.x / newZoom; + const sy = screenPoint.y / newZoom; + + cameraRef.current.zoom = newZoom; + cameraRef.current.rotation = newRotation; + cameraRef.current.x = start.worldPoint.x - (sx * cos + sy * sin); + cameraRef.current.y = start.worldPoint.y - (-sx * sin + sy * cos); scheduleCameraUpdate(); }; - const handleMove = (e) => { - const cx = e.movementX / cameraRef.current.zoom; - const cy = e.movementY / cameraRef.current.zoom; - const cos = Math.cos(AngleHelper.degToRad(cameraRef.current.rotation)); - const sin = Math.sin(AngleHelper.degToRad(cameraRef.current.rotation)); + const onPointerDown = (e) => { + pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); - cameraRef.current.x -= cx * cos + cy * sin; - cameraRef.current.y -= cy * cos - cx * sin; - - scheduleCameraUpdate(); + if (pointersRef.current.size === 1) { + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + twoFingerStartRef.current = null; + } else if (pointersRef.current.size === 2) { + startTwoFingerGesture(Array.from(pointersRef.current.values())); + lastPointerRef.current = null; + } }; - // update viewbox when the window resizes + const onPointerMove = (e) => { + if (!pointersRef.current.has(e.pointerId)) return; + + pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + if (pointersRef.current.size === 1) { + const last = lastPointerRef.current; + if (!last) return; + + const dx = e.clientX - last.x; + const dy = e.clientY - last.y; + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + + if (e.pointerType === 'mouse' && e.altKey) { + const deltaAngle = dx * Constants.map.rotationSensitivity; + if (deltaAngle !== 0) { + cameraRef.current.rotation += deltaAngle; + scheduleCameraUpdate(); + } + } else { + applyScreenPan(dx, dy); + } + return; + } + + if (pointersRef.current.size >= 2) { + const firstTwo = Array.from(pointersRef.current.values()).slice(0, 2); + if (!twoFingerStartRef.current) { + startTwoFingerGesture(firstTwo); + } else { + updateTwoFingerGesture(firstTwo); + } + } + }; + + const onPointerUp = (e) => { + pointersRef.current.delete(e.pointerId); + + if (pointersRef.current.size === 0) { + lastPointerRef.current = null; + twoFingerStartRef.current = null; + return; + } + + if (pointersRef.current.size === 1) { + const remaining = Array.from(pointersRef.current.values())[0]; + lastPointerRef.current = { x: remaining.x, y: remaining.y }; + twoFingerStartRef.current = null; + return; + } + + if (pointersRef.current.size >= 2) { + startTwoFingerGesture(Array.from(pointersRef.current.values()).slice(0, 2)); + lastPointerRef.current = null; + } + }; + + const onPointerCancel = onPointerUp; + useEffect(() => { window.addEventListener('resize', handleResize); handleResize(); // Initial call to set the viewBox @@ -113,6 +251,8 @@ export default function ZoomPanWrapper({children}) { useZoomPanSubscriber(setCamera, alignView); const onWheel = (e) => { + if (!clientRectRef.current) return; + const { left, top } = clientRectRef.current; const { x, y, zoom } = cameraRef.current; const { w, h } = viewBoxRef.current; @@ -146,37 +286,16 @@ export default function ZoomPanWrapper({children}) { scheduleCameraUpdate(); }; - const onMouseDown = (e) => { - e.preventDefault(); - isMouseDownRef.current = true; - }; - - const onMouseMove = (e) => { - if (!isMouseDownRef.current) return; - e.preventDefault(); - - if(e.altKey) { - handleRotation(e); - } else { - handleMove(e); - } - }; - - const onMouseUp = (e) => { - e.preventDefault(); - isMouseDownRef.current = false; - }; - return (
{children} @@ -184,4 +303,4 @@ export default function ZoomPanWrapper({children}) {
); -} +} \ No newline at end of file diff --git a/src/components/map/distance-meter/DistanceMeter.jsx b/src/components/map/distance-meter/DistanceMeter.jsx index a17d648..65c2b21 100644 --- a/src/components/map/distance-meter/DistanceMeter.jsx +++ b/src/components/map/distance-meter/DistanceMeter.jsx @@ -1,4 +1,5 @@ -import { useContext, useEffect } from "react"; +import { useContext, useEffect, useRef, useCallback } from "react"; +import Constants from '../../../helpers/constants'; import SceneryContext from "../../../contexts/SceneryContext"; import DistanceMeterContext from "../../../contexts/DistanceMeterContext"; import { getSVGCoords } from "./get-svg-coords"; @@ -7,8 +8,24 @@ import DistanceMeterView from "./DistanceMeterView"; export default function DistanceMeter() { const { distancePoints, setDistancePoints } = useContext(DistanceMeterContext); const { scenery } = useContext(SceneryContext); + const pointersRef = useRef(new Set()); + const mainPointerIdRef = useRef(null); + const pointerDownPosRef = useRef(null); + + const onPointerUp = useCallback((event) => { + const dx = pointerDownPosRef.current?.x - event.clientX; + const dy = pointerDownPosRef.current?.y - event.clientY; - const onClick = (event) => { + pointersRef.current.delete(event.pointerId); + if (mainPointerIdRef.current !== event.pointerId) return; + mainPointerIdRef.current = null; + pointerDownPosRef.current = null; + + if (pointersRef.current.size > 0) return; + + const dist = Math.sqrt(dx*dx + dy*dy); + if (dist > Constants.map.distanceMeterMaxMovePx) return; + setDistancePoints(oldDistancePoints => { if(!Array.isArray(oldDistancePoints)) return oldDistancePoints; const pos = getSVGCoords(event); @@ -16,7 +33,26 @@ export default function DistanceMeter() { return [...oldDistancePoints, pos]; }); - } + }, [setDistancePoints]); + + const onPointerDown = useCallback((event) => { + pointersRef.current.add(event.pointerId); + if(mainPointerIdRef.current !== null) return; + + mainPointerIdRef.current = event.pointerId; + pointerDownPosRef.current = { + x: event.clientX, + y: event.clientY + }; + }, []); + + const onPointerLeave = useCallback((event) => { + pointersRef.current.delete(event.pointerId); + if (mainPointerIdRef.current !== event.pointerId) + + mainPointerIdRef.current = null; + pointerDownPosRef.current = null; + }, []); useEffect(() => { setDistancePoints([]); @@ -32,7 +68,10 @@ export default function DistanceMeter() { y={-scenery.bounds.maxZ} width={scenery.bounds.maxX - scenery.bounds.minX} height={scenery.bounds.maxZ - scenery.bounds.minZ} - onClick={onClick} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + onPointerCancel={onPointerLeave} + onPointerLeave={onPointerLeave} no-export="true" /> diff --git a/src/components/map/distance-meter/DistanceMeterCircle.jsx b/src/components/map/distance-meter/DistanceMeterCircle.jsx index d6e2383..77af49f 100644 --- a/src/components/map/distance-meter/DistanceMeterCircle.jsx +++ b/src/components/map/distance-meter/DistanceMeterCircle.jsx @@ -1,11 +1,11 @@ -import { useRef } from 'react'; +import { useRef, useCallback } from 'react'; import { getSVGCoords } from './get-svg-coords'; export default function DistanceMeterCircle(props) { const { point, index, setDistancePoints } = props; - const isDragged = useRef(false); + const isDraggingRef = useRef(false); - const updatePointPosition = (event) => { + const updatePointPosition = useCallback((event) => { const pos = getSVGCoords(event); if (!pos) return; @@ -15,57 +15,75 @@ export default function DistanceMeterCircle(props) { newPoints[index] = pos; return newPoints; }); - } + }, [setDistancePoints, index]); - const removePoint = () => { + const removePoint = useCallback(() => { setDistancePoints(oldPoints => { if (!Array.isArray(oldPoints)) return oldPoints; const newPoints = [...oldPoints]; newPoints.splice(index, 1); return newPoints; }); - } + }, [setDistancePoints, index]); - const onMouseDown = (event) => { + const onPointerDown = useCallback((event) => { event.stopPropagation(); event.preventDefault(); - if (isDragged.current) return; - isDragged.current = true; + event.currentTarget.setPointerCapture(event.pointerId); - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp, { once: true }); - } + isDraggingRef.current = true; + }, []); - const onMouseUp = (event) => { + const onPointerUp = useCallback((event) => { event.stopPropagation(); event.preventDefault(); - if (!isDragged.current) return; - isDragged.current = false; + if (!isDraggingRef.current) return; - document.removeEventListener('mousemove', onMouseMove); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + isDraggingRef.current = false; updatePointPosition(event); - } + }, [updatePointPosition]); - const onMouseMove = (event) => { - if (!isDragged.current) return; + const onPointerMove = useCallback((event) => { + if (!isDraggingRef.current) return; event.stopPropagation(); event.preventDefault(); updatePointPosition(event); - } + }, [updatePointPosition]); - const onContextMenu = (event) => { + const onPointerCancel = useCallback((event) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + isDraggingRef.current = false; + }, []); + + const onContextMenu = useCallback((event) => { event.stopPropagation(); event.preventDefault(); + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + isDraggingRef.current = false; removePoint(); - } + }, [removePoint]); return ( - { + const onPointerDown = (event) => { event.stopPropagation(); event.preventDefault(); const pos = getSVGCoords(event); @@ -33,7 +33,7 @@ export default function DistanceMeterLine(props) { y2={end[1]} stroke="transparent" strokeWidth={3} - onMouseDown={onMouseDown} + onPointerDown={onPointerDown} /> { + setTouchClickedTrack(object); + }, [object]); + const onMouseEnter = useCallback(() => { setHoveredTrack(object); }, [object]); const onMouseLeave = useCallback(() => { - unsetHoveredTrack(null); - }, []); + unsetHoveredTrack(object); + }, [object]); - const onClick = useCallback((event) => { - if (object.shape === TrackShape.STRAIGHT && event.detail === 2) { + const onContextMenu = useCallback((event) => { + if (object.shape === TrackShape.STRAIGHT) { event.preventDefault(); onAlign(event); } @@ -96,6 +100,7 @@ function StatelessTrackRenderer(props) { className="track" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + onContextMenu={onContextMenu} onClick={onClick} export-force-visible="true" /> diff --git a/src/components/map/track-hover-info/TrackHoverInfoPopup.jsx b/src/components/map/track-hover-info/TrackHoverInfoPopup.jsx index 990d08e..c8eca26 100644 --- a/src/components/map/track-hover-info/TrackHoverInfoPopup.jsx +++ b/src/components/map/track-hover-info/TrackHoverInfoPopup.jsx @@ -9,7 +9,7 @@ export default function TrackHoverInfoPopup({track}) { {showAlignHint && (
- Double click to align the view + Right click to align the view
)} diff --git a/src/components/sidemenu/SideMenu.jsx b/src/components/sidemenu/SideMenu.jsx index 5b2a3b2..e71a6a2 100644 --- a/src/components/sidemenu/SideMenu.jsx +++ b/src/components/sidemenu/SideMenu.jsx @@ -15,14 +15,14 @@ export default function SideMenu() { return (
-
+
+
); } \ No newline at end of file diff --git a/src/helpers/angleHelper.js b/src/helpers/angleHelper.js index ddd5fe2..8bab503 100644 --- a/src/helpers/angleHelper.js +++ b/src/helpers/angleHelper.js @@ -42,13 +42,20 @@ function normalizeDegVector(vector) { ) } +function normalizeAngleDelta(delta) { + if (delta > 180) delta -= 360; + if (delta < -180) delta += 360; + return delta; +} + const AngleHelper = { degToRad, radToDeg, vectorDegToRad, vectorRadToDeg, normalizeDegAngle, - normalizeDegVector + normalizeDegVector, + normalizeAngleDelta } export default AngleHelper; diff --git a/src/helpers/constants.js b/src/helpers/constants.js index df7f06e..a89bd0a 100644 --- a/src/helpers/constants.js +++ b/src/helpers/constants.js @@ -14,7 +14,8 @@ const Constants = { platformMaxTilt: 0.5, showDebugTrackIds: false, mapZoomTrackDetailsThreshold: 1 / 1.44, - mapZoomObjectDetailsThreshold: 0.5 + mapZoomObjectDetailsThreshold: 0.5, + distanceMeterMaxMovePx: 10 }, parser: { logSceneryAfterFinished: readEnvBool('VITE_DO_LOG_SCENERY') ?? false, diff --git a/src/services/trackHoverInfoService.js b/src/services/trackHoverInfoService.js index aa03d33..3d6726e 100644 --- a/src/services/trackHoverInfoService.js +++ b/src/services/trackHoverInfoService.js @@ -2,6 +2,11 @@ import { BehaviorSubject } from 'rxjs'; const hoveredTracksStack = []; export const hoveredTrack$ = new BehaviorSubject(null); +export const touchClickedTrack$ = new BehaviorSubject(null); + +export function setTouchClickedTrack(track) { + touchClickedTrack$.next(track); +} export function setHoveredTrack(track) { hoveredTracksStack.push(track); @@ -16,4 +21,5 @@ export function unsetHoveredTrack(track) { export function resetHoveredTracksStack() { hoveredTracksStack.length = 0; hoveredTrack$.next(null); + touchClickedTrack$.next(null); }