Extract TrackHoverInfoTable component

- Extracted TrackHoverInfoTable as a separate component
This commit is contained in:
izawartka 2026-08-13 19:02:15 +02:00
parent 1d86f0f42f
commit daa86d7b85
4 changed files with 116 additions and 94 deletions

View File

@ -12,16 +12,16 @@
padding: 1em; padding: 1em;
} }
.track-hover-info-popup svg { .track-hover-info-popup-table svg {
width: 1.2em; width: 1.2em;
height: 1.2em; height: 1.2em;
} }
.track-hover-info-popup table { .track-hover-info-popup-table {
border-spacing: 2px; border-spacing: 2px;
} }
.track-hover-info-popup th { .track-hover-info-popup-table th {
text-align: start; text-align: start;
padding-right: 2em; padding-right: 2em;
color: var(--text-color-disabled); color: var(--text-color-disabled);
@ -33,7 +33,7 @@
color: var(--text-color-disabled); color: var(--text-color-disabled);
} }
.track-hover-info-popup .track-hover-info-popup-label { .track-hover-info-popup-table .track-hover-info-popup-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5em; gap: 0.5em;

View File

@ -1,8 +1,10 @@
import { useState, useEffect, useCallback, useRef, useContext } from "react"; import { useState, useEffect, useCallback, useRef, useContext, useMemo } from "react";
import { hoveredTrack$ } from "../../../services/trackHoverInfoService"; import { hoveredTrack$, touchClickedTrack$ } from "../../../services/trackHoverInfoService";
import TrackHoverInfoPopup from "./TrackHoverInfoPopup"; import TrackHoverInfoPopup from "./TrackHoverInfoPopup";
import SettingsContext from "../../../contexts/SettingsContext"; import SettingsContext from "../../../contexts/SettingsContext";
import { showDialog } from "../../../services/dialogService";
import './TrackHoverInfo.css'; import './TrackHoverInfo.css';
import TrackHoverInfoPopupTable from "./TrackHoverInfoTable";
export default function TrackHoverInfo() { export default function TrackHoverInfo() {
const { showTrackHoverInfo } = useContext(SettingsContext); const { showTrackHoverInfo } = useContext(SettingsContext);
@ -18,6 +20,7 @@ function InnerTrackHoverInfo() {
const [ hoveredTrack, setHoveredTrack ] = useState(null); const [ hoveredTrack, setHoveredTrack ] = useState(null);
const mousePos = useRef({ x: 0, y: 0 }); const mousePos = useRef({ x: 0, y: 0 });
const divRef = useRef(null); const divRef = useRef(null);
const isMobile = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []);
const handleMouseMove = useCallback((event) => { const handleMouseMove = useCallback((event) => {
mousePos.current.x = event.clientX; mousePos.current.x = event.clientX;
@ -26,19 +29,34 @@ function InnerTrackHoverInfo() {
if (!divRef.current) return; if (!divRef.current) return;
divRef.current.style.left = `${mousePos.current.x}px`; divRef.current.style.left = `${mousePos.current.x}px`;
divRef.current.style.top = `${mousePos.current.y}px`; divRef.current.style.top = `${mousePos.current.y}px`;
}, [mousePos, divRef]); }, []);
useEffect(() => { useEffect(() => {
const subscription = hoveredTrack$.subscribe(date => { const hoveredTrackSub = hoveredTrack$.subscribe(track => {
setHoveredTrack(date); if (isMobile) return;
setHoveredTrack(track);
}); });
document.addEventListener('mousemove', handleMouseMove); const touchClickedTrackSub = touchClickedTrack$.subscribe(track => {
if (!isMobile || !track) return;
showDialog(
<TrackHoverInfoPopupTable track={track} />,
[{ text: 'Close', onClick: () => {}, autoFocus: true, bgClick: true }]
);
});
if (!isMobile) {
document.addEventListener('mousemove', handleMouseMove);
}
return () => { return () => {
subscription.unsubscribe(); hoveredTrackSub.unsubscribe();
document.removeEventListener('mousemove', handleMouseMove); touchClickedTrackSub.unsubscribe();
} if (!isMobile) {
document.removeEventListener('mousemove', handleMouseMove);
}
};
}, [handleMouseMove]); }, [handleMouseMove]);
if (!hoveredTrack) return null; if (!hoveredTrack) return null;

View File

@ -1,35 +1,12 @@
import Constants from "../../../helpers/constants";
import { QuestionMarkIcon, SlopeIcon, SpeedIcon, HashIcon, LightningIcon, RadiusIcon } from "../../../icons";
import { TrackShape } from "../../../model/tracks/track"; import { TrackShape } from "../../../model/tracks/track";
import TrackHoverInfoConsts from "./TrackHoverInfoConsts"; import TrackHoverInfoPopupTable from "./TrackHoverInfoTable";
export default function TrackHoverInfoPopup(props) { export default function TrackHoverInfoPopup({track}) {
const { track } = props;
const slopeValue = Array.from(new Set([
Math.abs(track.start_slope).toFixed(1),
Math.abs(track.end_slope).toFixed(1),
]));
const slopeOptions = {join: ' / ', subOptions: {suffix: ' ‰'}};
const showAlignHint = track.shape === TrackShape.STRAIGHT; const showAlignHint = track.shape === TrackShape.STRAIGHT;
return ( return (
<div className="track-hover-info-popup"> <div className="track-hover-info-popup">
<table> <TrackHoverInfoPopupTable track={track} />
<tbody>
{Constants.map.showDebugTrackIds && (
<InfoPopupItem icon={HashIcon} value={track.id} label="Track ID" />
)}
<InfoPopupItem icon={HashIcon} value={track['id_isolation']} label="Isolation ID" />
<InfoPopupItem icon={SpeedIcon} value={track['derailspeed']} label="Derail speed" options={{suffix: ' km/h'}} />
<InfoPopupItem icon={SpeedIcon} value={track['maxspeed']} label="Max. speed" options={{suffix: ' km/h'}} />
<InfoPopupItem icon={LightningIcon} value={track['electrificationStatus']} label="Electrification" options={{translationConstKey: 'electrification'}} />
<InfoPopupItem icon={QuestionMarkIcon} value={track.shape} label="Type" options={{join: ' / ', translationConstKey: 'shape'}} />
<InfoPopupItem icon={SlopeIcon} value={slopeValue} label="Slope" options={slopeOptions} />
<InfoPopupSwitchItems track={track} />
<InfoPopupShapeItems track={track} />
</tbody>
</table>
{showAlignHint && ( {showAlignHint && (
<div className="track-hover-info-popup__align"> <div className="track-hover-info-popup__align">
Double click to align the view Double click to align the view
@ -38,58 +15,3 @@ export default function TrackHoverInfoPopup(props) {
</div> </div>
); );
} }
function InfoPopupSwitchItems(props) {
const { track } = props;
if (!track.switch) return null;
return <>
<InfoPopupItem icon={HashIcon} value={track.switch.id_switch} label="Switch ID" />
<InfoPopupItem icon={QuestionMarkIcon} value={track.switch.bare_model} label="Switch model" />
</>
}
function InfoPopupShapeItems(props) {
const { track } = props;
if (track.r === 0) return null;
return <InfoPopupItem icon={RadiusIcon} value={Math.abs(track.r)} label="Radius" options={{suffix: ' m', precision: 0}} />
}
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 (typeof value === 'number' && 'precision' in options) {
value = value.toFixed(options.precision);
}
if (value === undefined || value === null || value === '') {
return "N/A";
}
return `${value}${suffix || ''}`.trim();
}
function InfoPopupItem({ icon: Icon, label, value, options = {} }) {
const textValue = processItemValue(value, options);
return (
<tr>
<th>
<div className="track-hover-info-popup-label">
{Icon ? <Icon width={15} height={15} color="#888" strokeWidth={3} /> : null}
{label}
</div>
</th>
<td>{textValue}</td>
</tr>
);
}

View File

@ -0,0 +1,82 @@
import Constants from "../../../helpers/constants";
import { QuestionMarkIcon, SlopeIcon, SpeedIcon, HashIcon, LightningIcon, RadiusIcon } from "../../../icons";
import TrackHoverInfoConsts from "./TrackHoverInfoConsts";
export default function TrackHoverInfoPopupTable({track}) {
const slopeValue = Array.from(new Set([
Math.abs(track.start_slope).toFixed(1),
Math.abs(track.end_slope).toFixed(1),
]));
const slopeOptions = {join: ' / ', subOptions: {suffix: ' ‰'}};
return (
<table className="track-hover-info-popup-table">
<tbody>
{Constants.map.showDebugTrackIds && (
<InfoPopupItem icon={HashIcon} value={track.id} label="Track ID" />
)}
<InfoPopupItem icon={HashIcon} value={track['id_isolation']} label="Isolation ID" />
<InfoPopupItem icon={SpeedIcon} value={track['derailspeed']} label="Derail speed" options={{suffix: ' km/h'}} />
<InfoPopupItem icon={SpeedIcon} value={track['maxspeed']} label="Max. speed" options={{suffix: ' km/h'}} />
<InfoPopupItem icon={LightningIcon} value={track['electrificationStatus']} label="Electrification" options={{translationConstKey: 'electrification'}} />
<InfoPopupItem icon={QuestionMarkIcon} value={track.shape} label="Type" options={{join: ' / ', translationConstKey: 'shape'}} />
<InfoPopupItem icon={SlopeIcon} value={slopeValue} label="Slope" options={slopeOptions} />
<InfoPopupSwitchItems track={track} />
<InfoPopupShapeItems track={track} />
</tbody>
</table>
);
}
function InfoPopupSwitchItems({track}) {
if (!track.switch) return null;
return <>
<InfoPopupItem icon={HashIcon} value={track.switch.id_switch} label="Switch ID" />
<InfoPopupItem icon={QuestionMarkIcon} value={track.switch.bare_model} label="Switch model" />
</>
}
function InfoPopupShapeItems({track}) {
if (track.r === 0) return null;
return <InfoPopupItem icon={RadiusIcon} value={Math.abs(track.r)} label="Radius" options={{suffix: ' m', precision: 0}} />
}
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 (typeof value === 'number' && 'precision' in options) {
value = value.toFixed(options.precision);
}
if (value === undefined || value === null || value === '') {
return "N/A";
}
return `${value}${suffix || ''}`.trim();
}
function InfoPopupItem({ icon: Icon, label, value, options = {} }) {
const textValue = processItemValue(value, options);
return (
<tr>
<th>
<div className="track-hover-info-popup-label">
{Icon ? <Icon color={undefined} width={15} height={15} strokeWidth={3} /> : null}
{label}
</div>
</th>
<td>{textValue}</td>
</tr>
);
}