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 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 (
|
||||
<div className="zoom-pan-wrapper" ref={wrapperRef}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={getViewBoxString()}
|
||||
onWheel={onWheel}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
>
|
||||
<g ref={cameraWrapperRef} transform={getCameraTransformString()}>
|
||||
{children}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<ZoomPanContext.Provider value={{alignView}}>
|
||||
<div className="zoom-pan-wrapper" ref={wrapperRef}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={getViewBoxString()}
|
||||
onWheel={onWheel}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
>
|
||||
<g ref={cameraWrapperRef} transform={getCameraTransformString()}>
|
||||
{children}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</ZoomPanContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<MemoizedTrackRenderer
|
||||
object={object}
|
||||
trackColorMode={trackColorMode}
|
||||
/>
|
||||
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}/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ export default function TrackHoverInfo() {
|
||||
const { showTrackHoverInfo } = useContext(SettingsContext);
|
||||
|
||||
if (!showTrackHoverInfo) return null;
|
||||
|
||||
|
||||
return (
|
||||
<InnerTrackHoverInfo />
|
||||
);
|
||||
|
||||
@ -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) {
|
||||
<InfoPopupSwitchItems track={track} />
|
||||
</tbody>
|
||||
</table>
|
||||
{track.type === 'StandardTrack' && track.r === 0 && <div className="track-hover-info-popup__align">
|
||||
Double click to align view
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -64,4 +67,4 @@ function InfoPopupItem({ label, value, options = {} }) {
|
||||
<td>{textValue}</td>
|
||||
</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);
|
||||
}
|
||||
|
||||
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;
|
||||
export default AngleHelper;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user