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:
parent
c01077ce38
commit
2b94c0efaa
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 />
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
4
src/contexts/ZoomPanContext.js
Normal file
4
src/contexts/ZoomPanContext.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
const ZoomPanContext = createContext();
|
||||||
|
export default ZoomPanContext;
|
||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user