Tweak controls for mobile devices

- Updated scenery zoom pan wrapper, track hover info and distance meter to work correctly on touch screen / mobile devices
- Fixed SideMenu still responding to tab navigation even if closed
This commit is contained in:
izawartka 2026-08-13 19:08:26 +02:00
parent a1a27a9ca0
commit 0bedd56cc8
11 changed files with 278 additions and 81 deletions

View File

@ -1,6 +1,8 @@
.zoom-pan-wrapper { .zoom-pan-wrapper {
flex: 1; flex: 1;
position: relative; position: relative;
touch-action: none;
user-select: none;
} }
.zoom-pan-wrapper svg { .zoom-pan-wrapper svg {

View File

@ -10,6 +10,13 @@ function boundZoom(zoom) {
return Math.max(Constants.map.zoomMin, Math.min(Constants.map.zoomMax, 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}) { export default function ZoomPanWrapper({children}) {
const wrapperRef = useRef(null); const wrapperRef = useRef(null);
const svgRef = 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 cameraRef = useRef({ x: 0, y: 0, zoom: 1, rotation: 0 });
const clientRectRef = useRef(null); const clientRectRef = useRef(null);
const rafScheduledRef = useRef(false); const rafScheduledRef = useRef(false);
const isMouseDownRef = useRef(false); const pointersRef = useRef(new Map());
const lastPointerRef = useRef(null);
const twoFingerStartRef = useRef(null);
const getViewBoxString = () => { const getViewBoxString = () => {
const { x, y, w, h } = viewBoxRef.current; const { x, y, w, h } = viewBoxRef.current;
@ -59,28 +68,157 @@ export default function ZoomPanWrapper({children}) {
clientRectRef.current = rect; clientRectRef.current = rect;
}, [updateViewBox]); }, [updateViewBox]);
const handleRotation = (e) => { const applyScreenPan = useCallback((dxScreen, dyScreen) => {
const deltaAngle = e.movementX * Constants.map.rotationSensitivity; const { zoom, rotation } = cameraRef.current;
if(deltaAngle === 0) return; 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(); scheduleCameraUpdate();
}; };
const handleMove = (e) => { const onPointerDown = (e) => {
const cx = e.movementX / cameraRef.current.zoom; pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
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));
cameraRef.current.x -= cx * cos + cy * sin; if (pointersRef.current.size === 1) {
cameraRef.current.y -= cy * cos - cx * sin; lastPointerRef.current = { x: e.clientX, y: e.clientY };
twoFingerStartRef.current = null;
scheduleCameraUpdate(); } 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(() => { useEffect(() => {
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
handleResize(); // Initial call to set the viewBox handleResize(); // Initial call to set the viewBox
@ -113,6 +251,8 @@ export default function ZoomPanWrapper({children}) {
useZoomPanSubscriber(setCamera, alignView); useZoomPanSubscriber(setCamera, alignView);
const onWheel = (e) => { const onWheel = (e) => {
if (!clientRectRef.current) return;
const { left, top } = clientRectRef.current; const { left, top } = clientRectRef.current;
const { x, y, zoom } = cameraRef.current; const { x, y, zoom } = cameraRef.current;
const { w, h } = viewBoxRef.current; const { w, h } = viewBoxRef.current;
@ -146,37 +286,16 @@ export default function ZoomPanWrapper({children}) {
scheduleCameraUpdate(); 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 ( return (
<div className="zoom-pan-wrapper" ref={wrapperRef}> <div className="zoom-pan-wrapper" ref={wrapperRef}>
<svg <svg
ref={svgRef} ref={svgRef}
viewBox={getViewBoxString()} viewBox={getViewBoxString()}
onWheel={onWheel} onWheel={onWheel}
onMouseDown={onMouseDown} onPointerDown={onPointerDown}
onMouseMove={onMouseMove} onPointerMove={onPointerMove}
onMouseUp={onMouseUp} onPointerUp={onPointerUp}
onMouseLeave={onMouseUp} onPointerCancel={onPointerCancel}
> >
<g ref={cameraWrapperRef} transform={getCameraTransformString()}> <g ref={cameraWrapperRef} transform={getCameraTransformString()}>
{children} {children}
@ -184,4 +303,4 @@ export default function ZoomPanWrapper({children}) {
</svg> </svg>
</div> </div>
); );
} }

View File

@ -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 SceneryContext from "../../../contexts/SceneryContext";
import DistanceMeterContext from "../../../contexts/DistanceMeterContext"; import DistanceMeterContext from "../../../contexts/DistanceMeterContext";
import { getSVGCoords } from "./get-svg-coords"; import { getSVGCoords } from "./get-svg-coords";
@ -7,8 +8,24 @@ import DistanceMeterView from "./DistanceMeterView";
export default function DistanceMeter() { export default function DistanceMeter() {
const { distancePoints, setDistancePoints } = useContext(DistanceMeterContext); const { distancePoints, setDistancePoints } = useContext(DistanceMeterContext);
const { scenery } = useContext(SceneryContext); 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 => { setDistancePoints(oldDistancePoints => {
if(!Array.isArray(oldDistancePoints)) return oldDistancePoints; if(!Array.isArray(oldDistancePoints)) return oldDistancePoints;
const pos = getSVGCoords(event); const pos = getSVGCoords(event);
@ -16,7 +33,26 @@ export default function DistanceMeter() {
return [...oldDistancePoints, pos]; 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(() => { useEffect(() => {
setDistancePoints([]); setDistancePoints([]);
@ -32,7 +68,10 @@ export default function DistanceMeter() {
y={-scenery.bounds.maxZ} y={-scenery.bounds.maxZ}
width={scenery.bounds.maxX - scenery.bounds.minX} width={scenery.bounds.maxX - scenery.bounds.minX}
height={scenery.bounds.maxZ - scenery.bounds.minZ} height={scenery.bounds.maxZ - scenery.bounds.minZ}
onClick={onClick} onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={onPointerLeave}
onPointerLeave={onPointerLeave}
no-export="true" no-export="true"
/> />
<DistanceMeterView distancePoints={distancePoints} setDistancePoints={setDistancePoints} /> <DistanceMeterView distancePoints={distancePoints} setDistancePoints={setDistancePoints} />

View File

@ -1,11 +1,11 @@
import { useRef } from 'react'; import { useRef, useCallback } from 'react';
import { getSVGCoords } from './get-svg-coords'; import { getSVGCoords } from './get-svg-coords';
export default function DistanceMeterCircle(props) { export default function DistanceMeterCircle(props) {
const { point, index, setDistancePoints } = 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); const pos = getSVGCoords(event);
if (!pos) return; if (!pos) return;
@ -15,57 +15,75 @@ export default function DistanceMeterCircle(props) {
newPoints[index] = pos; newPoints[index] = pos;
return newPoints; return newPoints;
}); });
} }, [setDistancePoints, index]);
const removePoint = () => { const removePoint = useCallback(() => {
setDistancePoints(oldPoints => { setDistancePoints(oldPoints => {
if (!Array.isArray(oldPoints)) return oldPoints; if (!Array.isArray(oldPoints)) return oldPoints;
const newPoints = [...oldPoints]; const newPoints = [...oldPoints];
newPoints.splice(index, 1); newPoints.splice(index, 1);
return newPoints; return newPoints;
}); });
} }, [setDistancePoints, index]);
const onMouseDown = (event) => { const onPointerDown = useCallback((event) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
if (isDragged.current) return; event.currentTarget.setPointerCapture(event.pointerId);
isDragged.current = true;
document.addEventListener('mousemove', onMouseMove); isDraggingRef.current = true;
document.addEventListener('mouseup', onMouseUp, { once: true }); }, []);
}
const onMouseUp = (event) => { const onPointerUp = useCallback((event) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
if (!isDragged.current) return; if (!isDraggingRef.current) return;
isDragged.current = false;
document.removeEventListener('mousemove', onMouseMove); if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
isDraggingRef.current = false;
updatePointPosition(event); updatePointPosition(event);
} }, [updatePointPosition]);
const onMouseMove = (event) => { const onPointerMove = useCallback((event) => {
if (!isDragged.current) return; if (!isDraggingRef.current) return;
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
updatePointPosition(event); 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.stopPropagation();
event.preventDefault(); event.preventDefault();
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
isDraggingRef.current = false;
removePoint(); removePoint();
} }, [removePoint]);
return ( return (
<g <g
className="distance-meter-circle" className="distance-meter-circle"
onMouseDown={onMouseDown} onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerMove={onPointerMove}
onPointerCancel={onPointerCancel}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
> >
<circle <circle

View File

@ -13,7 +13,7 @@ export default function DistanceMeterLine(props) {
}); });
} }
const onMouseDown = (event) => { const onPointerDown = (event) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
const pos = getSVGCoords(event); const pos = getSVGCoords(event);
@ -33,7 +33,7 @@ export default function DistanceMeterLine(props) {
y2={end[1]} y2={end[1]}
stroke="transparent" stroke="transparent"
strokeWidth={3} strokeWidth={3}
onMouseDown={onMouseDown} onPointerDown={onPointerDown}
/> />
<line <line
x1={start[0]} x1={start[0]}

View File

@ -3,7 +3,7 @@ import SettingsContext from "../../../contexts/SettingsContext";
import Constants from "../../../helpers/constants"; import Constants from "../../../helpers/constants";
import {ElectrificationStatus} from "../../../model/electrification-status"; import {ElectrificationStatus} from "../../../model/electrification-status";
import MiscHelper from "../../../helpers/miscHelper"; import MiscHelper from "../../../helpers/miscHelper";
import {setHoveredTrack, unsetHoveredTrack} from "../../../services/trackHoverInfoService"; import {setHoveredTrack, setTouchClickedTrack, unsetHoveredTrack} from "../../../services/trackHoverInfoService";
import GradientsContext from "../../../contexts/GradientsContext"; import GradientsContext from "../../../contexts/GradientsContext";
import { TrackShape, TrackSource } from "../../../model/tracks/track"; import { TrackShape, TrackSource } from "../../../model/tracks/track";
import {useZoomPanEmitter} from "../../../hooks/useZoomPubSub"; import {useZoomPanEmitter} from "../../../hooks/useZoomPubSub";
@ -36,16 +36,20 @@ function StatelessTrackRenderer(props) {
const unscaledPathRef = React.useRef(null); const unscaledPathRef = React.useRef(null);
const scaledPathRef = React.useRef(null); const scaledPathRef = React.useRef(null);
const onClick = useCallback(() => {
setTouchClickedTrack(object);
}, [object]);
const onMouseEnter = useCallback(() => { const onMouseEnter = useCallback(() => {
setHoveredTrack(object); setHoveredTrack(object);
}, [object]); }, [object]);
const onMouseLeave = useCallback(() => { const onMouseLeave = useCallback(() => {
unsetHoveredTrack(null); unsetHoveredTrack(object);
}, []); }, [object]);
const onClick = useCallback((event) => { const onContextMenu = useCallback((event) => {
if (object.shape === TrackShape.STRAIGHT && event.detail === 2) { if (object.shape === TrackShape.STRAIGHT) {
event.preventDefault(); event.preventDefault();
onAlign(event); onAlign(event);
} }
@ -96,6 +100,7 @@ function StatelessTrackRenderer(props) {
className="track" className="track"
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
onContextMenu={onContextMenu}
onClick={onClick} onClick={onClick}
export-force-visible="true" export-force-visible="true"
/> />

View File

@ -9,7 +9,7 @@ export default function TrackHoverInfoPopup({track}) {
<TrackHoverInfoPopupTable track={track} /> <TrackHoverInfoPopupTable track={track} />
{showAlignHint && ( {showAlignHint && (
<div className="track-hover-info-popup__align"> <div className="track-hover-info-popup__align">
Double click to align the view Right click to align the view
</div> </div>
)} )}
</div> </div>

View File

@ -15,14 +15,14 @@ export default function SideMenu() {
return ( return (
<div className='side-menu-wrapper'> <div className='side-menu-wrapper'>
<div className={sideMenuClass}> <aside className={sideMenuClass} inert={!sideMenuOpen}>
<SceneryInfoButton /> <SceneryInfoButton />
<DistanceMeterButton /> <DistanceMeterButton />
<ExportViewButton /> <ExportViewButton />
<LayersMenu /> <LayersMenu />
<LayerOptionsMenu /> <LayerOptionsMenu />
<InfoFooter /> <InfoFooter />
</div> </aside>
</div> </div>
); );
} }

View File

@ -42,13 +42,20 @@ function normalizeDegVector(vector) {
) )
} }
function normalizeAngleDelta(delta) {
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
return delta;
}
const AngleHelper = { const AngleHelper = {
degToRad, degToRad,
radToDeg, radToDeg,
vectorDegToRad, vectorDegToRad,
vectorRadToDeg, vectorRadToDeg,
normalizeDegAngle, normalizeDegAngle,
normalizeDegVector normalizeDegVector,
normalizeAngleDelta
} }
export default AngleHelper; export default AngleHelper;

View File

@ -14,7 +14,8 @@ const Constants = {
platformMaxTilt: 0.5, platformMaxTilt: 0.5,
showDebugTrackIds: false, showDebugTrackIds: false,
mapZoomTrackDetailsThreshold: 1 / 1.44, mapZoomTrackDetailsThreshold: 1 / 1.44,
mapZoomObjectDetailsThreshold: 0.5 mapZoomObjectDetailsThreshold: 0.5,
distanceMeterMaxMovePx: 10
}, },
parser: { parser: {
logSceneryAfterFinished: readEnvBool('VITE_DO_LOG_SCENERY') ?? false, logSceneryAfterFinished: readEnvBool('VITE_DO_LOG_SCENERY') ?? false,

View File

@ -2,6 +2,11 @@ import { BehaviorSubject } from 'rxjs';
const hoveredTracksStack = []; const hoveredTracksStack = [];
export const hoveredTrack$ = new BehaviorSubject(null); export const hoveredTrack$ = new BehaviorSubject(null);
export const touchClickedTrack$ = new BehaviorSubject(null);
export function setTouchClickedTrack(track) {
touchClickedTrack$.next(track);
}
export function setHoveredTrack(track) { export function setHoveredTrack(track) {
hoveredTracksStack.push(track); hoveredTracksStack.push(track);
@ -16,4 +21,5 @@ export function unsetHoveredTrack(track) {
export function resetHoveredTracksStack() { export function resetHoveredTracksStack() {
hoveredTracksStack.length = 0; hoveredTracksStack.length = 0;
hoveredTrack$.next(null); hoveredTrack$.next(null);
touchClickedTrack$.next(null);
} }