Merge branch 'main' into prefab-parser

# Conflicts:
#	src/components/map/object-renderers/TrackRenderer.js
#	src/components/map/track-hover-info/TrackHoverInfoPopup.js
#	src/helpers/angleHelper.js
This commit is contained in:
dominik-korsa 2025-07-23 01:44:25 +02:00
commit 9e5e6ab7cb
No known key found for this signature in database
GPG Key ID: 5A24D76EE0A30974
7 changed files with 168 additions and 114 deletions

View File

@ -3,6 +3,7 @@ import './ZoomPanWrapper.css';
import Constants from '../../helpers/constants';
import { useZoomPanSubscriber, viewBox$, clientRect$, camera$ } from '../../hooks/useZoomPubSub';
import {mapRotation$} from '../../services/mapRotationService';
import AngleHelper from "../../helpers/angleHelper";
export default function ZoomPanWrapper({children}) {
const wrapperRef = useRef(null);
@ -89,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;

View File

@ -6,17 +6,24 @@ import MiscHelper from "../../../helpers/miscHelper";
import {setHoveredTrack, unsetHoveredTrack} from "../../../services/trackHoverInfoService";
import GradientsContext from "../../../contexts/GradientsContext";
import { TrackSource } from "../../../model/tracks/track";
import {useZoomPanEmitter} from "../../../hooks/useZoomPubSub";
export default function TrackRenderer(props) {
const { object } = props;
const { trackColorMode } = useContext(SettingsContext);
const { gradientDefs } = useContext(GradientsContext);
const { alignView } = useZoomPanEmitter();
const onAlign = () => {
alignView(object.rot.y);
};
return (
<MemoizedTrackRenderer
object={object}
trackColorMode={trackColorMode}
gradientDef={gradientDefs[trackColorMode] ?? null}
onAlign={onAlign}
/>
);
}
@ -24,7 +31,7 @@ export default function TrackRenderer(props) {
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;
@ -38,6 +45,14 @@ function StatelessTrackRenderer(props) {
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);
@ -56,7 +71,7 @@ function StatelessTrackRenderer(props) {
className="track"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
onClick={onClick}/>
</g>
);
}

View File

@ -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;
}

View File

@ -24,6 +24,9 @@ export default function TrackHoverInfoPopup(props) {
<InfoPopupShapeItems track={track} />
</tbody>
</table>
{track.type === 'StandardTrack' && track.r === 0 && <div className="track-hover-info-popup__align">
Double click to align view
</div>}
</div>
);
}

View File

@ -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;
}

View File

@ -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);
},
};
}