Implement journey ranking and improve UI presentation

Add `rankJourneys` utility to score connections based on arrival fit,
directness, and duration. Update `JourneyList` to display the best option
first with a "Top" badge and allow expanding to see more. Fix HAFAS
parser to include train direction in journey details. Limit API result
count to 5 and add TypeScript declaration for PNG assets.
This commit is contained in:
2026-05-14 17:05:56 +02:00
parent 0501d66fdc
commit 04e793cbb8
10 changed files with 168 additions and 18 deletions
+5 -1
View File
@@ -37,7 +37,11 @@ export function parseHafasJourneys(json: RawJson, hafasDate: string, queryDate:
const delay = Math.max(0, Math.round((rD.getTime() - sD.getTime()) / 60000));
const trains: string[] = (con.secL ?? [])
.filter((s: RawJson) => s.jny)
.map((s: RawJson) => s.jny?.stopL?.[0]?.name ?? "")
.map((s: RawJson) => {
const name = s.jny?.stopL?.[0]?.name ?? "";
const direction = s.jny?.dirTxt ?? s.jny?.dir ?? "";
return name && direction ? `${name} -> ${direction}` : name;
})
.filter(Boolean);
return {
+1
View File
@@ -8,3 +8,4 @@ export * from './status-utils';
export * from './hafas-time';
export * from './hafas-parser';
export * from './defaults';
export * from './journey-scoring';
+61
View File
@@ -0,0 +1,61 @@
import type { Journey } from "./types";
export interface RankedJourney {
journey: Journey;
score: number;
}
function durationMinutes(journey: Journey): number {
return Math.max(0, (journey.rA.getTime() - journey.rD.getTime()) / 60_000);
}
function arrivalDeltaMinutes(journey: Journey, targetArrivalTime: Date, finalLegDurationMs: number): number {
const finalArrival = journey.rA.getTime() + finalLegDurationMs;
return Math.abs(finalArrival - targetArrivalTime.getTime()) / 60_000;
}
/**
* Rank journeys by arrival fit, transfer count, and duration.
*
* Arrival fit is weighted highest because users asked for connections close to
* the planned arrival. Direct connections are favored next, then shorter trips.
* Late arrivals are heavily penalized so an on-time option usually wins.
*/
export function rankJourneys(
journeys: Journey[],
targetArrivalTime: Date,
finalLegDurationMs = 0,
): RankedJourney[] {
const durations = journeys.map(durationMinutes);
const minDuration = Math.min(...durations);
const maxDuration = Math.max(...durations);
const durationRange = Math.max(1, maxDuration - minDuration);
return journeys
.map((journey): RankedJourney => {
const finalArrivalMs = journey.rA.getTime() + finalLegDurationMs;
const targetArrivalMs = targetArrivalTime.getTime();
const deltaMinutes = Math.abs(finalArrivalMs - targetArrivalMs) / 60_000;
const lateMinutes = Math.max(0, (finalArrivalMs - targetArrivalMs) / 60_000);
const duration = durationMinutes(journey);
const arrivalScore = Math.max(0, 100 - deltaMinutes * 4) - lateMinutes * 10;
const changesScore = Math.max(0, 100 - journey.changes * 35);
const durationScore = 100 - ((duration - minDuration) / durationRange) * 100;
const cancelledPenalty = journey.cancelled ? 1_000 : 0;
const score = arrivalScore * 0.55 + changesScore * 0.3 + durationScore * 0.15 - cancelledPenalty;
return { journey, score };
})
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const arrivalTie = arrivalDeltaMinutes(a.journey, targetArrivalTime, finalLegDurationMs)
- arrivalDeltaMinutes(b.journey, targetArrivalTime, finalLegDurationMs);
if (arrivalTie !== 0) return arrivalTie;
if (a.journey.changes !== b.journey.changes) return a.journey.changes - b.journey.changes;
return durationMinutes(a.journey) - durationMinutes(b.journey);
});
}
+1
View File
@@ -36,6 +36,7 @@ export interface Journey {
delay: number; // delay in minutes
platform: string;
changes: number;
/** Train legs, optionally including direction text when supplied by HAFAS. */
trains: string[];
cancelled: boolean;
}