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;