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 (
);
-}
+}
\ 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);
}