feat: Enhance track model and import functionality

- 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.
This commit is contained in:
2025-10-11 19:54:10 +02:00
parent 090dca29c2
commit c2927f2f60
18 changed files with 968 additions and 52 deletions

View File

@@ -68,21 +68,22 @@ function App(): JSX.Element {
[data]
);
const routeComputation = useMemo(() => {
const core = computeRoute({
startId: routeSelection.startId,
endId: routeSelection.endId,
stationById,
adjacency: trackAdjacency,
});
const routeComputation = useMemo(
() =>
computeRoute({
startId: routeSelection.startId,
endId: routeSelection.endId,
stationById,
adjacency: trackAdjacency,
}),
[routeSelection, stationById, trackAdjacency]
);
const segments = core.stations ? buildSegmentsFromStations(core.stations) : [];
return {
...core,
segments,
};
}, [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) {
@@ -144,7 +145,7 @@ function App(): JSX.Element {
focusedStationId={focusedStationId}
startStationId={routeSelection.startId}
endStationId={routeSelection.endId}
routeSegments={routeComputation.segments}
routeSegments={routeSegments}
onStationClick={handleStationSelection}
/>
</div>
@@ -267,7 +268,9 @@ function App(): JSX.Element {
{data.tracks.map((track) => (
<li key={track.id}>
{track.startStationId} {track.endStationId} ·{' '}
{(track.lengthMeters / 1000).toFixed(1)} km
{track.lengthMeters > 0
? `${(track.lengthMeters / 1000).toFixed(1)} km`
: 'N/A'}
</li>
))}
</ul>
@@ -329,16 +332,3 @@ 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

@@ -57,6 +57,12 @@ export function NetworkMap({
const trackSegments = useMemo(() => {
return snapshot.tracks
.map((track) => {
if (track.coordinates && track.coordinates.length >= 2) {
return track.coordinates.map(
(pair) => [pair[0], pair[1]] as LatLngExpression
);
}
const start = stationLookup.get(track.startStationId);
const end = stationLookup.get(track.endStationId);
if (!start || !end) {

View File

@@ -22,6 +22,9 @@ export interface Track extends Identified {
readonly endStationId: string;
readonly lengthMeters: number;
readonly maxSpeedKph: number;
readonly status?: string | null;
readonly isBidirectional?: boolean;
readonly coordinates: readonly [number, number][];
}
export interface Train extends Identified {

View File

@@ -48,6 +48,11 @@ describe('route utilities', () => {
endStationId: 'station-b',
lengthMeters: 1200,
maxSpeedKph: 120,
coordinates: [
[51.5, -0.1],
[51.51, -0.105],
[51.52, -0.11],
],
...baseTimestamps,
},
{
@@ -56,6 +61,11 @@ describe('route utilities', () => {
endStationId: 'station-c',
lengthMeters: 1500,
maxSpeedKph: 110,
coordinates: [
[51.52, -0.11],
[51.53, -0.115],
[51.54, -0.12],
],
...baseTimestamps,
},
{
@@ -64,6 +74,11 @@ describe('route utilities', () => {
endStationId: 'station-d',
lengthMeters: 900,
maxSpeedKph: 115,
coordinates: [
[51.54, -0.12],
[51.545, -0.13],
[51.55, -0.15],
],
...baseTimestamps,
},
];
@@ -91,6 +106,9 @@ describe('route utilities', () => {
'track-cd',
]);
expect(result.totalLength).toBe(1200 + 1500 + 900);
expect(result.segments).toHaveLength(3);
expect(result.segments[0][0]).toEqual([51.5, -0.1]);
expect(result.segments[2][result.segments[2].length - 1]).toEqual([51.55, -0.15]);
});
it('returns an error when no path exists', () => {
@@ -118,6 +136,10 @@ describe('route utilities', () => {
endStationId: 'station-a',
lengthMeters: 0,
maxSpeedKph: 80,
coordinates: [
[51.5, -0.1],
[51.5005, -0.1005],
],
...baseTimestamps,
},
];
@@ -137,5 +159,58 @@ describe('route utilities', () => {
expect(result.error).toBe(
'No rail connection found between the selected stations.'
);
expect(result.segments).toHaveLength(0);
});
it('reverses track geometry when traversing in the opposite direction', () => {
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,
},
];
const tracks: Track[] = [
{
id: 'track-ab',
startStationId: 'station-a',
endStationId: 'station-b',
lengthMeters: 1200,
maxSpeedKph: 120,
coordinates: [
[51.5, -0.1],
[51.52, -0.11],
],
...baseTimestamps,
},
];
const stationById = new Map(stations.map((station) => [station.id, station]));
const adjacency = buildTrackAdjacency(tracks);
const result = computeRoute({
startId: 'station-b',
endId: 'station-a',
stationById,
adjacency,
});
expect(result.error).toBeNull();
expect(result.segments).toEqual([
[
[51.52, -0.11],
[51.5, -0.1],
],
]);
});
});

View File

@@ -1,8 +1,11 @@
import type { Station, Track } from '../types/domain';
export type LatLngTuple = readonly [number, number];
export interface NeighborEdge {
readonly neighborId: string;
readonly track: Track;
readonly isForward: boolean;
}
export type TrackAdjacency = Map<string, NeighborEdge[]>;
@@ -19,21 +22,22 @@ export interface RouteComputation {
readonly tracks: Track[];
readonly totalLength: number | null;
readonly error: string | null;
readonly segments: LatLngTuple[][];
}
export function buildTrackAdjacency(tracks: readonly Track[]): TrackAdjacency {
const adjacency: TrackAdjacency = new Map();
const register = (fromId: string, toId: string, track: Track) => {
const register = (fromId: string, toId: string, track: Track, isForward: boolean) => {
if (!adjacency.has(fromId)) {
adjacency.set(fromId, []);
}
adjacency.get(fromId)!.push({ neighborId: toId, track });
adjacency.get(fromId)!.push({ neighborId: toId, track, isForward });
};
for (const track of tracks) {
register(track.startStationId, track.endStationId, track);
register(track.endStationId, track.startStationId, track);
register(track.startStationId, track.endStationId, track, true);
register(track.endStationId, track.startStationId, track, false);
}
return adjacency;
@@ -55,6 +59,7 @@ export function computeRoute({
tracks: [],
totalLength: null,
error: 'Selected stations are no longer available.',
segments: [],
};
}
@@ -65,16 +70,17 @@ export function computeRoute({
tracks: [],
totalLength: 0,
error: null,
segments: [],
};
}
const visited = new Set<string>();
const queue: string[] = [];
const parent = new Map<string, { prev: string | null; via: Track | null }>();
const parent = new Map<string, { prev: string | null; edge: NeighborEdge | null }>();
queue.push(startId);
visited.add(startId);
parent.set(startId, { prev: null, via: null });
parent.set(startId, { prev: null, edge: null });
while (queue.length > 0) {
const current = queue.shift()!;
@@ -83,12 +89,13 @@ export function computeRoute({
}
const neighbors = adjacency.get(current) ?? [];
for (const { neighborId, track } of neighbors) {
for (const edge of neighbors) {
const { neighborId } = edge;
if (visited.has(neighborId)) {
continue;
}
visited.add(neighborId);
parent.set(neighborId, { prev: current, via: track });
parent.set(neighborId, { prev: current, edge });
queue.push(neighborId);
}
}
@@ -99,11 +106,13 @@ export function computeRoute({
tracks: [],
totalLength: null,
error: 'No rail connection found between the selected stations.',
segments: [],
};
}
const stationPath: string[] = [];
const trackSequence: Track[] = [];
const directions: boolean[] = [];
let cursor: string | null = endId;
while (cursor) {
@@ -112,19 +121,22 @@ export function computeRoute({
break;
}
stationPath.push(cursor);
if (details.via) {
trackSequence.push(details.via);
if (details.edge) {
trackSequence.push(details.edge.track);
directions.push(details.edge.isForward);
}
cursor = details.prev;
}
stationPath.reverse();
trackSequence.reverse();
directions.reverse();
const stations = stationPath
.map((id) => stationById.get(id))
.filter((station): station is Station => Boolean(station));
const segments = buildSegments(trackSequence, directions, stationById);
const totalLength = computeTotalLength(trackSequence, stations);
return {
@@ -132,9 +144,50 @@ export function computeRoute({
tracks: trackSequence,
totalLength,
error: null,
segments,
};
}
function buildSegments(
tracks: Track[],
directions: boolean[],
stationById: Map<string, Station>
): LatLngTuple[][] {
const segments: LatLngTuple[][] = [];
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
const isForward = directions[index] ?? true;
const coordinates = extractTrackCoordinates(track, stationById);
if (coordinates.length < 2) {
continue;
}
segments.push(isForward ? coordinates : [...coordinates].reverse());
}
return segments;
}
function extractTrackCoordinates(
track: Track,
stationById: Map<string, Station>
): LatLngTuple[] {
if (Array.isArray(track.coordinates) && track.coordinates.length >= 2) {
return track.coordinates.map((pair) => [pair[0], pair[1]] as LatLngTuple);
}
const start = stationById.get(track.startStationId);
const end = stationById.get(track.endStationId);
if (!start || !end) {
return [];
}
return [
[start.latitude, start.longitude],
[end.latitude, end.longitude],
];
}
function computeTotalLength(tracks: Track[], stations: Station[]): number | null {
if (tracks.length === 0 && stations.length <= 1) {
return 0;
@@ -181,5 +234,6 @@ function emptyResult(): RouteComputation {
tracks: [],
totalLength: null,
error: null,
segments: [],
};
}