Added track hover info

+Added a popup showing hovered track's info
This commit is contained in:
izawartka 2025-06-24 21:09:43 +02:00
parent 715a8ffc7c
commit 66eae33e80
10 changed files with 173 additions and 1 deletions

View File

@ -2,6 +2,7 @@ import ZoomPanWrapper from './ZoomPanWrapper';
import './Map.css'; import './Map.css';
import SceneryRoot from './SceneryRoot'; import SceneryRoot from './SceneryRoot';
import DistanceMeter from './distance-meter/DistanceMeter'; import DistanceMeter from './distance-meter/DistanceMeter';
import TrackHoverInfo from './track-hover-info/TrackHoverInfo';
export default function Map(props) { export default function Map(props) {
return ( return (
@ -10,6 +11,7 @@ export default function Map(props) {
<SceneryRoot /> <SceneryRoot />
<DistanceMeter /> <DistanceMeter />
</ZoomPanWrapper> </ZoomPanWrapper>
<TrackHoverInfo />
</div> </div>
) )
} }

View File

@ -19,7 +19,8 @@ const ObjectRendererQueue = [
'additionalComponents': [ 'additionalComponents': [
ElectrificationStatusPopup ElectrificationStatusPopup
], ],
'cond': (layers) => layers['tracks'] 'cond': (layers) => layers['tracks'],
'pointerEvents': true
}, },
{ {
'name': 'isolations-ids', 'name': 'isolations-ids',

View File

@ -3,6 +3,7 @@ import SettingsContext from "../../../contexts/SettingsContext";
import Constants from "../../../helpers/constants"; 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";
export default function TrackRenderer(props) { export default function TrackRenderer(props) {
const { object } = props; const { object } = props;
@ -21,6 +22,14 @@ const MemoizedTrackRenderer = React.memo(StatelessTrackRenderer);
function StatelessTrackRenderer(props) { function StatelessTrackRenderer(props) {
const { object, trackColorMode } = props; const { object, trackColorMode } = props;
const onMouseEnter = () => {
setHoveredTrack(object);
};
const onMouseLeave = () => {
unsetHoveredTrack(null);
};
const path = getTrackPath(object); const path = getTrackPath(object);
const color = getTrackColor(object, trackColorMode); const color = getTrackColor(object, trackColorMode);
const defs = getTrackDefs(object, trackColorMode); const defs = getTrackDefs(object, trackColorMode);
@ -37,6 +46,8 @@ function StatelessTrackRenderer(props) {
d={path} d={path}
stroke={color} stroke={color}
className="track" className="track"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/> />
</g> </g>
); );

View File

@ -0,0 +1,19 @@
.track-hover-info {
position: absolute;
z-index: 10000;
margin: 0.5em;
pointer-events: none;
}
.track-hover-info-popup {
background-color: #222;
border: 2px solid #444;
border-radius: 8px;
padding: 1em;
}
.track-hover-info-popup th {
text-align: start;
padding-right: 2em;
color: #888;
}

View File

@ -0,0 +1,46 @@
import { useState, useEffect, useCallback, useRef, useContext } from "react";
import { hoveredTrack$ } from "../../../services/trackHoverInfoService";
import TrackHoverInfoPopup from "./TrackHoverInfoPopup";
import SettingsContext from "../../../contexts/SettingsContext";
import './TrackHoverInfo.css';
export default function TrackHoverInfo() {
const [ hoveredTrack, setHoveredTrack ] = useState(null);
const mousePos = useRef({ x: 0, y: 0 });
const divRef = useRef(null);
const handleMouseMove = useCallback((event) => {
mousePos.current.x = event.clientX;
mousePos.current.y = event.clientY;
if (!divRef.current) return;
divRef.current.style.left = `${mousePos.current.x}px`;
divRef.current.style.top = `${mousePos.current.y}px`;
}, [mousePos, divRef]);
useEffect(() => {
const subscription = hoveredTrack$.subscribe(date => {
setHoveredTrack(date);
});
document.addEventListener('mousemove', handleMouseMove);
return () => {
subscription.unsubscribe();
document.removeEventListener('mousemove', handleMouseMove);
}
}, [handleMouseMove]);
if (!hoveredTrack) return null;
const divStyle = {
left: `${mousePos.current.x}px`,
top: `${mousePos.current.y}px`,
};
return (
<div className="track-hover-info" ref={divRef} style={divStyle}>
<TrackHoverInfoPopup track={hoveredTrack} />
</div>
);
}

View File

@ -0,0 +1,17 @@
import { ElectrificationStatus } from "../../../model/electrification-status";
const TrackHoverInfoConsts = {
electrification: {
[ElectrificationStatus.ELECTRIFIED]: 'Electrified',
[ElectrificationStatus.NON_ELECTRIFIED]: 'Non-electrified',
[ElectrificationStatus.NOT_CHECKED]: 'Unknown',
[ElectrificationStatus.CONFLICT]: 'Error / Conflict',
},
type: {
'StandardTrack': 'Standard track',
'PointTrack': 'Switch track',
'BezierTrack': 'Bezier track',
}
};
export default TrackHoverInfoConsts;

View File

@ -0,0 +1,53 @@
import TrackHoverInfoConsts from "./TrackHoverInfoConsts";
export default function TrackHoverInfoPopup(props) {
const { track } = props;
const slopeArr = Array.from(new Set([track['start_slope'], track['end_slope']]))
const slopeOptions = {join: ' / ', subOptions: {suffix: ' ‰'}};
return (
<div className="track-hover-info-popup">
<table>
<tbody>
<InfoPopupItem value={track['id_station']} label="Track ID" />
<InfoPopupItem value={track['id_isolation']} label="Isolation ID" />
<InfoPopupItem value={track['derailspeed']} label="Derail speed" options={{suffix: ' km/h'}} />
<InfoPopupItem value={track['maxspeed']} label="Max. speed" options={{suffix: ' km/h'}} />
<InfoPopupItem value={slopeArr} label="Slope" options={slopeOptions} />
<InfoPopupItem value={track['electrificationStatus']} label="Electrification" options={{translationConstKey: 'electrification'}} />
<InfoPopupItem value={track['type']} label="Type" options={{translationConstKey: 'type'}} />
</tbody>
</table>
</div>
);
}
function processItemValue(value, options) {
const { translationConstKey, join, subOptions, suffix } = options || {};
if (Array.isArray(value)) {
return value.map(item => processItemValue(item, subOptions)).join(join || ", ");
}
if (translationConstKey) {
return TrackHoverInfoConsts[translationConstKey]?.[value] || value;
}
if (!value) {
return "N/A";
}
return `${value}${suffix || ''}`.trim();
}
function InfoPopupItem({ label, value, options = {} }) {
const textValue = processItemValue(value, options);
return (
<tr>
<th>{label}</th>
<td>{textValue}</td>
</tr>
);
}

View File

@ -2,6 +2,7 @@ import { useContext } from "react";
import MainContext from "../../contexts/MainContext"; import MainContext from "../../contexts/MainContext";
import { useZoomPanEmitter } from "../../hooks/useZoomPubSub"; import { useZoomPanEmitter } from "../../hooks/useZoomPubSub";
import SceneryFilesHelper from "../../helpers/sceneryFilesHelper"; import SceneryFilesHelper from "../../helpers/sceneryFilesHelper";
import { resetHoveredTracksStack } from "../../services/trackHoverInfoService";
export default function CustomFileSelect() { export default function CustomFileSelect() {
const {setScenery, setIsLoading} = useContext(MainContext); const {setScenery, setIsLoading} = useContext(MainContext);
@ -14,6 +15,7 @@ export default function CustomFileSelect() {
setIsLoading(true); setIsLoading(true);
const scenery = await SceneryFilesHelper.loadCustom(file, center); const scenery = await SceneryFilesHelper.loadCustom(file, center);
setScenery(scenery); setScenery(scenery);
resetHoveredTracksStack();
setIsLoading(false); setIsLoading(false);
}; };

View File

@ -4,6 +4,7 @@ import { useZoomPanEmitter } from '../../hooks/useZoomPubSub';
import SceneryFilesHelper from '../../helpers/sceneryFilesHelper'; import SceneryFilesHelper from '../../helpers/sceneryFilesHelper';
import { setSceneriesListVersionDate } from '../../services/sceneriesListService'; import { setSceneriesListVersionDate } from '../../services/sceneriesListService';
import Constants from '../../helpers/constants'; import Constants from '../../helpers/constants';
import { resetHoveredTracksStack } from '../../services/trackHoverInfoService';
export default function SceneriesList(props) { export default function SceneriesList(props) {
const {setScenery, setIsLoading} = useContext(MainContext); const {setScenery, setIsLoading} = useContext(MainContext);
@ -16,6 +17,7 @@ export default function SceneriesList(props) {
setIsLoading(true); setIsLoading(true);
const scenery = await SceneryFilesHelper.load(name, center); const scenery = await SceneryFilesHelper.load(name, center);
setScenery(scenery); setScenery(scenery);
resetHoveredTracksStack();
setIsLoading(false); setIsLoading(false);
}; };

View File

@ -0,0 +1,19 @@
import { BehaviorSubject } from 'rxjs';
const hoveredTracksStack = [];
export const hoveredTrack$ = new BehaviorSubject(null);
export function setHoveredTrack(track) {
hoveredTracksStack.push(track);
hoveredTrack$.next(track);
}
export function unsetHoveredTrack(track) {
hoveredTracksStack.splice(hoveredTracksStack.indexOf(track), 1);
hoveredTrack$.next(hoveredTracksStack.at(-1) || null);
}
export function resetHoveredTracksStack() {
hoveredTracksStack.length = 0;
hoveredTrack$.next(null);
}