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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user