Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/web/src/components/panels/transit/PlatformBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PlatformBadge } from "./PlatformBadge";

vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());

describe("PlatformBadge", () => {
it("shows a move from the planned platform as a change", () => {
render(<PlatformBadge code="4" scheduledCode="2" />);
expect(screen.getByText(/2 → 4/)).toBeTruthy();
});

it("shows sections added to the planned platform as the same platform", () => {
render(<PlatformBadge code="10 A-B" scheduledCode="10" />);
expect(screen.queryByText(/→/)).toBeNull();
expect(screen.getByText(/10 A-B/)).toBeTruthy();
});
});
7 changes: 4 additions & 3 deletions apps/web/src/components/panels/transit/PlatformBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import Box from "@mui/material/Box";
import { platformChanged } from "@openmapx/mobility-core/platform";
import { useTranslations } from "next-intl";

/**
Expand All @@ -23,7 +24,8 @@ export function PlatformBadge({
tone?: "surface" | "onBanner";
}) {
const t = useTranslations("transit");
const wasChanged = changed || (!!scheduledCode && scheduledCode !== code);
const moved = platformChanged(scheduledCode, code);
const wasChanged = changed || moved;
const surface = wasChanged
? { bgcolor: "warning.main", color: "warning.contrastText" }
: { bgcolor: "action.selected", color: "text.primary" };
Expand All @@ -47,8 +49,7 @@ export function PlatformBadge({
...(tone === "onBanner" ? onBanner : surface),
}}
>
{t("platform")}{" "}
{wasChanged && scheduledCode && scheduledCode !== code ? `${scheduledCode} → ${code}` : code}
{t("platform")} {moved ? `${scheduledCode} → ${code}` : code}
{wasChanged && ` (${t("platformChanged")})`}
</Box>
);
Expand Down
5 changes: 5 additions & 0 deletions docs/docs/features/public-transit.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ write the train number or their operator into the line's name ("DB S11
train that two sources, or one source twice, list under different spellings
shows as one departure.

When sources give one departure different platforms, the board shows the
platform most sources give: some timetables number a station's platforms
differently from its signs. A platform change one source reports is shown when
it moves the departure off the platform most sources plan.

### Understanding timetable status

**Scheduled time** identifies explicitly timetable-only service. A prediction
Expand Down
1 change: 1 addition & 0 deletions integrations/transit-db-vendo/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ export async function getTrip(tripId: string): Promise<VehicleJourney | null> {
lat: s.stop?.location?.latitude ?? 0,
lng: s.stop?.location?.longitude ?? 0,
platform: s.platform ?? s.plannedPlatform ?? undefined,
scheduledPlatform: s.plannedPlatform ?? undefined,
scheduledArrival: s.plannedArrival ?? undefined,
scheduledDeparture: s.plannedDeparture ?? undefined,
expectedArrival: s.arrival ?? undefined,
Expand Down
1 change: 1 addition & 0 deletions integrations/transit-dynamic-registry/hafas-mgate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ export const hafasMgateAdapter: ProtocolAdapter = {
lat: s.stop?.location?.latitude ?? 0,
lng: s.stop?.location?.longitude ?? 0,
platform: s.platform ?? s.plannedPlatform ?? undefined,
scheduledPlatform: s.plannedPlatform ?? undefined,
scheduledArrival: s.plannedArrival ?? undefined,
scheduledDeparture: s.plannedDeparture ?? undefined,
expectedArrival: s.arrival ?? undefined,
Expand Down
6 changes: 5 additions & 1 deletion integrations/transit-hafas/hafas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,8 @@ describe("getTrip", () => {
plannedDeparture: "2026-03-10T10:06:00+01:00",
arrival: "2026-03-10T10:05:00+01:00",
departure: "2026-03-10T10:06:00+01:00",
platform: "4",
platform: "5",
plannedPlatform: "4",
arrivalDelay: 60,
cancelled: false,
},
Expand Down Expand Up @@ -643,6 +644,9 @@ describe("getTrip", () => {
if (!stop1) throw new Error("stop1 was undefined");
expect(stop1.stopId).toBe("vbb:900000024101");
expect(stop1.delaySeconds).toBe(60);
// A platform change keeps the planned platform beside the current one.
expect(stop1.platform).toBe("5");
expect(stop1.scheduledPlatform).toBe("4");
});

it("strips vbb: prefix before API call", async () => {
Expand Down
1 change: 1 addition & 0 deletions integrations/transit-hafas/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ export async function getTrip(inst: HafasInstance, tripId: string): Promise<Vehi
lat: s.stop?.location?.latitude ?? 0,
lng: s.stop?.location?.longitude ?? 0,
platform: s.platform ?? s.plannedPlatform ?? undefined,
scheduledPlatform: s.plannedPlatform ?? undefined,
scheduledArrival: s.plannedArrival ?? undefined,
scheduledDeparture: s.plannedDeparture ?? undefined,
expectedArrival: s.arrival ?? undefined,
Expand Down
79 changes: 79 additions & 0 deletions integrations/transit/__tests__/merged-board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,85 @@ describe("merged place board", () => {
});
});

describe("the platform a merged row shows", () => {
const re1 = (id: string, platform?: string, scheduledPlatform?: string) =>
dep(id, "RE1", "Aachen Hbf", "17:08:00", { platform, scheduledPlatform });
const platformOf = (rows: Departure[]) =>
rows.map((row) => [row.platform, row.scheduledPlatform]);

it("shows the platform most sources give over the first source's numbering", async () => {
// Transitous passes on DELFI's internal numbers at Köln Messe/Deutz.
const rows = await board({
"mo:central": [re1("mo:x", "85", "85")],
"oebb:central": [re1("oebb:x", "2", "2")],
"kvb:central": [re1("kvb:x", "2", "2")],
});
expect(platformOf(rows)).toEqual([["2", "2"]]);
});

it("shows a platform change one source reports", async () => {
const rows = await board({
"mo:central": [re1("mo:x", "85", "85")],
"oebb:central": [re1("oebb:x", "2", "2")],
"kvb:central": [re1("kvb:x", "4", "2")],
});
expect(platformOf(rows)).toEqual([["4", "2"]]);
});

it("takes the scheduled platform from the source whose platform it shows", async () => {
// Pairing "85" scheduled with "2" current would show a change that isn't one.
const rows = await board({
"mo:central": [re1("mo:x", "85", "85")],
"rmv:central": [re1("rmv:x", "2")],
"vsn:central": [re1("vsn:x", "2")],
});
expect(platformOf(rows)).toEqual([["2", undefined]]);
});

it("doesn't show a change that is only one source's two numberings", async () => {
// Transitous can pair DELFI's planned "85" with the real current track.
const rows = await board({
"mo:central": [re1("mo:x", "2", "85")],
"oebb:central": [re1("oebb:x", "2", "2")],
});
expect(platformOf(rows)).toEqual([["2", "2"]]);
});

it("shows a change one source reports in its own numbering against the others' plan", async () => {
const rows = await board({
"mo:central": [re1("mo:x", "3", "85")],
"oebb:central": [re1("oebb:x", "2", "2")],
});
expect(platformOf(rows)).toEqual([["3", "2"]]);
});

it("doesn't show a change when a source only adds the platform's sections", async () => {
const rows = await board({
"mo:central": [re1("mo:x")],
"kvb:central": [re1("kvb:x", "10 A-B", "10")],
});
expect(platformOf(rows)).toEqual([["10 A-B", undefined]]);
});

it("counts a source once however many of its stops list the departure", async () => {
const rows = await board({
"mo:central": [re1("mo:x", "85", "85")],
"oebb:central": [re1("oebb:x", "2", "2")],
"oebb:station": [re1("oebb:x", "2", "2")],
"kvb:central": [re1("kvb:x", "85", "85")],
});
expect(platformOf(rows)).toEqual([["85", "85"]]);
});

it("keeps the first source's platform when sources are split evenly", async () => {
const rows = await board({
"mo:central": [re1("mo:x", "85", "85")],
"oebb:central": [re1("oebb:x", "2", "2")],
});
expect(platformOf(rows)).toEqual([["85", "85"]]);
});
});

describe("place stop linking", () => {
it("keeps a stop abbreviating the station name when another source spells it out", async () => {
const rows = await board(
Expand Down
103 changes: 97 additions & 6 deletions integrations/transit/place-transit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
snapshotContributors,
} from "@openmapx/mobility-core/contributors";
import type { Freshness } from "@openmapx/mobility-core/freshness";
import { platformChanged } from "@openmapx/mobility-core/platform";
import { TTL as TTL_POLICY } from "@openmapx/mobility-core/policy";
import type { MobilityResult } from "@openmapx/mobility-core/result";
import type {
Expand Down Expand Up @@ -157,6 +158,8 @@ interface FoldedDeparture {
headsign: string;
/** The planned platform, else the current one. */
platform?: string;
/** The departure as its source listed it. */
departure: Departure;
}

