Align the view to a straight track on double click

This feature will be useful when exporting the visualization to an SVG file
This commit is contained in:
dominik-korsa 2025-07-18 00:23:44 +02:00
parent c01077ce38
commit 2b94c0efaa
No known key found for this signature in database
GPG Key ID: 5A24D76EE0A30974
7 changed files with 84 additions and 37 deletions

View File

@ -2,7 +2,9 @@ import React, { useRef, useEffect, useCallback } from 'react';
import './ZoomPanWrapper.css'; import './ZoomPanWrapper.css';
import Constants from '../../helpers/constants'; import Constants from '../../helpers/constants';
import { useZoomPanSubscriber, viewBox$, clientRect$, camera$ } from '../../hooks/useZoomPubSub'; import { useZoomPanSubscriber, viewBox$, clientRect$, camera$ } from '../../hooks/useZoomPubSub';
import { mapRotation$ } from '../../services/mapRotationService'; import {mapRotation$} from '../../services/mapRotationService';
import ZoomPanContext from "../../contexts/ZoomPanContext";
import AngleHelper from "../../helpers/angleHelper";
export default function ZoomPanWrapper({children}) { export default function ZoomPanWrapper({children}) {
const wrapperRef = useRef(null); const wrapperRef = useRef(null);
@ -18,13 +20,13 @@ export default function ZoomPanWrapper({children}) {
const { x, y, w, h } = viewBoxRef.current; const { x, y, w, h } = viewBoxRef.current;
return `${x} ${y} ${w} ${h}`; return `${x} ${y} ${w} ${h}`;
}; };
const updateViewBox = useCallback((viewBox) => { const updateViewBox = useCallback((viewBox) => {
viewBoxRef.current = viewBox; viewBoxRef.current = viewBox;
svgRef.current.setAttribute('viewBox', getViewBoxString()); svgRef.current.setAttribute('viewBox', getViewBoxString());
viewBox$.next(viewBoxRef.current); viewBox$.next(viewBoxRef.current);
}, []); }, []);
const getCameraTransformString = () => { const getCameraTransformString = () => {
const { x, y, zoom, rotation } = cameraRef.current; const { x, y, zoom, rotation } = cameraRef.current;
@ -42,7 +44,7 @@ export default function ZoomPanWrapper({children}) {
mapRotation$.next(cameraRef.current.rotation); mapRotation$.next(cameraRef.current.rotation);
}); });
}, []); }, []);
const handleResize = useCallback(() => { const handleResize = useCallback(() => {
const rect = wrapperRef.current?.getBoundingClientRect(); const rect = wrapperRef.current?.getBoundingClientRect();
if (!rect) return; if (!rect) return;
@ -66,13 +68,13 @@ export default function ZoomPanWrapper({children}) {
const cy = e.movementY / cameraRef.current.zoom; const cy = e.movementY / cameraRef.current.zoom;
const cos = Math.cos(cameraRef.current.rotation * Math.PI / 180); const cos = Math.cos(cameraRef.current.rotation * Math.PI / 180);
const sin = Math.sin(cameraRef.current.rotation * Math.PI / 180); const sin = Math.sin(cameraRef.current.rotation * Math.PI / 180);
cameraRef.current.x -= cx * cos + cy * sin; cameraRef.current.x -= cx * cos + cy * sin;
cameraRef.current.y -= cy * cos - cx * sin; cameraRef.current.y -= cy * cos - cx * sin;
scheduleCameraUpdate(); scheduleCameraUpdate();
}; };
// update viewbox when the window resizes // update viewbox when the window resizes
useEffect(() => { useEffect(() => {
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
@ -81,14 +83,14 @@ export default function ZoomPanWrapper({children}) {
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', handleResize);
}; };
}, [handleResize]); }, [handleResize]);
const centerOn = useCallback((cx, cy) => { const centerOn = useCallback((cx, cy) => {
cameraRef.current.x = cx; cameraRef.current.x = cx;
cameraRef.current.y = cy; cameraRef.current.y = cy;
scheduleCameraUpdate(); scheduleCameraUpdate();
}, [scheduleCameraUpdate]); }, [scheduleCameraUpdate]);
// Subscribe to any external "center" calls // Subscribe to any external "center" calls
useZoomPanSubscriber(centerOn); useZoomPanSubscriber(centerOn);
@ -125,7 +127,7 @@ export default function ZoomPanWrapper({children}) {
scheduleCameraUpdate(); scheduleCameraUpdate();
}; };
const onMouseDown = (e) => { const onMouseDown = (e) => {
e.preventDefault(); e.preventDefault();
isMouseDownRef.current = true; isMouseDownRef.current = true;
@ -146,22 +148,36 @@ export default function ZoomPanWrapper({children}) {
e.preventDefault(); e.preventDefault();
isMouseDownRef.current = false; isMouseDownRef.current = false;
}; };
// `event` could be used in the future to perform the rotation around the click position
const alignView = (angleDeg, _event) => {
// Find such relative rotation in the range [-45, 45] degrees that aligns the track
// with y rotation angleDeg to the X or Y screen axis.
const angleDifference = -angleDeg - cameraRef.current.rotation;
let deltaAngle = AngleHelper.normalizeDegAngle(angleDifference, 90);
if (deltaAngle > 45) deltaAngle -= 90;
cameraRef.current.rotation += deltaAngle;
scheduleCameraUpdate();
};
return ( return (
<div className="zoom-pan-wrapper" ref={wrapperRef}> <ZoomPanContext.Provider value={{alignView}}>
<svg <div className="zoom-pan-wrapper" ref={wrapperRef}>
ref={svgRef} <svg
viewBox={getViewBoxString()} ref={svgRef}
onWheel={onWheel} viewBox={getViewBoxString()}
onMouseDown={onMouseDown} onWheel={onWheel}
onMouseMove={onMouseMove} onMouseDown={onMouseDown}
onMouseUp={onMouseUp} onMouseMove={onMouseMove}
onMouseLeave={onMouseUp} onMouseUp={onMouseUp}
> onMouseLeave={onMouseUp}
<g ref={cameraWrapperRef} transform={getCameraTransformString()}> >
{children} <g ref={cameraWrapperRef} transform={getCameraTransformString()}>
</g> {children}
</svg> </g>
</div> </svg>
</div>
</ZoomPanContext.Provider>
); );
} }

