- Added new fields to TrackModel: status, is_bidirectional, and coordinates. - Updated network service to handle new track attributes and geometry extraction. - Introduced CLI scripts for importing and loading tracks from OpenStreetMap. - Implemented normalization of track elements to ensure valid geometries. - Enhanced tests for track model, network service, and import/load scripts. - Updated frontend to accommodate new track attributes and improve route computation. - Documented OSM ingestion process in architecture and runtime views.
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
import './styles/global.css';
|
|
|
|
import type { LatLngExpression } from 'leaflet';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import { LoginForm } from './components/auth/LoginForm';
|
|
import { NetworkMap } from './components/map/NetworkMap';
|
|
import { useNetworkSnapshot } from './hooks/useNetworkSnapshot';
|
|
import { useAuth } from './state/AuthContext';
|
|
import type { Station } from './types/domain';
|
|
import { buildTrackAdjacency, computeRoute } from './utils/route';
|
|
|
|
function App(): JSX.Element {
|
|
const { token, user, status: authStatus, logout } = useAuth();
|
|
const isAuthenticated = authStatus === 'authenticated' && token !== null;
|
|
const { data, status, error } = useNetworkSnapshot(isAuthenticated ? token : null);
|
|
const [focusedStationId, setFocusedStationId] = useState<string | null>(null);
|
|
const [routeSelection, setRouteSelection] = useState<{
|
|
startId: string | null;
|
|
endId: string | null;
|
|
}>({ startId: null, endId: null });
|
|
|
|
useEffect(() => {
|
|
if (status !== 'success' || !data?.stations.length) {
|
|
setFocusedStationId(null);
|
|
setRouteSelection({ startId: null, endId: null });
|
|
return;
|
|
}
|
|
|
|
if (!focusedStationId || !hasStation(data.stations, focusedStationId)) {
|
|
setFocusedStationId(data.stations[0].id);
|
|
}
|
|
}, [status, data, focusedStationId]);
|
|
|
|
useEffect(() => {
|
|
if (status !== 'success' || !data) {
|
|
return;
|
|
}
|
|
|
|
setRouteSelection((current) => {
|
|
const startExists = current.startId
|
|
? hasStation(data.stations, current.startId)
|
|
: false;
|
|
const endExists = current.endId
|
|
? hasStation(data.stations, current.endId)
|
|
: false;
|
|
|
|
return {
|
|
startId: startExists ? current.startId : null,
|
|
endId: endExists ? current.endId : null,
|
|
};
|
|
});
|
|
}, [status, data]);
|
|
|
|
const stationById = useMemo(() => {
|
|
if (!data) {
|
|
return new Map<string, Station>();
|
|
}
|
|
const lookup = new Map<string, Station>();
|
|
for (const station of data.stations) {
|
|
lookup.set(station.id, station);
|
|
}
|
|
return lookup;
|
|
}, [data]);
|
|
|
|
const trackAdjacency = useMemo(
|
|
() => buildTrackAdjacency(data ? data.tracks : []),
|
|
[data]
|
|
);
|
|
|
|
const routeComputation = useMemo(
|
|
() =>
|
|
computeRoute({
|
|
startId: routeSelection.startId,
|
|
endId: routeSelection.endId,
|
|
stationById,
|
|
adjacency: trackAdjacency,
|
|
}),
|
|
[routeSelection, stationById, trackAdjacency]
|
|
);
|
|
|
|
const routeSegments = useMemo<LatLngExpression[][]>(() => {
|
|
return routeComputation.segments.map((segment) =>
|
|
segment.map((pair) => [pair[0], pair[1]] as LatLngExpression)
|
|
);
|
|
}, [routeComputation.segments]);
|
|
|
|
const focusedStation = useMemo(() => {
|
|
if (!data || !focusedStationId) {
|
|
return null;
|
|
}
|
|
return stationById.get(focusedStationId) ?? null;
|
|
}, [data, focusedStationId, stationById]);
|
|
|
|
const handleStationSelection = (stationId: string) => {
|
|
setFocusedStationId(stationId);
|
|
setRouteSelection((current) => {
|
|
if (!current.startId || (current.startId && current.endId)) {
|
|
return { startId: stationId, endId: null };
|
|
}
|
|
|
|
if (current.startId === stationId) {
|
|
return { startId: stationId, endId: null };
|
|
}
|
|
|
|
return { startId: current.startId, endId: stationId };
|
|
});
|
|
};
|
|
|
|
const clearRouteSelection = () => {
|
|
setRouteSelection({ startId: null, endId: null });
|
|
};
|
|
|
|
return (
|
|
<div className="app-shell">
|
|
<header className="app-header">
|
|
<div>
|
|
<h1>Rail Game</h1>
|
|
<p>Build and manage a railway network using real-world map data.</p>
|
|
</div>
|
|
{isAuthenticated && (
|
|
<div className="auth-meta">
|
|
<span>Signed in as {user?.fullName ?? user?.username}</span>
|
|
<button type="button" onClick={() => logout()} className="ghost-button">
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
)}
|
|
</header>
|
|
<main className="app-main">
|
|
{!isAuthenticated ? (
|
|
<section className="card">
|
|
<LoginForm />
|
|
</section>
|
|
) : (
|
|
<section className="card">
|
|
<h2>Network Snapshot</h2>
|
|
{status === 'loading' && <p>Loading network data…</p>}
|
|
{status === 'error' && <p className="error-text">{error}</p>}
|
|
{status === 'success' && data && (
|
|
<div className="snapshot-layout">
|
|
<div className="map-wrapper">
|
|
<NetworkMap
|
|
snapshot={data}
|
|
focusedStationId={focusedStationId}
|
|
startStationId={routeSelection.startId}
|
|
endStationId={routeSelection.endId}
|
|
routeSegments={routeSegments}
|
|
onStationClick={handleStationSelection}
|
|
/>
|
|
</div>
|
|
<div className="route-panel">
|
|
<div className="route-panel__header">
|
|
<h3>Route Selection</h3>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={clearRouteSelection}
|
|
disabled={!routeSelection.startId && !routeSelection.endId}
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
<p className="route-panel__hint">
|
|
Click a station to set the origin, then click another station to
|
|
preview the rail corridor between them.
|
|
</p>
|
|
<dl className="route-panel__meta">
|
|
<div>
|
|
<dt>Origin</dt>
|
|
<dd>
|
|
{routeSelection.startId
|
|
? (stationById.get(routeSelection.startId)?.name ??
|
|
'Unknown station')
|
|
: 'Choose a station'}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Destination</dt>
|
|
<dd>
|
|
{routeSelection.endId
|
|
? (stationById.get(routeSelection.endId)?.name ??
|
|
'Unknown station')
|
|
: 'Choose a station'}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Estimated Length</dt>
|
|
<dd>
|
|
{routeComputation.totalLength !== null
|
|
? `${(routeComputation.totalLength / 1000).toFixed(2)} km`
|
|
: 'N/A'}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
{routeComputation.error && (
|
|
<p className="route-panel__error">{routeComputation.error}</p>
|
|
)}
|
|
{!routeComputation.error && routeComputation.stations && (
|
|
<div className="route-panel__path">
|
|
<span>Path:</span>
|
|
<ol>
|
|
{routeComputation.stations.map((station) => (
|
|
<li key={`route-station-${station.id}`}>{station.name}</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="grid">
|
|
<div>
|
|
<h3>Stations</h3>
|
|
<ul>
|
|
{data.stations.map((station) => (
|
|
<li key={station.id}>
|
|
<button
|
|
type="button"
|
|
className={`station-list-item${
|
|
station.id === focusedStationId
|
|
? ' station-list-item--selected'
|
|
: ''
|
|
}${
|
|
station.id === routeSelection.startId
|
|
? ' station-list-item--start'
|
|
: ''
|
|
}${
|
|
station.id === routeSelection.endId
|
|
? ' station-list-item--end'
|
|
: ''
|
|
}`}
|
|
aria-pressed={station.id === focusedStationId}
|
|
onClick={() => handleStationSelection(station.id)}
|
|
>
|
|
<span className="station-list-item__name">
|
|
{station.name}
|
|
</span>
|
|
<span className="station-list-item__coords">
|
|
{station.latitude.toFixed(3)},{' '}
|
|
{station.longitude.toFixed(3)}
|
|
</span>
|
|
{station.id === routeSelection.startId && (
|
|
<span className="station-list-item__badge">Origin</span>
|
|
)}
|
|
{station.id === routeSelection.endId && (
|
|
<span className="station-list-item__badge">
|
|
Destination
|
|
</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<h3>Trains</h3>
|
|
<ul>
|
|
{data.trains.map((train) => (
|
|
<li key={train.id}>
|
|
{train.designation} · {train.capacity} capacity ·{' '}
|
|
{train.maxSpeedKph} km/h
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<h3>Tracks</h3>
|
|
<ul>
|
|
{data.tracks.map((track) => (
|
|
<li key={track.id}>
|
|
{track.startStationId} → {track.endStationId} ·{' '}
|
|
{track.lengthMeters > 0
|
|
? `${(track.lengthMeters / 1000).toFixed(1)} km`
|
|
: 'N/A'}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
{focusedStation && (
|
|
<div className="selected-station">
|
|
<h3>Focused Station</h3>
|
|
<dl>
|
|
<div>
|
|
<dt>Name</dt>
|
|
<dd>{focusedStation.name}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Coordinates</dt>
|
|
<dd>
|
|
{focusedStation.latitude.toFixed(5)},{' '}
|
|
{focusedStation.longitude.toFixed(5)}
|
|
</dd>
|
|
</div>
|
|
{focusedStation.code && (
|
|
<div>
|
|
<dt>Code</dt>
|
|
<dd>{focusedStation.code}</dd>
|
|
</div>
|
|
)}
|
|
{typeof focusedStation.elevationM === 'number' && (
|
|
<div>
|
|
<dt>Elevation</dt>
|
|
<dd>{focusedStation.elevationM.toFixed(1)} m</dd>
|
|
</div>
|
|
)}
|
|
{focusedStation.osmId && (
|
|
<div>
|
|
<dt>OSM ID</dt>
|
|
<dd>{focusedStation.osmId}</dd>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<dt>Status</dt>
|
|
<dd>
|
|
{(focusedStation.isActive ?? true) ? 'Active' : 'Inactive'}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|
|
|
|
function hasStation(stations: Station[], id: string): boolean {
|
|
return stations.some((station) => station.id === id);
|
|
}
|