From 2b94c0efaae53c5535c3d404a8af00a5daaceb63 Mon Sep 17 00:00:00 2001 From: dominik-korsa <29484605+dominik-korsa@users.noreply.github.com> Date: Fri, 18 Jul 2025 00:23:44 +0200 Subject: [PATCH 1/3] Align the view to a straight track on double click This feature will be useful when exporting the visualization to an SVG file --- src/components/map/ZoomPanWrapper.js | 66 ++++++++++++------- .../map/object-renderers/TrackRenderer.js | 22 +++++-- .../map/track-hover-info/TrackHoverInfo.css | 10 +++ .../map/track-hover-info/TrackHoverInfo.js | 2 +- .../track-hover-info/TrackHoverInfoPopup.js | 7 +- src/contexts/ZoomPanContext.js | 4 ++ src/helpers/angleHelper.js | 10 +-- 7 files changed, 84 insertions(+), 37 deletions(-) create mode 100644 src/contexts/ZoomPanContext.js diff --git a/src/components/map/ZoomPanWrapper.js b/src/components/map/ZoomPanWrapper.js index 4997ab8..b28b95b 100644 --- a/src/components/map/ZoomPanWrapper.js +++ b/src/components/map/ZoomPanWrapper.js @@ -2,7 +2,9 @@ import React, { useRef, useEffect, useCallback } from 'react'; import './ZoomPanWrapper.css'; import Constants from '../../helpers/constants'; 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}) { const wrapperRef = useRef(null); @@ -18,13 +20,13 @@ export default function ZoomPanWrapper({children}) { const { x, y, w, h } = viewBoxRef.current; return `${x} ${y} ${w} ${h}`; }; - + const updateViewBox = useCallback((viewBox) => { viewBoxRef.current = viewBox; svgRef.current.setAttribute('viewBox', getViewBoxString()); viewBox$.next(viewBoxRef.current); }, []); - + const getCameraTransformString = () => { const { x, y, zoom, rotation } = cameraRef.current; @@ -42,7 +44,7 @@ export default function ZoomPanWrapper({children}) { mapRotation$.next(cameraRef.current.rotation); }); }, []); - + const handleResize = useCallback(() => { const rect = wrapperRef.current?.getBoundingClientRect(); if (!rect) return; @@ -66,13 +68,13 @@ export default function ZoomPanWrapper({children}) { const cy = e.movementY / cameraRef.current.zoom; const cos = Math.cos(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.y -= cy * cos - cx * sin; scheduleCameraUpdate(); }; - + // update viewbox when the window resizes useEffect(() => { window.addEventListener('resize', handleResize); @@ -81,14 +83,14 @@ export default function ZoomPanWrapper({children}) { window.removeEventListener('resize', handleResize); }; }, [handleResize]); - + const centerOn = useCallback((cx, cy) => { cameraRef.current.x = cx; cameraRef.current.y = cy; scheduleCameraUpdate(); }, [scheduleCameraUpdate]); - + // Subscribe to any external "center" calls useZoomPanSubscriber(centerOn); @@ -125,7 +127,7 @@ export default function ZoomPanWrapper({children}) { scheduleCameraUpdate(); }; - + const onMouseDown = (e) => { e.preventDefault(); isMouseDownRef.current = true; @@ -146,22 +148,36 @@ export default function ZoomPanWrapper({children}) { e.preventDefault(); 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 ( -
- - - {children} - - -
+ +
+ + + {children} + + +
+
); } diff --git a/src/components/map/object-renderers/TrackRenderer.js b/src/components/map/object-renderers/TrackRenderer.js index b9fa747..6f9c9c8 100644 --- a/src/components/map/object-renderers/TrackRenderer.js +++ b/src/components/map/object-renderers/TrackRenderer.js @@ -4,23 +4,29 @@ import Constants from "../../../helpers/constants"; import { ElectrificationStatus } from "../../../model/electrification-status"; import MiscHelper from "../../../helpers/miscHelper"; import { setHoveredTrack, unsetHoveredTrack } from "../../../services/trackHoverInfoService"; +import ZoomPanContext from "../../../contexts/ZoomPanContext"; export default function TrackRenderer(props) { const { object } = props; const { trackColorMode } = useContext(SettingsContext); + const {alignView} = useContext(ZoomPanContext); + + const onAlign = (event) => { + alignView(object.rot.y, event); + }; return ( + onAlign={onAlign}/> ); } const MemoizedTrackRenderer = React.memo(StatelessTrackRenderer); function StatelessTrackRenderer(props) { - const { object, trackColorMode } = props; + const { object, trackColorMode , onAlign} = props; if (object.points.start.distanceSq(object.points.end) < 0.001) { return null; @@ -34,7 +40,15 @@ function StatelessTrackRenderer(props) { 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 defs = getTrackDefs(object, trackColorMode); @@ -52,7 +66,7 @@ function StatelessTrackRenderer(props) { className="track" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} - /> + onClick={onClick}/> ); } diff --git a/src/components/map/track-hover-info/TrackHoverInfo.css b/src/components/map/track-hover-info/TrackHoverInfo.css index 451660b..c02ce3f 100644 --- a/src/components/map/track-hover-info/TrackHoverInfo.css +++ b/src/components/map/track-hover-info/TrackHoverInfo.css @@ -12,8 +12,18 @@ padding: 1em; } +.track-hover-info-popup table { + border-spacing: 2px; +} + .track-hover-info-popup th { text-align: start; padding-right: 2em; color: #888; } + +.track-hover-info-popup__align { + margin-top: 1.5em; + padding: 2px; + color: #888; +} diff --git a/src/components/map/track-hover-info/TrackHoverInfo.js b/src/components/map/track-hover-info/TrackHoverInfo.js index 7ea2233..eb215f6 100644 --- a/src/components/map/track-hover-info/TrackHoverInfo.js +++ b/src/components/map/track-hover-info/TrackHoverInfo.js @@ -8,7 +8,7 @@ export default function TrackHoverInfo() { const { showTrackHoverInfo } = useContext(SettingsContext); if (!showTrackHoverInfo) return null; - + return ( ); diff --git a/src/components/map/track-hover-info/TrackHoverInfoPopup.js b/src/components/map/track-hover-info/TrackHoverInfoPopup.js index 8d17120..b73385b 100644 --- a/src/components/map/track-hover-info/TrackHoverInfoPopup.js +++ b/src/components/map/track-hover-info/TrackHoverInfoPopup.js @@ -4,7 +4,7 @@ export default function TrackHoverInfoPopup(props) { const { track } = props; const slopeArr = Array.from(new Set([ - Math.abs(track['start_slope']), + Math.abs(track['start_slope']), Math.abs(track['end_slope']) ])); const slopeOptions = {join: ' / ', subOptions: {suffix: ' ‰'}}; @@ -23,6 +23,9 @@ export default function TrackHoverInfoPopup(props) { + {track.type === 'StandardTrack' && track.r === 0 &&
+ Double click to align view +
} ); } @@ -64,4 +67,4 @@ function InfoPopupItem({ label, value, options = {} }) { {textValue} ); -} \ No newline at end of file +} diff --git a/src/contexts/ZoomPanContext.js b/src/contexts/ZoomPanContext.js new file mode 100644 index 0000000..98c8cfe --- /dev/null +++ b/src/contexts/ZoomPanContext.js @@ -0,0 +1,4 @@ +import { createContext } from 'react'; + +const ZoomPanContext = createContext(); +export default ZoomPanContext; diff --git a/src/helpers/angleHelper.js b/src/helpers/angleHelper.js index 2dbda25..8b53af8 100644 --- a/src/helpers/angleHelper.js +++ b/src/helpers/angleHelper.js @@ -8,11 +8,11 @@ function radToDeg(angle) { return angle * (180 / Math.PI); } -function normalizeDegAngle(angle) { +function normalizeDegAngle(angle, max = 360) { if (angle < 0) { - return angle + 360 * Math.ceil(Math.abs(angle) / 360); - } else if (angle >= 360) { - return angle - 360 * Math.floor(angle / 360); + return angle + max * Math.ceil(Math.abs(angle) / max); + } else if (angle >= max) { + return angle - max * Math.floor(angle / max); } else { return angle; } @@ -33,4 +33,4 @@ const AngleHelper = { normalizeDegVector } -export default AngleHelper; \ No newline at end of file +export default AngleHelper; From 2e1947a7d4136babc750c9a4717b5e94a743921e Mon Sep 17 00:00:00 2001 From: dominik-korsa <29484605+dominik-korsa@users.noreply.github.com> Date: Wed, 23 Jul 2025 00:24:29 +0200 Subject: [PATCH 2/3] Reindent TrackRenderer.js to use 4 spaces --- .../map/object-renderers/TrackRenderer.js | 218 +++++++++--------- 1 file changed, 109 insertions(+), 109 deletions(-) diff --git a/src/components/map/object-renderers/TrackRenderer.js b/src/components/map/object-renderers/TrackRenderer.js index c5b99a7..9c67e4a 100644 --- a/src/components/map/object-renderers/TrackRenderer.js +++ b/src/components/map/object-renderers/TrackRenderer.js @@ -1,78 +1,78 @@ -import React, { useContext } from "react"; +import React, {useContext} from "react"; import SettingsContext from "../../../contexts/SettingsContext"; import Constants from "../../../helpers/constants"; -import { ElectrificationStatus } from "../../../model/electrification-status"; -import { setHoveredTrack, unsetHoveredTrack } from "../../../services/trackHoverInfoService"; +import {ElectrificationStatus} from "../../../model/electrification-status"; +import {setHoveredTrack, unsetHoveredTrack} from "../../../services/trackHoverInfoService"; import ZoomPanContext from "../../../contexts/ZoomPanContext"; import GradientsContext from "../../../contexts/GradientsContext"; import MiscHelper from "../../../helpers/miscHelper"; export default function TrackRenderer(props) { - const { object } = props; - const { trackColorMode } = useContext(SettingsContext); - const { gradientDefs } = useContext(GradientsContext); - const {alignView} = useContext(ZoomPanContext); + const {object} = props; + const {trackColorMode} = useContext(SettingsContext); + const {gradientDefs} = useContext(GradientsContext); + const {alignView} = useContext(ZoomPanContext); - const onAlign = (event) => { - alignView(object.rot.y, event); - }; + const onAlign = (event) => { + alignView(object.rot.y, event); + }; - return ( - - ); + return ( + + ); } const MemoizedTrackRenderer = React.memo(StatelessTrackRenderer); function StatelessTrackRenderer(props) { - const { object, trackColorMode, gradientDef, onAlign } = props; + const {object, trackColorMode, gradientDef, onAlign} = props; - if (object.points.start.distanceSq(object.points.end) < 0.001) { - return null; - } - - const onMouseEnter = () => { - setHoveredTrack(object); - }; - - const onMouseLeave = () => { - unsetHoveredTrack(null); - }; - - const onClick = (event) => { - if (object.type !== 'StandardTrack' || object.r !== 0) return; - if (event.detail === 2) { - event.preventDefault(); - onAlign(event); + if (object.points.start.distanceSq(object.points.end) < 0.001) { + return null; } - }; - const path = getTrackPath(object); - const color = getTrackColor(object, trackColorMode, gradientDef); - const defs = getTrackDefs(object, trackColorMode, gradientDef); + const onMouseEnter = () => { + setHoveredTrack(object); + }; - return ( - - {defs} - - - - ); + const onMouseLeave = () => { + unsetHoveredTrack(null); + }; + + 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, gradientDef); + const defs = getTrackDefs(object, trackColorMode, gradientDef); + + return ( + + {defs} + + + + ); } function getTrackPath(object) { @@ -113,57 +113,57 @@ function getGradientValues(object, trackColorMode) { } function getTrackColor(object, trackColorMode, gradientDef) { - const modeDef = Constants.trackColorModes[trackColorMode]; + const modeDef = Constants.trackColorModes[trackColorMode]; - switch (trackColorMode) { - case "standard": - default: - if(object.prefab_name && object.prefab_name.includes('trans-mat')) - return modeDef.options['invisible'][0]; - - return modeDef.options[modeDef.optionDefault][0]; - - case "electrification": - switch(object.electrificationStatus) { - case ElectrificationStatus.NOT_CHECKED: - return modeDef.options['not-checked'][0]; - case ElectrificationStatus.NON_ELECTRIFIED: - return modeDef.options['non-electrified'][0]; - case ElectrificationStatus.ELECTRIFIED: - return modeDef.options['electrified'][0]; - case ElectrificationStatus.CONFLICT: - return modeDef.options['conflict'][0]; + switch (trackColorMode) { + case "standard": default: - return modeDef.options[modeDef.optionDefault][0]; - } + if (object.prefab_name && object.prefab_name.includes('trans-mat')) + return modeDef.options['invisible'][0]; - case "type": - switch (object.type) { - case "StandardTrack": - return modeDef.options['standard-track'][0]; - case "PointTrack": - return modeDef.options['point-track'][0]; - case "RouteTrack": - return modeDef.options['route-track'][0]; - case "BezierTrack": - return modeDef.options['bezier-track'][0]; - default: - return modeDef.options[modeDef.optionDefault][0]; - } + return modeDef.options[modeDef.optionDefault][0]; - case "max-speed": - if (!object.maxspeed) { - if (object.type === 'RouteTrack') return modeDef.options['unknown'][0]; - return modeDef.options['derail'][0]; - } - return MiscHelper.getTrackGradientColor(gradientDef, object.maxspeed); + case "electrification": + switch (object.electrificationStatus) { + case ElectrificationStatus.NOT_CHECKED: + return modeDef.options['not-checked'][0]; + case ElectrificationStatus.NON_ELECTRIFIED: + return modeDef.options['non-electrified'][0]; + case ElectrificationStatus.ELECTRIFIED: + return modeDef.options['electrified'][0]; + case ElectrificationStatus.CONFLICT: + return modeDef.options['conflict'][0]; + default: + return modeDef.options[modeDef.optionDefault][0]; + } - case "elevation": - case "slope": - const [startValue, endValue] = getGradientValues(object, trackColorMode); - if (startValue === endValue) return MiscHelper.getTrackGradientColor(gradientDef, startValue); - return `url(#track-${trackColorMode}-${object.id})`; - } + case "type": + switch (object.type) { + case "StandardTrack": + return modeDef.options['standard-track'][0]; + case "PointTrack": + return modeDef.options['point-track'][0]; + case "RouteTrack": + return modeDef.options['route-track'][0]; + case "BezierTrack": + return modeDef.options['bezier-track'][0]; + default: + return modeDef.options[modeDef.optionDefault][0]; + } + + case "max-speed": + if (!object.maxspeed) { + if (object.type === 'RouteTrack') return modeDef.options['unknown'][0]; + return modeDef.options['derail'][0]; + } + return MiscHelper.getTrackGradientColor(gradientDef, object.maxspeed); + + case "elevation": + case "slope": + const [startValue, endValue] = getGradientValues(object, trackColorMode); + if (startValue === endValue) return MiscHelper.getTrackGradientColor(gradientDef, startValue); + return `url(#track-${trackColorMode}-${object.id})`; + } } function getGradientDefs(object, trackColorMode, gradientDef, startValue, endValue) { @@ -184,16 +184,16 @@ function getGradientDefs(object, trackColorMode, gradientDef, startValue, endVal x2={x2} y2={y2} > - - + + ); } function getTrackDefs(object, trackColorMode, gradientDef) { - const gradientVals = getGradientValues(object, trackColorMode); - if (gradientVals === null) return null; - const [startValue, endValue] = gradientVals; - return getGradientDefs(object, trackColorMode, gradientDef, startValue, endValue); + const gradientVals = getGradientValues(object, trackColorMode); + if (gradientVals === null) return null; + const [startValue, endValue] = gradientVals; + return getGradientDefs(object, trackColorMode, gradientDef, startValue, endValue); } From de6d3aa5dc0ca81484cd21e54f9da516298deb1a Mon Sep 17 00:00:00 2001 From: dominik-korsa <29484605+dominik-korsa@users.noreply.github.com> Date: Wed, 23 Jul 2025 00:44:13 +0200 Subject: [PATCH 3/3] Use ZoomPanEmitter/Subscriber instead of a context for aligning the view --- src/components/map/ZoomPanWrapper.js | 60 +++++++++---------- .../map/object-renderers/TrackRenderer.js | 8 +-- src/contexts/ZoomPanContext.js | 4 -- src/hooks/useZoomPubSub.js | 18 +++++- 4 files changed, 48 insertions(+), 42 deletions(-) delete mode 100644 src/contexts/ZoomPanContext.js diff --git a/src/components/map/ZoomPanWrapper.js b/src/components/map/ZoomPanWrapper.js index b28b95b..2a5295e 100644 --- a/src/components/map/ZoomPanWrapper.js +++ b/src/components/map/ZoomPanWrapper.js @@ -3,7 +3,6 @@ import './ZoomPanWrapper.css'; import Constants from '../../helpers/constants'; import { useZoomPanSubscriber, viewBox$, clientRect$, camera$ } from '../../hooks/useZoomPubSub'; import {mapRotation$} from '../../services/mapRotationService'; -import ZoomPanContext from "../../contexts/ZoomPanContext"; import AngleHelper from "../../helpers/angleHelper"; export default function ZoomPanWrapper({children}) { @@ -91,8 +90,19 @@ export default function ZoomPanWrapper({children}) { scheduleCameraUpdate(); }, [scheduleCameraUpdate]); - // Subscribe to any external "center" calls - useZoomPanSubscriber(centerOn); + const alignView = (angleDeg) => { + // 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(); + }; + + // Subscribe to any external `center` and `alignView` calls + useZoomPanSubscriber(centerOn, alignView); const onWheel = (e) => { const { left, top } = clientRectRef.current; @@ -149,35 +159,21 @@ export default function ZoomPanWrapper({children}) { 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 ( - -
- - - {children} - - -
-
+
+ + + {children} + + +
); } diff --git a/src/components/map/object-renderers/TrackRenderer.js b/src/components/map/object-renderers/TrackRenderer.js index 9c67e4a..6a7496b 100644 --- a/src/components/map/object-renderers/TrackRenderer.js +++ b/src/components/map/object-renderers/TrackRenderer.js @@ -3,18 +3,18 @@ import SettingsContext from "../../../contexts/SettingsContext"; import Constants from "../../../helpers/constants"; import {ElectrificationStatus} from "../../../model/electrification-status"; import {setHoveredTrack, unsetHoveredTrack} from "../../../services/trackHoverInfoService"; -import ZoomPanContext from "../../../contexts/ZoomPanContext"; import GradientsContext from "../../../contexts/GradientsContext"; import MiscHelper from "../../../helpers/miscHelper"; +import {useZoomPanEmitter} from "../../../hooks/useZoomPubSub"; export default function TrackRenderer(props) { const {object} = props; const {trackColorMode} = useContext(SettingsContext); const {gradientDefs} = useContext(GradientsContext); - const {alignView} = useContext(ZoomPanContext); + const { alignView } = useZoomPanEmitter(); - const onAlign = (event) => { - alignView(object.rot.y, event); + const onAlign = () => { + alignView(object.rot.y); }; return ( diff --git a/src/contexts/ZoomPanContext.js b/src/contexts/ZoomPanContext.js deleted file mode 100644 index 98c8cfe..0000000 --- a/src/contexts/ZoomPanContext.js +++ /dev/null @@ -1,4 +0,0 @@ -import { createContext } from 'react'; - -const ZoomPanContext = createContext(); -export default ZoomPanContext; diff --git a/src/hooks/useZoomPubSub.js b/src/hooks/useZoomPubSub.js index fa500be..acaf8c3 100644 --- a/src/hooks/useZoomPubSub.js +++ b/src/hooks/useZoomPubSub.js @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { Subject, BehaviorSubject } from 'rxjs'; const zoomCenter$ = new Subject(); +const viewAlign$ = new Subject(); // BehaviorSubjects to store current viewBox, clientRect and camera transform export const viewBox$ = new BehaviorSubject(null); @@ -32,17 +33,27 @@ export function getCurrentCamera() { /** * useZoomPanSubscriber * -* Registers a callback (onCenter) that will be invoked whenever +* Registers an `onCenter` callback that will be invoked whenever * someone calls `center(x, y)` via the emitter. You should pass * a function that takes (x, y) and recenters your viewBox accordingly. + * + * Similarly, registers an `onAlign` callback that will be invoked + * when someone calls `alignView(angleDeg)`. */ -export function useZoomPanSubscriber(onCenter) { +export function useZoomPanSubscriber(onCenter, onAlign) { useEffect(() => { const sub = zoomCenter$.subscribe(({ x, y }) => { onCenter(x, y); }); return () => sub.unsubscribe(); }, [onCenter]); + + useEffect(() => { + const sub = viewAlign$.subscribe((angleDeg) => { + onAlign(angleDeg); + }); + return () => sub.unsubscribe(); + }, [onAlign]); } /** @@ -57,5 +68,8 @@ export function useZoomPanEmitter() { center: (x, y) => { zoomCenter$.next({ x, y }); }, + alignView: (angleDeg) => { + viewAlign$.next(angleDeg); + }, }; }