View File

@ -4,23 +4,29 @@ 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, unsetHoveredTrack } from "../../../services/trackHoverInfoService";
import ZoomPanContext from "../../../contexts/ZoomPanContext";
export default function TrackRenderer(props) { export default function TrackRenderer(props) {
const { object } = props; const { object } = props;
const { trackColorMode } = useContext(SettingsContext); const { trackColorMode } = useContext(SettingsContext);
const {alignView} = useContext(ZoomPanContext);
const onAlign = (event) => {
alignView(object.rot.y, event);
};
return ( return (
<MemoizedTrackRenderer <MemoizedTrackRenderer
object={object} object={object}
trackColorMode={trackColorMode} trackColorMode={trackColorMode}
/> onAlign={onAlign}/>
); );
} }
const MemoizedTrackRenderer = React.memo(StatelessTrackRenderer); const MemoizedTrackRenderer = React.memo(StatelessTrackRenderer);
function StatelessTrackRenderer(props) { function StatelessTrackRenderer(props) {
const { object, trackColorMode } = props; const { object, trackColorMode , onAlign} = props;
if (object.points.start.distanceSq(object.points.end) < 0.001) { if (object.points.start.distanceSq(object.points.end) < 0.001) {
return null; return null;
@ -34,7 +40,15 @@ function StatelessTrackRenderer(props) {
unsetHoveredTrack(null); unsetHoveredTrack(null);
}; };
const path = getTrackPath(object); const onClick = (event) => {
if (object.type !== 'StandardTrack' || object.r !== 0) return;
if (event.detail === 2) {
event.preventDefault();
onAlign(event);
}
};
const path = getTrackPath(object);
const color = getTrackColor(object, trackColorMode); const color = getTrackColor(object, trackColorMode);
const defs = getTrackDefs(object, trackColorMode); const defs = getTrackDefs(object, trackColorMode);
@ -52,7 +66,7 @@ function StatelessTrackRenderer(props) {
className="track" className="track"
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
/> onClick={onClick}/>
</g> </g>
); );
} }

View File

@ -12,8 +12,18 @@
padding: 1em; padding: 1em;
} }
.track-hover-info-popup table {
border-spacing: 2px;
}
.track-hover-info-popup th { .track-hover-info-popup th {
text-align: start; text-align: start;
padding-right: 2em; padding-right: 2em;
color: #888; color: #888;
} }
.track-hover-info-popup__align {
margin-top: 1.5em;
padding: 2px;
color: #888;
}

View File

@ -8,7 +8,7 @@ export default function TrackHoverInfo() {
const { showTrackHoverInfo } = useContext(SettingsContext); const { showTrackHoverInfo } = useContext(SettingsContext);
if (!showTrackHoverInfo) return null; if (!showTrackHoverInfo) return null;
return ( return (
<InnerTrackHoverInfo /> <InnerTrackHoverInfo />
); );

View File

@ -4,7 +4,7 @@ export default function TrackHoverInfoPopup(props) {
const { track } = props; const { track } = props;
const slopeArr = Array.from(new Set([ const slopeArr = Array.from(new Set([
Math.abs(track['start_slope']), Math.abs(track['start_slope']),
Math.abs(track['end_slope']) Math.abs(track['end_slope'])
])); ]));
const slopeOptions = {join: ' / ', subOptions: {suffix: ' ‰'}}; const slopeOptions = {join: ' / ', subOptions: {suffix: ' ‰'}};
@ -23,6 +23,9 @@ export default function TrackHoverInfoPopup(props) {
<InfoPopupSwitchItems track={track} /> <InfoPopupSwitchItems track={track} />
</tbody> </tbody>
</table> </table>
{track.type === 'StandardTrack' && track.r === 0 && <div className="track-hover-info-popup__align">
Double click to align view
</div>}
</div> </div>
); );
} }
@ -64,4 +67,4 @@ function InfoPopupItem({ label, value, options = {} }) {
<td>{textValue}</td> <td>{textValue}</td>
</tr> </tr>
); );
} }

View File

@ -0,0 +1,4 @@
import { createContext } from 'react';
const ZoomPanContext = createContext();
export default ZoomPanContext;

View File

@ -8,11 +8,11 @@ function radToDeg(angle) {
return angle * (180 / Math.PI); return angle * (180 / Math.PI);
} }
function normalizeDegAngle(angle) { function normalizeDegAngle(angle, max = 360) {
if (angle < 0) { if (angle < 0) {
return angle + 360 * Math.ceil(Math.abs(angle) / 360); return angle + max * Math.ceil(Math.abs(angle) / max);
} else if (angle >= 360) { } else if (angle >= max) {
return angle - 360 * Math.floor(angle / 360); return angle - max * Math.floor(angle / max);
} else { } else {
return angle; return angle;
} }
@ -33,4 +33,4 @@ const AngleHelper = {
normalizeDegVector normalizeDegVector
} }
export default AngleHelper; export default AngleHelper;