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} `); +}