diff --git a/src/components/map/ZoomPanWrapper.js b/src/components/map/ZoomPanWrapper.js index 4997ab8..2a5295e 100644 --- a/src/components/map/ZoomPanWrapper.js +++ b/src/components/map/ZoomPanWrapper.js @@ -2,7 +2,8 @@ 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 AngleHelper from "../../helpers/angleHelper"; export default function ZoomPanWrapper({children}) { const wrapperRef = useRef(null); @@ -18,13 +19,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 +43,7 @@ export default function ZoomPanWrapper({children}) { mapRotation$.next(cameraRef.current.rotation); }); }, []); - + const handleResize = useCallback(() => { const rect = wrapperRef.current?.getBoundingClientRect(); if (!rect) return; @@ -66,13 +67,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,16 +82,27 @@ 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); + + 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; @@ -125,7 +137,7 @@ export default function ZoomPanWrapper({children}) { scheduleCameraUpdate(); }; - + const onMouseDown = (e) => { e.preventDefault(); isMouseDownRef.current = true; @@ -146,7 +158,7 @@ export default function ZoomPanWrapper({children}) { e.preventDefault(); isMouseDownRef.current = false; }; - + return (
- ); + const onAlign = () => { + alignView(object.rot.y); + }; + + return ( + + ); } const MemoizedTrackRenderer = React.memo(StatelessTrackRenderer); function StatelessTrackRenderer(props) { - const { object, trackColorMode, gradientDef } = props; + const {object, trackColorMode, gradientDef, onAlign} = props; - if (object.points.start.distanceSq(object.points.end) < 0.001) { - return null; - } + if (object.points.start.distanceSq(object.points.end) < 0.001) { + return null; + } - const onMouseEnter = () => { - setHoveredTrack(object); - }; + const onMouseEnter = () => { + setHoveredTrack(object); + }; - const onMouseLeave = () => { - unsetHoveredTrack(null); - }; + const onMouseLeave = () => { + unsetHoveredTrack(null); + }; - const path = getTrackPath(object); - const color = getTrackColor(object, trackColorMode, gradientDef); - const defs = getTrackDefs(object, trackColorMode, gradientDef); + const onClick = (event) => { + if (object.type !== 'StandardTrack' || object.r !== 0) return; + if (event.detail === 2) { + event.preventDefault(); + onAlign(event); + } + }; - return ( - - {defs} - - - - ); + const path = getTrackPath(object); + const color = getTrackColor(object, trackColorMode, gradientDef); + const defs = getTrackDefs(object, trackColorMode, gradientDef); + + return ( + + {defs} + + + + ); } function getTrackPath(object) { @@ -99,57 +114,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.source) { - case TrackSource.STANDARD: - return modeDef.options['standard-track'][0]; - case TrackSource.SWITCH: - return modeDef.options['point-track'][0]; - case TrackSource.ROUTE: - return modeDef.options['route-track'][0]; - case TrackSource.BEZIER: - 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.source) { + case TrackSource.STANDARD: + return modeDef.options['standard-track'][0]; + case TrackSource.SWITCH: + return modeDef.options['point-track'][0]; + case TrackSource.ROUTE: + return modeDef.options['route-track'][0]; + case TrackSource.BEZIER: + 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) { @@ -170,16 +185,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); } 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 027e073..2042621 100644 --- a/src/components/map/track-hover-info/TrackHoverInfoPopup.js +++ b/src/components/map/track-hover-info/TrackHoverInfoPopup.js @@ -24,6 +24,9 @@ export default function TrackHoverInfoPopup(props) { + {track.type === 'StandardTrack' && track.r === 0 &&
+ Double click to align view +
}
); } diff --git a/src/helpers/angleHelper.js b/src/helpers/angleHelper.js index b53f634..4430758 100644 --- a/src/helpers/angleHelper.js +++ b/src/helpers/angleHelper.js @@ -24,11 +24,11 @@ function rotationRadToDeg(vector) { ); } -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; } 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); + }, }; }