diff --git a/apps/web/src/components/panels/transit/PlatformBadge.test.tsx b/apps/web/src/components/panels/transit/PlatformBadge.test.tsx
new file mode 100644
index 000000000..6110d1a30
--- /dev/null
+++ b/apps/web/src/components/panels/transit/PlatformBadge.test.tsx
@@ -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();
+ expect(screen.getByText(/2 → 4/)).toBeTruthy();
+ });
+
+ it("shows sections added to the planned platform as the same platform", () => {
+ render();
+ expect(screen.queryByText(/→/)).toBeNull();
+ expect(screen.getByText(/10 A-B/)).toBeTruthy();
+ });
+});
diff --git a/apps/web/src/components/panels/transit/PlatformBadge.tsx b/apps/web/src/components/panels/transit/PlatformBadge.tsx
index 6d79fb1c9..2903398f6 100644
--- a/apps/web/src/components/panels/transit/PlatformBadge.tsx
+++ b/apps/web/src/components/panels/transit/PlatformBadge.tsx
@@ -1,6 +1,7 @@
"use client";
import Box from "@mui/material/Box";
+import { platformChanged } from "@openmapx/mobility-core/platform";
import { useTranslations } from "next-intl";
/**
@@ -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" };
@@ -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")})`}
);
diff --git a/docs/docs/features/public-transit.md b/docs/docs/features/public-transit.md
index c778a6d02..8be8cacd5 100644
--- a/docs/docs/features/public-transit.md
+++ b/docs/docs/features/public-transit.md
@@ -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
diff --git a/integrations/transit-db-vendo/provider.ts b/integrations/transit-db-vendo/provider.ts
index cd66f9195..769244791 100644
--- a/integrations/transit-db-vendo/provider.ts
+++ b/integrations/transit-db-vendo/provider.ts
@@ -535,6 +535,7 @@ export async function getTrip(tripId: string): Promise {
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,
diff --git a/integrations/transit-dynamic-registry/hafas-mgate.ts b/integrations/transit-dynamic-registry/hafas-mgate.ts
index 2ff59cbf4..3aa9c5b83 100644
--- a/integrations/transit-dynamic-registry/hafas-mgate.ts
+++ b/integrations/transit-dynamic-registry/hafas-mgate.ts
@@ -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,
diff --git a/integrations/transit-hafas/hafas.test.ts b/integrations/transit-hafas/hafas.test.ts
index 07a6de4b4..df01fabd6 100644
--- a/integrations/transit-hafas/hafas.test.ts
+++ b/integrations/transit-hafas/hafas.test.ts
@@ -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,
},
@@ -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 () => {
diff --git a/integrations/transit-hafas/provider.ts b/integrations/transit-hafas/provider.ts
index d043279df..a1ae77fda 100644
--- a/integrations/transit-hafas/provider.ts
+++ b/integrations/transit-hafas/provider.ts
@@ -355,6 +355,7 @@ export async function getTrip(inst: HafasInstance, tripId: string): Promise {
});
});
+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(
diff --git a/integrations/transit/place-transit.ts b/integrations/transit/place-transit.ts
index 5823c0c33..98abdbc89 100644
--- a/integrations/transit/place-transit.ts
+++ b/integrations/transit/place-transit.ts
@@ -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 {
@@ -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;
}
/**
@@ -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>();
+ for (const { departure, provider } of parts) {
+ const value = stated(departure);
+ if (!value) continue;
+ const stating = sources.get(value) ?? new Set();
+ 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;
@@ -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.
*/
@@ -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;
@@ -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 = {
@@ -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,
diff --git a/packages/core/src/navigation/processTransitFix.ts b/packages/core/src/navigation/processTransitFix.ts
index af1f5c067..db3da6b29 100644
--- a/packages/core/src/navigation/processTransitFix.ts
+++ b/packages/core/src/navigation/processTransitFix.ts
@@ -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";
@@ -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(
diff --git a/packages/core/src/utils/fptf.test.ts b/packages/core/src/utils/fptf.test.ts
index 115582b0e..8ef38f454 100644
--- a/packages/core/src/utils/fptf.test.ts
+++ b/packages/core/src/utils/fptf.test.ts
@@ -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);
+ });
+});
diff --git a/packages/core/src/utils/fptf.ts b/packages/core/src/utils/fptf.ts
index bf6d7505d..c2b639436 100644
--- a/packages/core/src/utils/fptf.ts
+++ b/packages/core/src/utils/fptf.ts
@@ -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),
diff --git a/packages/mobility-core/package.json b/packages/mobility-core/package.json
index d850f7847..7920d9f3f 100644
--- a/packages/mobility-core/package.json
+++ b/packages/mobility-core/package.json
@@ -21,6 +21,10 @@
"types": "./src/line-name.ts",
"default": "./src/line-name.ts"
},
+ "./platform": {
+ "types": "./src/platform.ts",
+ "default": "./src/platform.ts"
+ },
"./camera": {
"types": "./src/types/camera.ts",
"default": "./src/types/camera.ts"
diff --git a/packages/mobility-core/src/platform.test.ts b/packages/mobility-core/src/platform.test.ts
new file mode 100644
index 000000000..b7152f272
--- /dev/null
+++ b/packages/mobility-core/src/platform.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+import { platformChanged } from "./platform";
+
+describe("platformChanged", () => {
+ it("reports a move to another platform", () => {
+ expect(platformChanged("2", "4")).toBe(true);
+ expect(platformChanged("1 A-C", "2 A-C")).toBe(true);
+ expect(platformChanged("10", "1")).toBe(true);
+ });
+
+ it("doesn't report the platform's sections being added or dropped", () => {
+ expect(platformChanged("10", "10 A-B")).toBe(false);
+ expect(platformChanged("10 A-B", "10")).toBe(false);
+ });
+
+ it("doesn't report a change without both platforms", () => {
+ expect(platformChanged(undefined, "4")).toBe(false);
+ expect(platformChanged("2", undefined)).toBe(false);
+ expect(platformChanged("2", " 2 ")).toBe(false);
+ });
+});
diff --git a/packages/mobility-core/src/platform.ts b/packages/mobility-core/src/platform.ts
new file mode 100644
index 000000000..3a27cd6b2
--- /dev/null
+++ b/packages/mobility-core/src/platform.ts
@@ -0,0 +1,12 @@
+/**
+ * Whether a departure's current platform differs from its planned one.
+ * Sources can add a platform's sections once they know where the train stops:
+ * KVB plans "10" and later gives "10 A-B". That narrows the plan rather than
+ * moving the departure, so it is no change.
+ */
+export function platformChanged(planned: string | undefined, current: string | undefined): boolean {
+ const from = planned?.trim();
+ const to = current?.trim();
+ if (!from || !to || from === to) return false;
+ return !to.startsWith(`${from} `) && !from.startsWith(`${to} `);
+}