diff --git a/docs/docs/features/public-transit.md b/docs/docs/features/public-transit.md index 8be8cacd5..0524d3128 100644 --- a/docs/docs/features/public-transit.md +++ b/docs/docs/features/public-transit.md @@ -78,6 +78,15 @@ place's stops get the same warning. Lines, alerts and facilities that cannot be loaded are left out rather than kept as "none", and are loaded again on a later visit or within about a minute. +A place's board includes the stops within 1 km whose name is the place's name. +Sources spell it differently, with or without the town ("Neumarkt", "Köln +Neumarkt", "Marienplatz, München"). It also includes stops whose name says +they belong to the station, such as "Köln Dom/Hbf" at Köln Hauptbahnhof or +"S+U Alexanderplatz Bhf/Gontardstr." at Alexanderplatz. A stop that only shares +the district is left out: "Köln Deutz Suevenstr." is not on the board for +"Köln Messe/Deutz". Some bus stops beside a station are left out too, when +their name doesn't say they belong to it. + A place's stops can have different names, for example a main station and a bus stop beside it. A departure from a stop other than the place's main one shows that stop's name next to its line, so you know where to board. When you open a diff --git a/integrations/transit/__tests__/merged-board.test.ts b/integrations/transit/__tests__/merged-board.test.ts index 55ac0d518..43b129ff3 100644 --- a/integrations/transit/__tests__/merged-board.test.ts +++ b/integrations/transit/__tests__/merged-board.test.ts @@ -392,7 +392,7 @@ describe("the stop a merged row leaves from", () => { "ms:bf": [dep("ms:t1", "RE1", "Aachen Hbf", "10:00:00")], "ms:bus": [dep("ms:u1", "150", "Chorweiler", "10:01:00")], }, - { "ms:bf": "Köln Messe/Deutz Bf", "ms:bus": "Köln Messe/Deutz Ottoplatz Bussteig" }, + { "ms:bf": "Köln Messe/Deutz Bf", "ms:bus": "Köln Bf Deutz/Messe Ottoplatz Bussteig" }, "Köln Messe/Deutz", ); expect(rows.map((row) => row.atOtherStop ?? false)).toEqual([false, true]); diff --git a/integrations/transit/__tests__/place-link-names.test.ts b/integrations/transit/__tests__/place-link-names.test.ts index 09c7ffbba..494cf6bfb 100644 --- a/integrations/transit/__tests__/place-link-names.test.ts +++ b/integrations/transit/__tests__/place-link-names.test.ts @@ -18,7 +18,7 @@ const station: TransitStop[] = [ ]; /** A board over a cache that keeps what it is given, and a stop search returning `station`. */ -function setup(options: PlaceTransitOptions = {}) { +function setup(options: PlaceTransitOptions = {}, stops: TransitStop[] = station) { const values = new Map(); const ctx = { cache: { @@ -33,7 +33,7 @@ function setup(options: PlaceTransitOptions = {}) { }, } as unknown as IntegrationContext; const search = vi.fn(async () => ({ - data: station, + data: stops, attributions: [], freshness: freshnessNow(), })); @@ -95,3 +95,139 @@ describe("names that link a place's stops", () => { expect(search.mock.calls.length).toBeGreaterThan(calls); }); }); + +/** A stop `meters` north of (lat, lng): 0.001° of latitude is about 111 m. */ +function stopAt(id: string, name: string, lat: number, lng: number, meters = 100): TransitStop { + return { id, provider: id.split(":")[0], name, lat: lat + meters / 111_000, lng, modes: ["bus"] }; +} + +async function linkedNames(place: { lat: number; lng: number; name: string }, names: string[]) { + const stops = names.map((name, i) => stopAt(`p:${i}`, name, place.lat, place.lng)); + const { transit } = setup({}, stops); + const linked = await transit.getLinkedStops(place); + return linked.data.map((stop) => stop.name); +} + +describe("which stops a place's name links", () => { + // Names live providers give stops within 1 km of Köln Messe/Deutz. + const deutz = { lat: 50.940833, lng: 6.975, name: "Köln Messe/Deutz" }; + + it("links the station under each source's spelling", async () => { + const station = [ + "Köln Messe/Deutz", + "Köln Messe/Deutz Bf", + "Köln Messe/Deutz Bahnhof", + "Messe/Deutz Bahnhof, Köln", + "Messe/Deutz", + "Köln Messe/Deutz Gl. 11-12", + ]; + expect(await linkedNames(deutz, station)).toEqual(station); + }); + + it("links a stop named as the station's own", async () => { + const own = ["Köln Bf Deutz/Messe LANXESS arena", "Köln, Deutz Bf Deutz/Messe LANXESS arena"]; + expect(await linkedNames(deutz, own)).toEqual(own); + }); + + it("doesn't link stops that only share the district or the town", async () => { + const others = [ + "Köln Deutz Suevenstr.", + "Köln, Deutz Gummersbacher Straße", + "Köln, Deutz Deutzer Freiheit", + "Köln Deutz Technische Hochschule", + "Köln Deutz Koelnmesse", + "Köln Bf Deutz/Messeplatz", + "KÖLN", + ]; + expect(await linkedNames(deutz, others)).toEqual([]); + }); + + it("links a stop named with or without its town", async () => { + const neumarkt = { lat: 50.9364, lng: 6.9469, name: "Neumarkt" }; + expect( + await linkedNames(neumarkt, ["Köln Neumarkt", "Neumarkt", "Köln Neumarkt Galerie"]), + ).toEqual(["Köln Neumarkt", "Neumarkt"]); + const friedrichstrasse = { lat: 52.5203, lng: 13.3869, name: "Berlin Friedrichstraße" }; + expect( + await linkedNames(friedrichstrasse, [ + "S+U Friedrichstr. Bhf (Berlin)", + "Friedrichstr./Reinhardtstr. (Berlin)", + ]), + ).toEqual(["S+U Friedrichstr. Bhf (Berlin)"]); + }); + + it("links a stop whose town a comma sets apart, but not another stop of that district", async () => { + const pasing = { lat: 48.1497, lng: 11.4614, name: "München-Pasing" }; + expect( + await linkedNames(pasing, [ + "Pasing, München", + "Pasing Bahnhof, München", + "Krankenhaus Pasing, München", + "Rathaus Pasing, München", + ]), + ).toEqual(["Pasing, München", "Pasing Bahnhof, München"]); + const marienplatz = { lat: 48.1372, lng: 11.5755, name: "Marienplatz" }; + expect(await linkedNames(marienplatz, ["Marienplatz, München"])).toEqual([ + "Marienplatz, München", + ]); + }); + + it("links a station named for its side of town", async () => { + const ost = { lat: 48.1274, lng: 11.6047, name: "München Ost" }; + expect( + await linkedNames(ost, [ + "Ostbahnhof, München", + "München Ostbahnhof (Orleansplatz)", + "München Ostfriedhof", + ]), + ).toEqual(["Ostbahnhof, München", "München Ostbahnhof (Orleansplatz)"]); + }); + + it("links the stops a main station's name marks as its own", async () => { + const hbf = { lat: 50.9432, lng: 6.9586, name: "Köln Hauptbahnhof" }; + expect( + await linkedNames(hbf, ["Köln Hbf", "Köln Dom/Hbf", "Köln Breslauer Platz/Hbf", "Köln Dom"]), + ).toEqual(["Köln Hbf", "Köln Dom/Hbf", "Köln Breslauer Platz/Hbf"]); + const alex = { lat: 52.5219, lng: 13.4132, name: "Berlin Alexanderplatz" }; + expect( + await linkedNames(alex, [ + "S+U Alexanderplatz Bhf/Gontardstr. (Berlin)", + "U Alexanderplatz/Karl-Liebknecht-Str. (Berlin)", + ]), + ).toEqual(["S+U Alexanderplatz Bhf/Gontardstr. (Berlin)"]); + }); + + it("links only the station of a place named like its town", async () => { + const homburg = { lat: 50.2193, lng: 8.6211, name: "Bad Homburg" }; + expect( + await linkedNames(homburg, ["Bad Homburg Bahnhof", "Bad Homburg Kurhaus", "Bad Homburg"]), + ).toEqual(["Bad Homburg Bahnhof", "Bad Homburg"]); + }); + + it("doesn't read a comma as setting the town apart when the place is a town", async () => { + const bruehl = { lat: 50.8285, lng: 6.9036, name: "Brühl" }; + expect( + await linkedNames(bruehl, [ + "Brühl Bf", + "Brühl Mitte", + "Brühl Nord", + "Brühl Am Stern", + "Brühl, Giesler Galerie", + "Rathaus, Brühl", + ]), + ).toEqual(["Brühl Bf"]); + }); + + it("keeps different places with a shared last word apart", async () => { + const tor = { lat: 52.4991, lng: 13.4181, name: "Kottbusser Tor" }; + expect(await linkedNames(tor, ["U Kottbusser Tor (Berlin)", "Schlesisches Tor"])).toEqual([ + "U Kottbusser Tor (Berlin)", + ]); + }); + + it("doesn't link a stop more than 1 km away", async () => { + const stops = [stopAt("p:far", "Köln Messe/Deutz", deutz.lat, deutz.lng, 1_200)]; + const { transit } = setup({}, stops); + expect((await transit.getLinkedStops(deutz)).data).toEqual([]); + }); +}); diff --git a/integrations/transit/__tests__/place-timetable-evidence.test.ts b/integrations/transit/__tests__/place-timetable-evidence.test.ts index e42e82e0e..c8ccf6767 100644 --- a/integrations/transit/__tests__/place-timetable-evidence.test.ts +++ b/integrations/transit/__tests__/place-timetable-evidence.test.ts @@ -124,7 +124,7 @@ describe("linked stop discovery failures", () => { it("keeps the stops found by one name variant when another variant's search fails", async () => { const search = vi.fn(async (query: string) => { if (query === "Aachen Hauptbahnhof") throw new Error("Transit stop sources unavailable"); - return result(stops); + return result(stops.map((stop) => ({ ...stop, name: "Aachen Hbf" }))); }); const api = discovering(search as unknown as TransitOrchestrator["searchByNameRaw"]); diff --git a/integrations/transit/place-transit.ts b/integrations/transit/place-transit.ts index 98abdbc89..8bbe1bbff 100644 --- a/integrations/transit/place-transit.ts +++ b/integrations/transit/place-transit.ts @@ -1,5 +1,5 @@ import type { BBox } from "@openmapx/core"; -import { diceSimilarity, haversineMeters } from "@openmapx/core"; +import { haversineMeters } from "@openmapx/core"; import type { IntegrationContext, TransitProvider } from "@openmapx/integration-framework"; import { captureTransitContributors } from "@openmapx/integration-framework"; import { @@ -34,12 +34,7 @@ import { } from "./dedup.js"; import type { TransitOrchestrator } from "./orchestrator.js"; import { emptyResult, mergeFreshness } from "./result-merge.js"; -import { - mainStopKey, - stopNameKey, - stopNameSpecificity, - withoutBracketedParts, -} from "./stop-names.js"; +import { mainStopKey, stopNameKey, stopNameSpecificity, stopNameWords } from "./stop-names.js"; /** Preserve scalar ownership when a weaker row supplies a missing field. */ function retainFieldReceipt( @@ -274,13 +269,9 @@ interface DepartureWithFeed extends Departure { } const LINK_RADIUS_M = 1000; // 1 km -// Place→stop linking deliberately uses a more permissive Dice floor (0.4) -// than `DEDUP.NAME_SIMILARITY_MIN` (0.6) because place names and transit -// stop names often differ stylistically ("Köln Hbf" vs "Köln -// Hauptbahnhof"). Tightening to the policy default would drop legitimate -// links. -const MIN_NAME_DICE = 0.4; -const MIN_INFORMATIVE_TOKEN_LEN = 4; +/** How far stops named after a place as their town tell that it is one, as "Brühl Mitte" does. */ +const TOWN_EVIDENCE_RADIUS_M = 2000; +const MIN_TOWN_STOPS = 3; const TTL = { placeStops: TTL_POLICY.PLACE_LINK, @@ -394,18 +385,113 @@ function hashKey(prefix: string, data: unknown): string { return `${prefix}:${(hash >>> 0).toString(36)}`; } -function normalizeLinkName(name: string): string { - return withoutBracketedParts(name, ["()"]) - .toLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, " ") - .replace(/\s+/g, " ") - .trim(); +/** Words saying a stop is a station's own, as in "Köln Dom/Hbf" or "Köln Bf Deutz/Messe". */ +const STATION_WORDS = new Set(["hauptbahnhof", "bahnhof", "bhf"]); +/** Words saying only that a stop is a station, as in "Köln Messe/Deutz Bf". */ +const GENERIC_LINK_WORDS = new Set(["bahnhof", "bhf", "station", "gare"]); +/** A station named for its side of town: "Ostbahnhof" is "München Ost". */ +const SIDE_STATION = /^(nord|ost|sud|west)bahnhof$/; +/** Spellings of station words the search's synonyms don't cover. */ +const STATION_SPELLINGS: Readonly> = { hauptbf: "hauptbahnhof" }; +/** The most words a town written apart by a comma has, as in "Marienplatz, München". */ +const MAX_TOWN_WORDS = 2; + +interface LinkName { + /** Distinct words in order, street abbreviations written out. */ + words: string[]; + /** The words without a town leading the name ("Köln Neumarkt"), when no comma sets one apart. */ + withoutLeadingTown: string[][]; + /** The words without a town a comma sets apart, first or last ("Marienplatz, München"). */ + withoutCommaTown: string[][]; + /** Whether the name calls the stop a station. */ + station: boolean; +} + +function linkWords(text: string): { words: string[]; station: boolean } { + let station = false; + const words = stopNameWords(text).flatMap((spelt) => { + const word = STATION_SPELLINGS[spelt] ?? spelt; + const side = SIDE_STATION.exec(word); + if (side || STATION_WORDS.has(word)) station = true; + if (side) return [side[1]]; + if (GENERIC_LINK_WORDS.has(word)) return []; + return [word.replace(/ß/g, "ss").replace(/str$/, "strasse")]; + }); + return { words: [...new Set(words)], station }; +} + +function linkName(name: string): LinkName { + const { words, station } = linkWords(name); + const parts = name.split(","); + const withoutCommaTown: string[][] = []; + if (parts.length > 1) { + for (const [town, rest] of [ + [parts[0], parts.slice(1)], + [parts[parts.length - 1], parts.slice(0, -1)], + ] as const) { + if (linkWords(town).words.length <= MAX_TOWN_WORDS) { + withoutCommaTown.push(linkWords(rest.join(",")).words); + } + } + } + const nonEmpty = (list: string[][]) => list.filter((w) => w.length > 0); + return { + words, + withoutLeadingTown: nonEmpty(parts.length > 1 ? [] : [words.slice(1)]), + withoutCommaTown: nonEmpty(withoutCommaTown), + station, + }; } -function tokenizeLinkName(name: string): string[] { - const norm = normalizeLinkName(name); - if (!norm) return []; - return norm.split(" ").filter((t) => t.length > 0); +const sameWords = (a: string[], b: string[]) => + a.length > 0 && a.length === b.length && a.every((word) => b.includes(word)); + +/** + * Whether a stop's name names the place: the same words, in any order, where + * one of the two may leave out its town ("Köln Neumarkt" and "Neumarkt", + * "Messe/Deutz" and "Köln Messe/Deutz", "Marienplatz, München" and + * "Marienplatz"); or a name calling the stop a station that has all of the + * place's words but its town ("Köln Dom/Hbf" and "Dom/Hbf" for "Köln + * Hauptbahnhof"). Sharing a district alone doesn't link: "Köln Deutz + * Suevenstr." is not "Köln Messe/Deutz". + * + * When the place is itself named like a town, a comma doesn't tell a stop's + * town: in "Brühl, Giesler Galerie" or "Rathaus, Garmisch-Partenkirchen" + * either part could be it. + */ +function namesPlace(stop: LinkName, place: LinkName, placeIsTown: boolean): boolean { + const stopWithoutTown = placeIsTown + ? stop.withoutLeadingTown + : [...stop.withoutLeadingTown, ...stop.withoutCommaTown]; + const placeWithoutTown = [...place.withoutLeadingTown, ...place.withoutCommaTown]; + if (sameWords(stop.words, place.words)) return true; + if (stopWithoutTown.some((words) => sameWords(words, place.words))) return true; + if (placeWithoutTown.some((words) => sameWords(stop.words, words))) return true; + if (!stop.station) return false; + return [place.words, ...placeWithoutTown].some( + (words) => words.length > 0 && words.every((word) => stop.words.includes(word)), + ); +} + +/** + * Whether nearby stops are named after the place as their town: at least + * {@link MIN_TOWN_STOPS} different ones begin with its name, as "Brühl Mitte", + * "Brühl Nord" and "Brühl Am Stern" do. A comma part is read on its own, so + * "Marienplatz, München" doesn't make Marienplatz a town. + */ +function namedLikeTown(place: LinkName, nearbyNames: string[]): boolean { + const n = place.words.length; + if (n === 0) return false; + const after = new Set(); + for (const name of nearbyNames) { + for (const part of name.split(",")) { + const { words } = linkWords(part); + if (words.length > n && place.words.every((word, i) => words[i] === word)) { + after.add(words.slice(n).join(" ")); + } + } + } + return after.size >= MIN_TOWN_STOPS; } /** A name's canonical form, so "Köln Hbf" and "Köln Hauptbahnhof" are one. */ @@ -424,66 +510,25 @@ function placeCacheId(lat: number, lng: number, names: string[], placeId?: strin const OSM_REF = /^(node|way|relation)\/[1-9]\d*$/; -/** - * The stops a place's name links: within 1 km and named similarly to the name - * or one of its spellings, and, when the candidates differ in other words, - * sharing one of those words with the name, so a stop isn't linked by its - * city's name alone. - */ +/** The stops a place's name links: within 1 km and naming the place (see {@link namesPlace}). */ function linkByName( candidates: TransitStop[], lat: number, lng: number, name: string, ): TransitStop[] { - const normVariants = getQueryVariants(name) - .map((v) => normalizeLinkName(v)) - .filter((v, i, arr) => v.length > 0 && arr.indexOf(v) === i); - - // First-pass candidates: distance + fuzzy name match against any query variant. - const prelim = candidates.filter((stop) => { - if (haversineMeters(lat, lng, stop.lat, stop.lng) > LINK_RADIUS_M) return false; - const stopNorm = normalizeLinkName(stop.name); - if (!stopNorm) return false; - let best = 0; - for (const q of normVariants) { - const score = diceSimilarity(stopNorm, q); - if (score > best) best = score; - } - return best >= MIN_NAME_DICE; + const place = linkName(name); + const near = candidates.flatMap((stop) => { + const meters = haversineMeters(lat, lng, stop.lat, stop.lng); + return meters <= TOWN_EVIDENCE_RADIUS_M ? [{ stop, meters, name: linkName(stop.name) }] : []; }); - if (prelim.length === 0) return []; - - // Second-pass pruning: avoid linking by city token only. - // Keep candidates that share at least one informative token from the place - // name variants (tokens present in some, but not all, prelim candidates). - // Tokens are compared in expanded form, so "Köln Hbf" shares "hauptbahnhof" - // with a place or stop spelled "Köln Hauptbahnhof". - const tokenFreq = new Map(); - const prelimTokenSets = prelim.map((s) => new Set(tokenizeLinkName(expandSearchQuery(s.name)))); - for (const tokens of prelimTokenSets) { - for (const t of tokens) { - tokenFreq.set(t, (tokenFreq.get(t) ?? 0) + 1); - } - } - - const placeTokens = new Set(); - for (const q of normVariants) { - for (const t of tokenizeLinkName(expandSearchQuery(q))) { - if (t.length < MIN_INFORMATIVE_TOKEN_LEN) continue; - if (/^\d+$/.test(t)) continue; - placeTokens.add(t); - } - } - - const informativeTokens = Array.from(placeTokens).filter((t) => { - const count = tokenFreq.get(t) ?? 0; - return count > 0 && count < prelim.length; - }); - - return informativeTokens.length === 0 - ? prelim - : prelim.filter((_, i) => informativeTokens.some((t) => prelimTokenSets[i].has(t))); + const placeIsTown = namedLikeTown( + place, + near.map((n) => n.stop.name), + ); + return near + .filter((n) => n.meters <= LINK_RADIUS_M && namesPlace(n.name, place, placeIsTown)) + .map((n) => n.stop); } interface CachedStopsEnvelope { diff --git a/integrations/transit/stop-names.ts b/integrations/transit/stop-names.ts index b30a81cfb..5a42c574c 100644 --- a/integrations/transit/stop-names.ts +++ b/integrations/transit/stop-names.ts @@ -53,14 +53,22 @@ const GENERIC_STATION_WORDS = new Set(["bahnhof", "station", "gare"]); * is kept, since it tells a main station from other stops of a city. */ export function stopNameKey(name: string): string { - const words = withoutBracketedParts(expandSearchQuery(name)) + const words = stopNameWords(name).filter((word) => !GENERIC_STATION_WORDS.has(word)); + return [...new Set(words)].sort().join(" "); +} + +/** + * A stop name's words in order, normalised as {@link stopNameKey} compares + * them, but keeping the words that say the stop is a station. + */ +export function stopNameWords(name: string): string[] { + return withoutBracketedParts(expandSearchQuery(name)) .normalize("NFD") .replace(/\p{M}/gu, "") .toLowerCase() .replace(PLATFORM_DESIGNATION, " ") .split(/[^\p{L}\p{N}]+/u) - .filter((word) => word.length > 1 && !GENERIC_STATION_WORDS.has(word)); - return [...new Set(words)].sort().join(" "); + .filter((word) => word.length > 1); } /** How specific a stop name is: "Köln Breslauer Platz/Hbf" names more than "Köln Hbf". */