feat: add route selection functionality and improve station handling

- Added `vitest` for testing and created initial tests for route utilities.
- Implemented route selection logic in the App component, allowing users to select start and end stations.
- Updated the NetworkMap component to reflect focused and selected stations, including visual indicators for start and end stations.
- Enhanced the route panel UI to display selected route information and estimated lengths.
- Introduced utility functions for building track adjacency and computing routes based on selected stations.
- Improved styling for route selection and station list items to enhance user experience.
This commit is contained in:
2025-10-11 19:28:35 +02:00
parent 92d19235d8
commit 090dca29c2
10 changed files with 1441 additions and 52 deletions

View File

@@ -1,38 +1,114 @@
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 [selectedStationId, setSelectedStationId] = useState<string | null>(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) {
setSelectedStationId(null);
setFocusedStationId(null);
setRouteSelection({ startId: null, endId: null });
return;
}
if (
!selectedStationId ||
!data.stations.some((station) => station.id === selectedStationId)
) {
setSelectedStationId(data.stations[0].id);
if (!focusedStationId || !hasStation(data.stations, focusedStationId)) {
setFocusedStationId(data.stations[0].id);
}
}, [status, data, selectedStationId]);
}, [status, data, focusedStationId]);
const selectedStation = useMemo(() => {
if (!data || !selectedStationId) {
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(() => {
const core = computeRoute({
startId: routeSelection.startId,
endId: routeSelection.endId,
stationById,
adjacency: trackAdjacency,
});
const segments = core.stations ? buildSegmentsFromStations(core.stations) : [];
return {
...core,
segments,
};
}, [routeSelection, stationById, trackAdjacency]);
const focusedStation = useMemo(() => {
if (!data || !focusedStationId) {
return null;
}
return data.stations.find((station) => station.id === selectedStationId) ?? null;
}, [data, selectedStationId]);
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">
@@ -65,10 +141,71 @@ function App(): JSX.Element {
<div className="map-wrapper">
<NetworkMap
snapshot={data}
selectedStationId={selectedStationId}
onSelectStation={(id) => setSelectedStationId(id)}
focusedStationId={focusedStationId}
startStationId={routeSelection.startId}
endStationId={routeSelection.endId}
routeSegments={routeComputation.segments}
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>
@@ -78,12 +215,20 @@ function App(): JSX.Element {
<button
type="button"
className={`station-list-item${
station.id === selectedStationId
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 === selectedStationId}
onClick={() => setSelectedStationId(station.id)}
aria-pressed={station.id === focusedStationId}
onClick={() => handleStationSelection(station.id)}
>
<span className="station-list-item__name">
{station.name}
@@ -92,6 +237,14 @@ function App(): JSX.Element {
{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>
))}
@@ -120,43 +273,43 @@ function App(): JSX.Element {
</ul>
</div>
</div>
{selectedStation && (
{focusedStation && (
<div className="selected-station">
<h3>Selected Station</h3>
<h3>Focused Station</h3>
<dl>
<div>
<dt>Name</dt>
<dd>{selectedStation.name}</dd>
<dd>{focusedStation.name}</dd>
</div>
<div>
<dt>Coordinates</dt>
<dd>
{selectedStation.latitude.toFixed(5)},{' '}
{selectedStation.longitude.toFixed(5)}
{focusedStation.latitude.toFixed(5)},{' '}
{focusedStation.longitude.toFixed(5)}
</dd>
</div>
{selectedStation.code && (
{focusedStation.code && (
<div>
<dt>Code</dt>
<dd>{selectedStation.code}</dd>
<dd>{focusedStation.code}</dd>
</div>
)}
{typeof selectedStation.elevationM === 'number' && (
{typeof focusedStation.elevationM === 'number' && (
<div>
<dt>Elevation</dt>
<dd>{selectedStation.elevationM.toFixed(1)} m</dd>
<dd>{focusedStation.elevationM.toFixed(1)} m</dd>
</div>
)}
{selectedStation.osmId && (
{focusedStation.osmId && (
<div>
<dt>OSM ID</dt>
<dd>{selectedStation.osmId}</dd>
<dd>{focusedStation.osmId}</dd>
</div>
)}
<div>
<dt>Status</dt>
<dd>
{(selectedStation.isActive ?? true) ? 'Active' : 'Inactive'}
{(focusedStation.isActive ?? true) ? 'Active' : 'Inactive'}
</dd>
</div>
</dl>
@@ -172,3 +325,20 @@ function App(): JSX.Element {
}
export default App;
function hasStation(stations: Station[], id: string): boolean {
return stations.some((station) => station.id === id);
}
function buildSegmentsFromStations(stations: Station[]): LatLngExpression[][] {
const segments: LatLngExpression[][] = [];
for (let index = 0; index < stations.length - 1; index += 1) {
const current = stations[index];
const next = stations[index + 1];
segments.push([
[current.latitude, current.longitude],
[next.latitude, next.longitude],
]);
}
return segments;
}

View File

@@ -15,8 +15,11 @@ import 'leaflet/dist/leaflet.css';
interface NetworkMapProps {
readonly snapshot: NetworkSnapshot;
readonly selectedStationId?: string | null;
readonly onSelectStation?: (stationId: string) => void;
readonly focusedStationId?: string | null;
readonly startStationId?: string | null;
readonly endStationId?: string | null;
readonly routeSegments?: LatLngExpression[][];
readonly onStationClick?: (stationId: string) => void;
}
interface StationPosition {
@@ -29,8 +32,11 @@ const DEFAULT_CENTER: LatLngExpression = [51.505, -0.09];
export function NetworkMap({
snapshot,
selectedStationId,
onSelectStation,
focusedStationId,
startStationId,
endStationId,
routeSegments = [],
onStationClick,
}: NetworkMapProps): JSX.Element {
const stationPositions = useMemo<StationPosition[]>(() => {
return snapshot.stations.map((station) => ({
@@ -86,12 +92,12 @@ export function NetworkMap({
] as LatLngBoundsExpression;
}, [stationPositions]);
const selectedPosition = useMemo(() => {
if (!selectedStationId) {
const focusedPosition = useMemo(() => {
if (!focusedStationId) {
return null;
}
return stationLookup.get(selectedStationId) ?? null;
}, [selectedStationId, stationLookup]);
return stationLookup.get(focusedStationId) ?? null;
}, [focusedStationId, stationLookup]);
return (
<MapContainer
@@ -104,35 +110,52 @@ export function NetworkMap({
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{selectedPosition ? <StationFocus position={selectedPosition} /> : null}
{focusedPosition ? <StationFocus position={focusedPosition} /> : null}
{trackSegments.map((segment, index) => (
<Polyline
key={`track-${index}`}
positions={segment}
pathOptions={{ color: '#38bdf8', weight: 4 }}
pathOptions={{ color: '#334155', weight: 3, opacity: 0.8 }}
/>
))}
{routeSegments.map((segment, index) => (
<Polyline
key={`route-${index}`}
positions={segment}
pathOptions={{ color: '#facc15', weight: 6, opacity: 0.9 }}
/>
))}
{stationPositions.map((station) => (
<CircleMarker
key={station.id}
center={station.position}
radius={station.id === selectedStationId ? 9 : 6}
radius={station.id === focusedStationId ? 9 : 6}
pathOptions={{
color: station.id === selectedStationId ? '#34d399' : '#f97316',
fillColor: station.id === selectedStationId ? '#6ee7b7' : '#fed7aa',
fillOpacity: 0.95,
weight: station.id === selectedStationId ? 3 : 1,
color: resolveMarkerStroke(
station.id,
startStationId,
endStationId,
focusedStationId
),
fillColor: resolveMarkerFill(
station.id,
startStationId,
endStationId,
focusedStationId
),
fillOpacity: 0.96,
weight: station.id === focusedStationId ? 3 : 1,
}}
eventHandlers={{
click: () => {
onSelectStation?.(station.id);
onStationClick?.(station.id);
},
}}
>
<Tooltip
direction="top"
offset={[0, -8]}
permanent={station.id === selectedStationId}
permanent={station.id === focusedStationId}
sticky
>
{station.name}
@@ -152,3 +175,39 @@ function StationFocus({ position }: { position: LatLngExpression }): null {
return null;
}
function resolveMarkerStroke(
stationId: string,
startStationId?: string | null,
endStationId?: string | null,
focusedStationId?: string | null
): string {
if (stationId === startStationId) {
return '#38bdf8';
}
if (stationId === endStationId) {
return '#fb923c';
}
if (stationId === focusedStationId) {
return '#22c55e';
}
return '#f97316';
}
function resolveMarkerFill(
stationId: string,
startStationId?: string | null,
endStationId?: string | null,
focusedStationId?: string | null
): string {
if (stationId === startStationId) {
return '#bae6fd';
}
if (stationId === endStationId) {
return '#fed7aa';
}
if (stationId === focusedStationId) {
return '#bbf7d0';
}
return '#ffe4c7';
}

View File

@@ -102,6 +102,7 @@ body {
background-color 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
flex-wrap: wrap;
}
.station-list-item:hover,
@@ -118,6 +119,16 @@ body {
box-shadow: 0 8px 18px -10px rgba(45, 212, 191, 0.65);
}
.station-list-item--start {
border-color: rgba(56, 189, 248, 0.8);
background: rgba(14, 165, 233, 0.2);
}
.station-list-item--end {
border-color: rgba(249, 115, 22, 0.8);
background: rgba(234, 88, 12, 0.18);
}
.station-list-item__name {
font-weight: 600;
}
@@ -128,6 +139,27 @@ body {
font-family: 'Fira Code', 'Source Code Pro', monospace;
}
.station-list-item__badge {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.18);
color: rgba(226, 232, 240, 0.85);
}
.station-list-item--start .station-list-item__badge {
background: rgba(56, 189, 248, 0.35);
color: #0ea5e9;
}
.station-list-item--end .station-list-item__badge {
background: rgba(249, 115, 22, 0.35);
color: #f97316;
}
.grid h3 {
margin-bottom: 0.5rem;
font-size: 1.1rem;
@@ -151,6 +183,89 @@ body {
width: 100%;
}
.route-panel {
display: grid;
gap: 0.85rem;
padding: 1.1rem 1.35rem;
border-radius: 12px;
border: 1px solid rgba(250, 204, 21, 0.3);
background: rgba(161, 98, 7, 0.16);
}
.route-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.route-panel__hint {
font-size: 0.9rem;
color: rgba(226, 232, 240, 0.78);
}
.route-panel__meta {
display: grid;
gap: 0.45rem;
}
.route-panel__meta > div {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
}
.route-panel__meta dt {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(226, 232, 240, 0.65);
}
.route-panel__meta dd {
font-size: 0.95rem;
color: rgba(226, 232, 240, 0.92);
}
.route-panel__error {
color: #f87171;
font-weight: 600;
}
.route-panel__path {
display: flex;
gap: 0.6rem;
align-items: baseline;
}
.route-panel__path span {
font-size: 0.85rem;
color: rgba(226, 232, 240, 0.7);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.route-panel__path ol {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
list-style: none;
padding: 0;
margin: 0;
}
.route-panel__path li::after {
content: '→';
margin-left: 0.35rem;
color: rgba(250, 204, 21, 0.75);
}
.route-panel__path li:last-child::after {
content: '';
margin: 0;
}
.selected-station {
margin-top: 1rem;
padding: 1rem 1.25rem;

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { buildTrackAdjacency, computeRoute } from './route';
import type { Station, Track } from '../types/domain';
const baseTimestamps = {
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
describe('route utilities', () => {
it('finds a multi-hop path across connected tracks', () => {
const stations: Station[] = [
{
id: 'station-a',
name: 'Alpha',
latitude: 51.5,
longitude: -0.1,
...baseTimestamps,
},
{
id: 'station-b',
name: 'Bravo',
latitude: 51.52,
longitude: -0.11,
...baseTimestamps,
},
{
id: 'station-c',
name: 'Charlie',
latitude: 51.54,
longitude: -0.12,
...baseTimestamps,
},
{
id: 'station-d',
name: 'Delta',
latitude: 51.55,
longitude: -0.15,
...baseTimestamps,
},
];
const tracks: Track[] = [
{
id: 'track-ab',
startStationId: 'station-a',
endStationId: 'station-b',
lengthMeters: 1200,
maxSpeedKph: 120,
...baseTimestamps,
},
{
id: 'track-bc',
startStationId: 'station-b',
endStationId: 'station-c',
lengthMeters: 1500,
maxSpeedKph: 110,
...baseTimestamps,
},
{
id: 'track-cd',
startStationId: 'station-c',
endStationId: 'station-d',
lengthMeters: 900,
maxSpeedKph: 115,
...baseTimestamps,
},
];
const stationById = new Map(stations.map((station) => [station.id, station]));
const adjacency = buildTrackAdjacency(tracks);
const result = computeRoute({
startId: 'station-a',
endId: 'station-d',
stationById,
adjacency,
});
expect(result.error).toBeNull();
expect(result.stations?.map((station) => station.id)).toEqual([
'station-a',
'station-b',
'station-c',
'station-d',
]);
expect(result.tracks.map((track) => track.id)).toEqual([
'track-ab',
'track-bc',
'track-cd',
]);
expect(result.totalLength).toBe(1200 + 1500 + 900);
});
it('returns an error when no path exists', () => {
const stations: Station[] = [
{
id: 'station-a',
name: 'Alpha',
latitude: 51.5,
longitude: -0.1,
...baseTimestamps,
},
{
id: 'station-b',
name: 'Bravo',
latitude: 51.6,
longitude: -0.2,
...baseTimestamps,
},
];
const tracks: Track[] = [
{
id: 'track-self',
startStationId: 'station-a',
endStationId: 'station-a',
lengthMeters: 0,
maxSpeedKph: 80,
...baseTimestamps,
},
];
const stationById = new Map(stations.map((station) => [station.id, station]));
const adjacency = buildTrackAdjacency(tracks);
const result = computeRoute({
startId: 'station-a',
endId: 'station-b',
stationById,
adjacency,
});
expect(result.stations).toBeNull();
expect(result.tracks).toHaveLength(0);
expect(result.error).toBe(
'No rail connection found between the selected stations.'
);
});
});

185
frontend/src/utils/route.ts Normal file
View File

@@ -0,0 +1,185 @@
import type { Station, Track } from '../types/domain';
export interface NeighborEdge {
readonly neighborId: string;
readonly track: Track;
}
export type TrackAdjacency = Map<string, NeighborEdge[]>;
export interface ComputeRouteParams {
readonly startId?: string | null;
readonly endId?: string | null;
readonly stationById: Map<string, Station>;
readonly adjacency: TrackAdjacency;
}
export interface RouteComputation {
readonly stations: Station[] | null;
readonly tracks: Track[];
readonly totalLength: number | null;
readonly error: string | null;
}
export function buildTrackAdjacency(tracks: readonly Track[]): TrackAdjacency {
const adjacency: TrackAdjacency = new Map();
const register = (fromId: string, toId: string, track: Track) => {
if (!adjacency.has(fromId)) {
adjacency.set(fromId, []);
}
adjacency.get(fromId)!.push({ neighborId: toId, track });
};
for (const track of tracks) {
register(track.startStationId, track.endStationId, track);
register(track.endStationId, track.startStationId, track);
}
return adjacency;
}
export function computeRoute({
startId,
endId,
stationById,
adjacency,
}: ComputeRouteParams): RouteComputation {
if (!startId || !endId) {
return emptyResult();
}
if (!stationById.has(startId) || !stationById.has(endId)) {
return {
stations: null,
tracks: [],
totalLength: null,
error: 'Selected stations are no longer available.',
};
}
if (startId === endId) {
const station = stationById.get(startId);
return {
stations: station ? [station] : null,
tracks: [],
totalLength: 0,
error: null,
};
}
const visited = new Set<string>();
const queue: string[] = [];
const parent = new Map<string, { prev: string | null; via: Track | null }>();
queue.push(startId);
visited.add(startId);
parent.set(startId, { prev: null, via: null });
while (queue.length > 0) {
const current = queue.shift()!;
if (current === endId) {
break;
}
const neighbors = adjacency.get(current) ?? [];
for (const { neighborId, track } of neighbors) {
if (visited.has(neighborId)) {
continue;
}
visited.add(neighborId);
parent.set(neighborId, { prev: current, via: track });
queue.push(neighborId);
}
}
if (!parent.has(endId)) {
return {
stations: null,
tracks: [],
totalLength: null,
error: 'No rail connection found between the selected stations.',
};
}
const stationPath: string[] = [];
const trackSequence: Track[] = [];
let cursor: string | null = endId;
while (cursor) {
const details = parent.get(cursor);
if (!details) {
break;
}
stationPath.push(cursor);
if (details.via) {
trackSequence.push(details.via);
}
cursor = details.prev;
}
stationPath.reverse();
trackSequence.reverse();
const stations = stationPath
.map((id) => stationById.get(id))
.filter((station): station is Station => Boolean(station));
const totalLength = computeTotalLength(trackSequence, stations);
return {
stations,
tracks: trackSequence,
totalLength,
error: null,
};
}
function computeTotalLength(tracks: Track[], stations: Station[]): number | null {
if (tracks.length === 0 && stations.length <= 1) {
return 0;
}
const hasTrackLengths = tracks.every(
(track) =>
typeof track.lengthMeters === 'number' && Number.isFinite(track.lengthMeters)
);
if (hasTrackLengths) {
return tracks.reduce((total, track) => total + (track.lengthMeters ?? 0), 0);
}
if (stations.length < 2) {
return null;
}
let total = 0;
for (let index = 0; index < stations.length - 1; index += 1) {
total += haversineDistance(stations[index], stations[index + 1]);
}
return total;
}
function haversineDistance(a: Station, b: Station): number {
const R = 6371_000;
const toRad = (value: number) => (value * Math.PI) / 180;
const dLat = toRad(b.latitude - a.latitude);
const dLon = toRad(b.longitude - a.longitude);
const lat1 = toRad(a.latitude);
const lat2 = toRad(b.latitude);
const sinDLat = Math.sin(dLat / 2);
const sinDLon = Math.sin(dLon / 2);
const root = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon;
const c = 2 * Math.atan2(Math.sqrt(root), Math.sqrt(1 - root));
return R * c;
}
function emptyResult(): RouteComputation {
return {
stations: null,
tracks: [],
totalLength: null,
error: null,
};
}