/**
Expand All @@ -175,6 +178,96 @@ function repeats(part: FoldedDeparture, dep: Departure): boolean {
);
}

/**
* The value `stated` by the most sources, counting each source once. A tie goes to
* the value more sources state by `tieBreak`, then to the first source's.
*/
function mostStated(
parts: readonly FoldedDeparture[],
stated: (departure: Departure) => string | undefined,
tieBreak?: (value: string) => number,
): { value: string; departure: Departure } | undefined {
const sources = new Map<string, Set<string>>();
for (const { departure, provider } of parts) {
const value = stated(departure);
if (!value) continue;
const stating = sources.get(value) ?? new Set<string>();
stating.add(provider);
sources.set(value, stating);
}
let best: { value: string; departure: Departure; score: [number, number] } | undefined;
for (const { departure } of parts) {
const value = stated(departure);
if (!value) continue;
const score: [number, number] = [sources.get(value)?.size ?? 0, tieBreak?.(value) ?? 0];
if (
!best ||
score[0] > best.score[0] ||
(score[0] === best.score[0] && score[1] > best.score[1])
)
best = { value, departure, score };
}
return best && { value: best.value, departure: best.departure };
}

/**
* Set the platform a merged row shows. Sources number platforms differently:
* at Köln Messe/Deutz, Transitous passes on DELFI's internal numbers ("85")
* while the HAFAS sources give the track riders see ("2"), and for some trains
* Transitous pairs DELFI's planned number with the real current track.
*
* The planned platform is the one the most sources plan. A source's platform
* change counts when it moves the departure off both its own plan and that
* one, because a source that only knows the plan can't report it. Otherwise
* the current platform is the one the most sources give. A tie keeps the
* first source's.
*/
function choosePlatform(row: MergedDeparture, parts: readonly FoldedDeparture[]): void {
const stated = parts.filter((part) => part.departure.platform);
if (stated.length === 0) return;
const statingCurrent = (value: string) =>
new Set(stated.filter((part) => part.departure.platform === value).map((p) => p.provider)).size;
const plan = mostStated(stated, (d) => d.scheduledPlatform ?? d.platform, statingCurrent);
const changed = stated.filter(
({ departure: d }) =>
platformChanged(d.scheduledPlatform, d.platform) && platformChanged(plan?.value, d.platform),
);
const current = mostStated(changed.length > 0 ? changed : stated, (d) => d.platform);
if (!current) return;
// Without a change, keep a scheduled platform only where a source states it.
const scheduled =
changed.length > 0
? plan
: stated
.map(({ departure }) => departure)
.filter((d) => d.scheduledPlatform === current.value)
.map((departure) => ({ value: current.value, departure }))[0];
if (row.platform !== current.value) {
retainFieldReceipt(row, "platform", contributorsForField(current.departure, "platform"));
row.platform = current.value;
row.contributors = snapshotContributors(
row.contributors ?? [],
current.departure.contributors ?? [],
);
}
if (row.scheduledPlatform === scheduled?.value) return;
if (scheduled) {
retainFieldReceipt(
row,
"scheduledPlatform",
contributorsForField(scheduled.departure, "scheduledPlatform"),
);
row.scheduledPlatform = scheduled.value;
row.contributors = snapshotContributors(
row.contributors ?? [],
scheduled.departure.contributors ?? [],
);
} else {
retainFieldReceipt(row, "scheduledPlatform", []);
delete row.scheduledPlatform;
}
}

