Auto labels rotation

+Added AlwaysUpText component that rotates text by 180 degrees if it is upside down (taking view rotation into account)
+Added SimpleLabelText component that always displays text horizontally relative to the view rotation
+Added mapRotationService which stores a subscribable view rotation value
*Refactored all ObjectRenderers to use the above components for object labels
This commit is contained in:
izawartka 2025-07-06 23:48:31 +02:00
parent 8db877c2e3
commit 1e6cdf2bfb
14 changed files with 159 additions and 99 deletions

View File

@ -2,6 +2,7 @@ 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';
export default function ZoomPanWrapper({children}) {
const wrapperRef = useRef(null);
@ -38,6 +39,7 @@ export default function ZoomPanWrapper({children}) {
rafScheduledRef.current = false;
cameraWrapperRef.current.setAttribute('transform', getCameraTransformString());
camera$.next(cameraRef.current);
mapRotation$.next(cameraRef.current.rotation);
});
}, []);

View File

@ -1,4 +1,9 @@
import { useEffect, useState } from "react";
import { mapRotation$ } from "../../../services/mapRotationService";
import AngleHelper from "../../../helpers/angleHelper";
export default function DistanceMeterText(props) {
const [mapRotation, setMapRotation] = useState(0);
const { start, end, totalDistance } = props;
const cx = (start[0] + end[0]) / 2;
@ -7,10 +12,19 @@ export default function DistanceMeterText(props) {
const dy = end[1] - start[1];
const len = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
const upsideDown = angle >= 90 || angle <= -90;
const rotation = AngleHelper.normalizeDegAngle(mapRotation + angle);
const upsideDown = rotation >= 90 && rotation <= 270;
const x = cx + len / 2;
const y = cy + (upsideDown ? 2 : -2);
useEffect(() => {
const subscription = mapRotation$.subscribe(date => {
setMapRotation(date);
});
return () => subscription.unsubscribe();
}, []);
return (
<text
x={x}

View File

@ -1,15 +1,10 @@
import { ReactSVG } from 'react-svg'
import AngleHelper from '../../../helpers/angleHelper';
import AlwaysUpText from '../text/AlwaysUpText';
export default function DerailerRenderer(props) {
const { object } = props;
const [x, y] = object.pos.toSVGCoords();
const rot = AngleHelper.normalizeDegAngle(object.rot.y);
const upsideDown = rot >= 180;
const upsideDownRot = upsideDown ? 90 : -90;
const anchor = upsideDown ? "end" : "start";
return (
<g className="derailer" transform={`translate(${x}, ${y}) rotate(${object.rot.y})`}>
<g className="derailer-icon" transform={`translate(-2.835, -2.835)`}>
@ -23,11 +18,12 @@ export default function DerailerRenderer(props) {
}}
/>
</g>
<g transform={`translate(0, -3) rotate(${upsideDownRot})`}>
<text x="0" y="0" textAnchor={anchor} dominantBaseline="middle">
{object.getPrintableDerailerName()}
</text>
</g>
<AlwaysUpText
baseRot={object.rot.y}
additionalRot={-90}
offsetY={-3}
text={object.getPrintableDerailerName()}
/>
</g>
);
}

View File

@ -1,3 +1,5 @@
import SimpleLabelText from "../text/SimpleLabelText";
export default function IsolationIdRenderer(props) {
const { object } = props;
const pos = object.points.start.lerp(object.points.end, 0.5);
@ -8,16 +10,10 @@ export default function IsolationIdRenderer(props) {
return null;
}
return (
<text
return <SimpleLabelText
text={text}
className="isolation-id"
x={x}
y={y}
textAnchor='middle'
id={`isolation-id-${object.id}`}
style={{ userSelect: "none" }}
className="isolation-id"
>
{text}
</text>
);
/>
}

View File

@ -1,15 +1,11 @@
import { ReactSVG } from "react-svg";
import AngleHelper from "../../../helpers/angleHelper";
import AlwaysUpText from "../text/AlwaysUpText";
export default function RouteRenderer(props) {
const { object } = props;
const [x, y] = object.pos.toSVGCoords();
const rot = AngleHelper.normalizeDegAngle(object.rot.y);
const upsideDown = rot >= 180;
const upsideDownRot = upsideDown ? 90 : -90;
const offY = object.track_count === 2 ? -22 : -20;
const anchor = upsideDown ? "start" : "end";
const arrowOffset = - object.track_offset - 37.8;
return (
@ -24,11 +20,15 @@ export default function RouteRenderer(props) {
}}
/>
</g>
<g transform={`translate(${object.track_offset}, 0) rotate(${upsideDownRot})`}>
<text x='0' y={offY} textAnchor={anchor} dominantBaseline="bottom">
{object.route_name}
</text>
</g>
<AlwaysUpText
baseRot={object.rot.y}
additionalRot={-90}
offsetX={object.track_offset}
reverseAnchor={true}
y={offY}
dominantBaseline="bottom"
text={object.route_name}
/>
</g>
);
}

View File

@ -1,5 +1,5 @@
import { ReactSVG } from 'react-svg';
import AngleHelper from '../../../helpers/angleHelper';
import AlwaysUpText from '../text/AlwaysUpText';
export default function SignalBoxRenderer(props) {
const { object } = props;
@ -7,9 +7,7 @@ export default function SignalBoxRenderer(props) {
const text = object.getPrintableSignalBoxName();
const rotDiff = object.def.undef ? Math.round(object.rot.y / 90) * (-90) : object.def.rot * 90;
const rot = AngleHelper.normalizeDegAngle(object.rot.y + rotDiff);
const upsideDown = rot >= 90 && rot < 270;
const upsideDownRot = upsideDown ? 180 : 0;
const rot = object.rot.y + rotDiff;
return (
<g className="signalbox" transform={`translate(${x}, ${y}) rotate(${rot})`}>
@ -24,11 +22,13 @@ export default function SignalBoxRenderer(props) {
}}
/>
</g>
<g transform={`translate(0, 6) rotate(${upsideDownRot})`}>
<text x="0" y="0" textAnchor="middle" dominantBaseline="middle">
{text}
</text>
</g>
<AlwaysUpText
baseRot={rot - 90}
additionalRot={180}
offsetY={6}
textAnchor='middle'
text={text}
/>
</g>
);
}

View File

@ -3,6 +3,7 @@ import AngleHelper from '../../../helpers/angleHelper';
import DefinedSignalSigns from '../../../model/defs/defined-signal-signs';
import SignalElementsEnums from '../../../model/signal-elements/enums';
import ExtendedSignalsHelper from '../../../helpers/extendedSignalsHelper';
import AlwaysUpText from '../text/AlwaysUpText';
import C from '../../../helpers/extendedSignalsConstants';
export default function SignalExtendedRenderer(props) {
@ -25,19 +26,17 @@ export default function SignalExtendedRenderer(props) {
function SignalNameText(props) {
const {object, rot, pointsData} = props;
const upsideDown = rot >= 180;
const upsideDownRot = upsideDown ? 90 : -90;
const anchor = upsideDown ? "start" : "end";
const isOverhead = object.signal_elements.isOverhead();
const baseY = isOverhead ? pointsData.headOffsetY + pointsData.polePoints.end : 0;
return (
<g transform={`translate(0, ${baseY + C.NAME_TEXT_OFFSET}) rotate(${upsideDownRot})`}>
<text x="0" y="0" textAnchor={anchor} dominantBaseline="middle">
{object.getPrintableSignalName()}
</text>
</g>
<AlwaysUpText
baseRot={rot}
additionalRot={-90}
offsetY={baseY + C.NAME_TEXT_OFFSET}
text={object.getPrintableSignalName()}
reverseAnchor={true}
/>
);
}

View File

@ -1,15 +1,10 @@
import { ReactSVG } from "react-svg";
import AngleHelper from "../../../helpers/angleHelper";
import AlwaysUpText from "../text/AlwaysUpText";
export default function SignalStandardRenderer(props) {
const { object } = props;
const [x, y] = object.pos.toSVGCoords();
const rot = AngleHelper.normalizeDegAngle(object.rot.y);
const upsideDown = rot >= 180;
const upsideDownRot = upsideDown ? 90 : -90;
const anchor = upsideDown ? "end" : "start";
return (
<g className="signal" transform={`translate(${x}, ${y}) rotate(${object.rot.y})`}>
<g className="signal-icon" transform={`translate(-1.89, -1.89)`}>
@ -23,11 +18,12 @@ export default function SignalStandardRenderer(props) {
}}
/>
</g>
<g transform={`translate(0, -2) rotate(${upsideDownRot})`}>
<text x="0" y="0" textAnchor={anchor} dominantBaseline="middle">
{object.getPrintableSignalName()}
</text>
</g>
<AlwaysUpText
baseRot={object.rot.y}
additionalRot={-90}
offsetY={-2}
text={object.getPrintableSignalName()}
/>
</g>
);
}

View File

@ -1,15 +1,10 @@
import { ReactSVG } from 'react-svg'
import AngleHelper from '../../../helpers/angleHelper';
import AlwaysUpText from '../text/AlwaysUpText';
export default function SpawnPointRenderer(props) {
const { object } = props;
const [x, y] = object.pos.toSVGCoords();
const rot = AngleHelper.normalizeDegAngle(object.rot.y);
const upsideDown = rot >= 180;
const upsideDownRot = upsideDown ? 90 : -90;
const anchor = upsideDown ? "start" : "end";
return (
<g className="spawn-point" transform={`translate(${x}, ${y}) rotate(${object.rot.y})`}>
<g className="signal-icon" transform={`rotate(180) translate(-2.835, -2.835)`}>
@ -23,11 +18,13 @@ export default function SpawnPointRenderer(props) {
}}
/>
</g>
<g transform={`translate(0, 3) rotate(${upsideDownRot})`}>
<text x="0" y="0" textAnchor={anchor} dominantBaseline="middle">
{object.getPrintableSpawnPointName()}
</text>
</g>
<AlwaysUpText
baseRot={object.rot.y}
additionalRot={-90}
offsetY={3}
reverseAnchor={true}
text={object.getPrintableSpawnPointName()}
/>
</g>
);
}

View File

@ -1,18 +1,14 @@
import SimpleLabelText from "../text/SimpleLabelText";
export default function SwitchNameRenderer(props) {
const { object } = props;
const [x, y] = object.pos.toSVGCoords();
const text = object.id_switch;
return (
<text
return <SimpleLabelText
text={text}
className="switch-name"
x={x}
y={y}
textAnchor='middle'
id={`switch-${object.id}`}
style={{ userSelect: "none" }}
className="switch-name"
>
{text}
</text>
);
/>;
}

View File

@ -1,3 +1,5 @@
import SimpleLabelText from "../text/SimpleLabelText";
export default function TrackObjectRenderer(props) {
const { object } = props;
const [x, y] = object.pos.toSVGCoords();
@ -6,29 +8,26 @@ export default function TrackObjectRenderer(props) {
return (
<g className="track-object">
<rect
x={x - 2}
y={y - 2}
width='4px'
height='4px'
x={x - 1}
y={y - 1}
width='2px'
height='2px'
fill='transparent'
stroke='none'
></rect>
<circle
cx={x}
cy={y}
r='1px'
r='0.5px'
></circle>
<text
<SimpleLabelText
text={text}
className="track-object-label"
x={x}
y={y}
id={`track-object-${object.id}`}
style={{ userSelect: "none" }}
enableBackground="new 0 0 100 100"
textAnchor="middle"
dominantBaseline="middle"
>
{text}
</text>
wrapperStyle={{pointerEvents: 'none'}}
/>
</g>
);
}

View File

@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
import AngleHelper from "../../../helpers/angleHelper";
import { mapRotation$ } from "../../../services/mapRotationService";
export default function AlwaysUpText(props) {
const [mapRotation, setMapRotation] = useState(0);
const { baseRot, additionalRot, text, reverseAnchor, offsetX, offsetY } = props;
const adjustedRot = AngleHelper.normalizeDegAngle(baseRot + mapRotation);
const upsideDown = adjustedRot >= 180;
const upsideDownRot = upsideDown ? 180 : 0;
const anchor = (upsideDown !== !!reverseAnchor) ? "end" : "start";
useEffect(() => {
const subscription = mapRotation$.subscribe(rotation => {
setMapRotation(rotation);
});
return () => subscription.unsubscribe();
}, []);
return (
<g transform={`translate(${offsetX || 0}, ${offsetY || 0}) rotate(${upsideDownRot + additionalRot})`}>
<text textAnchor={anchor} dominantBaseline='middle' {...props}>
{text}
</text>
</g>
);
}

View File

@ -0,0 +1,29 @@
import { useCallback, useEffect, useRef } from "react";
import { mapRotation$ } from "../../../services/mapRotationService";
export default function SimpleLabelText(props) {
const { text, x, y, wrapperStyle } = props;
const gRef = useRef(null);
const getTransformString = useCallback((rot) => (
`translate(${x || 0}, ${y || 0}) rotate(${rot})`
), [x, y]);
useEffect(() => {
const subscription = mapRotation$.subscribe(rotation => {
if(!gRef.current) return;
gRef.current.setAttribute("transform", getTransformString(-rotation));
});
return () => subscription.unsubscribe();
}, [getTransformString]);
return (
<g transform={getTransformString(0)} ref={gRef} style={wrapperStyle}>
<text textAnchor="middle" dominantBaseline="middle" {...props} x={null} y={null}>
{text}
</text>
</g>
);
}

View File

@ -0,0 +1,7 @@
import { BehaviorSubject } from 'rxjs';
export const mapRotation$ = new BehaviorSubject(null);
export function setMapRotation(rotation) {
mapRotation$.next(rotation);
}