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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user