/** Extended Departure with optional feedTag (set by some providers like MOTIS). */
interface DepartureWithFeed extends Departure {
feedTag?: string;
Expand Down Expand Up @@ -665,7 +758,8 @@ export function createPlaceTransit(
* one row per departure.
*
* Each row names the stop it leaves from, and is marked when that is not the
* place's main stop (see {@link nameRowStops}).
* place's main stop (see {@link nameRowStops}), and the platform most likely
* right (see {@link choosePlatform}).
*
* Not cached — caller refetches every 30s for real-time data.
*/
Expand Down Expand Up @@ -756,11 +850,6 @@ export function createPlaceTransit(
contributed = true;
existing.delaySeconds = dep.delaySeconds;
}
if (!existing.platform && dep.platform) {
retainFieldReceipt(existing, "platform", contributorsForField(dep, "platform"));
existing.platform = dep.platform;
contributed = true;
}
if (dep.canceled && !existing.canceled) {
retainFieldReceipt(existing, "canceled", contributorsForField(dep, "canceled"));
existing.canceled = true;
Expand Down Expand Up @@ -841,6 +930,7 @@ export function createPlaceTransit(
line: dep.route.shortName,
headsign: dep.headsign,
platform: dep.scheduledPlatform ?? dep.platform,
departure: dep,
};
if (!existing) {
const entry: MergedDeparture = {
Expand All @@ -861,6 +951,7 @@ export function createPlaceTransit(
}
}

for (const row of rows) choosePlatform(row, folded.get(row) ?? []);
nameRowStops(
rows.map((row) => ({ row, stops: (folded.get(row) ?? []).map((part) => part.stop) })),
placeName,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/navigation/processTransitFix.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { platformChanged } from "@openmapx/mobility-core/platform";
import type { TransitStep, TripItinerary } from "@openmapx/mobility-core/transit";
import type { TransitLegCapture } from "./mobileProtocol";
import { snapPreparedRoute } from "./routeMatcher";
Expand Down Expand Up @@ -416,8 +417,7 @@ export function processTransitFix(input: TransitTickInput): TransitTickResult {
const changedPlatform = capture?.stops[0];
if (
changedPlatform?.platform &&
changedPlatform.scheduledPlatform &&
changedPlatform.platform !== changedPlatform.scheduledPlatform
platformChanged(changedPlatform.scheduledPlatform, changedPlatform.platform)
) {
emit({
id: eventId(
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/utils/fptf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,30 @@ describe("normalizeFptfDeparture line name", () => {
expect(dep.serviceInfo).toBeUndefined();
});
});

describe("normalizeFptfDeparture platform", () => {
const line = { name: "RE1", product: "regional" };

it("shows the platform a train was moved to and keeps the planned one", () => {
// hafas-client: `platform` is the prognosed platform, else the planned one.
const dep = normalizeFptfDeparture({ line, platform: "4", plannedPlatform: "2" }, "db:");
expect(dep.platform).toBe("4");
expect(dep.scheduledPlatform).toBe("2");
});

it("shows the planned platform when nothing changed", () => {
const dep = normalizeFptfDeparture({ line, platform: "2", plannedPlatform: "2" }, "db:");
expect(dep.platform).toBe("2");
expect(dep.scheduledPlatform).toBe("2");
});

it("keeps the planned platform of a cancelled departure", () => {
// hafas-client sets `platform: null` when a departure is cancelled.
const dep = normalizeFptfDeparture(
{ line, cancelled: true, platform: null, plannedPlatform: "2" },
"db:",
);
expect(dep.platform).toBe("2");
expect(dep.canceled).toBe(true);
});
});
5 changes: 4 additions & 1 deletion packages/core/src/utils/fptf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,10 @@ export function normalizeFptfDeparture(d: any, prefix: string): Departure {
scheduledAt,
expectedAt,
delaySeconds,
platform: d.plannedPlatform ?? d.platform ?? undefined,
// hafas-client's `platform` is the current platform: the changed one when
// the source reports a change, else the planned one.
platform: d.platform ?? d.plannedPlatform ?? undefined,
scheduledPlatform: d.plannedPlatform ?? undefined,
canceled: d.cancelled ?? false,
occupancy: mapFptfLoadFactor(d.loadFactor),
remarks: normalizeRemarks(d.remarks),
Expand Down
Loading
Loading