diff --git a/README.md b/README.md index e4219d8..f488be5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ It also handles connections (up to 6 changes, an optional stopover city, overnig | --- | --- | --- | | **[MAX JEUNE](https://www.sncf-connect.com/catalogue/description/max-jeune)** | 16 to 27, €79/month, 12-month minimum | €0 seats in 2nd class on TGV INOUI, OUIGO high-speed and Intercités with reservation, in France plus Luxembourg and Freiburg. Book up to 30 days ahead, 6 trips at a time, one €0 trip a day from the same station, confirm 1 to 2 days before. No €0 seats on Friday and Sunday afternoons and evenings or holiday eves. Railmap shows the trains with a free MAX seat open for booking. | | **[MAX SENIOR](https://www.sncf-connect.com/catalogue/description/max-senior)** | 60 and over, same price | Same €0 seats as MAX JEUNE. | -| **[Interrail](https://www.interrail.com/en/interrail-passes/global-pass)** | Residents of the 33 Interrail countries (everyone else buys Eurail). Youth up to 27, senior from 60, children 4 to 11 free with a Child Pass | Any train with a pass-holder reservation (€10 to 20 on TGV, limited seats). Living in France: a Global Pass covers only one trip out of France and one trip back in, and there is no One Country Pass for your own country. Railmap shows every SNCF train a pass holder can take, international stops included. | +| **[Interrail](https://www.interrail.com/en/interrail-passes/global-pass)** | Residents of the 33 Interrail countries (everyone else buys Eurail). Youth up to 27, senior from 60, children 4 to 11 free with a Child Pass | Any train with a pass-holder reservation (€10 to 20 on TGV, limited seats). Living in France: a Global Pass covers only one trip out of France and one trip back in, and there is no One Country Pass for your own country. Railmap shows the trains a pass holder can take across Europe (SNCF, Renfe, DB, NS, SBB and the other operators train-api publishes), with changes between them. | **Lower carbon.** A TGV trip emits about 50 times less CO₂ than driving alone and 100 times less than flying ([ADEME](https://impactco2.fr/outils/transport)). Each exact trip shows its own figure. diff --git a/docs/user-flows.md b/docs/user-flows.md index d2eb3f2..6710f08 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -230,6 +230,27 @@ the destination is reachable, with its calendar open. back is instant. Switching between the two MAX passes re-runs straight away. Booking still opens SNCF Connect, where pass-holder reservations are sold; "How does it work?" links Interrail's page on French reservations. A `card=interrail` link loads the Interrail snapshot at boot. + **Interrail across Europe**: on Interrail, a background worker (`src/europe/`) downloads + train-api's `/v2/` index and every operator's station list (small) once per visit, and + matches the operators' stations into one directory. A search then downloads only the + trip bundles of the operators on the way between its two towns (operators that meet in a + town, on a shortest chain or one operator longer; explore: the origin's operators and + their neighbours), so Paris to Madrid loads SNCF and Renfe, never Finland, and the page + stays light as countries are added. It searches them with train-api's journey engine + (`src/europe/engine.ts`, a copy of train-api's `src/search.ts`). Settings has an opt-in + "Europe's timetables offline": it keeps every operator's files in Cache Storage + (`railmap-europe`, which `public/sw.js` leaves alone), refreshes them on a visit once + train-api has published newer ones, and the worker reads them whenever train-api can't + be reached; turning it off deletes them. Every station in the directory joins the + station fields and the map: a place SNCF also serves keeps SNCF's id, any other takes the engine's + (`amsterdam-centraal`). The exact trip (one way, round trip, flexible days) reads its + journeys from that network: the days shown first, then the rest of the bookable days in + the background, after which the calendars fill in. A search from or to a station covers + every station of its town. Explore from a station lists, after SNCF's direct + destinations, the 80 fastest of the 150 busiest towns reachable that day. A train of + another operator books on that operator's site ("Book on Renfe"), and carries no + reservation fee. When the bundles can't load, Interrail searches SNCF's trains alone, as + before. Ideas, arrive-at, multi-city and the planner still search SNCF's trains. "How does it work?" lists who can use each pass and its rules (MAX JEUNE 16 to 27, MAX SENIOR from 60, booking window and daily limits; Interrail for residents of the 33 countries, Eurail for everyone else) and the special case for people living in France (a diff --git a/public/llms.txt b/public/llms.txt index 1eb1a40..0aee96d 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -43,7 +43,7 @@ Query params: - `from`: origin station id ; `to`: destination station id (in `mode=tour`, the optional finish city; set `to`=`from` for a loop) - `date`: `YYYY-MM-DD` - `conn`: `0` | `1` | `2` | `3` | `6` (max changes; 6 = unlimited; default 1) -- `card`: `jeune` | `senior` | `interrail` (Interrail: every running train, seat unknown; data in `/data/interrail.json`, the compact format of `src/data/compact.ts`) +- `card`: `jeune` | `senior` | `interrail` (Interrail: every running train, seat unknown; SNCF's in `/data/interrail.json`, the compact format of `src/data/compact.ts`, and every operator's from train-api's `/v2/` trip bundles; `from`/`to` then also take the engine's station ids, such as `amsterdam-centraal`) - `after` / `before`: `HH:MM` departure window ; `arrbefore`: `HH:MM` arrive-before cutoff ; `maxdur`: minutes ; `type`: axis - `via`: station id to route through (`mode=od`) - `flex`: `1` | `2` | `3` | `7` — also search ± N days around `date` (`mode=od/from/to/tour`) diff --git a/public/sw.js b/public/sw.js index 6b65e36..3fc05b8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -7,7 +7,9 @@ * - Navigation requests → network-first, fallback to cached shell * - /data/*.json → network-first, fallback to cache * - Same-origin GET → cache-first (hashed JS/CSS assets) - * - train-api *.json → network-first, fallback to cache (offline timetable) + * - train-api *.json → network-first, fallback to cache (offline timetable), except + * the v2 trip files: Interrail across Europe keeps those itself, + * only when the user turns offline mode on (src/europe/) * - Other cross-origin / POST → passthrough, never cached */ @@ -15,7 +17,11 @@ // re-install and re-activate: the activate handler then deletes the previous cache // and navigates any client stranded on a stale shell (the white-page failure mode) // onto the freshly deployed one. -const CACHE_NAME = "maxjeune-v38"; +const CACHE_NAME = "maxjeune-v39"; + +// Europe's timetables, kept by the page's Europe worker when the user turns offline mode +// on (OFFLINE_CACHE in src/europe/cache.ts). Not this worker's to delete. +const EUROPE_CACHE = "railmap-europe"; // Minimal app shell — paths relative to the SW's scope (/Railmap/) // Vite injects a hashed index.html in the build output at the base path. @@ -47,8 +53,9 @@ self.addEventListener("activate", (event) => { const keys = await caches.keys(); // If an older cache exists, this is an UPGRADE: a client may be stranded on // a stale shell pointing at a deleted bundle (the blank-page failure mode). - const wasUpgrade = keys.some((k) => k !== CACHE_NAME); - await Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))); + const old = keys.filter((k) => k !== CACHE_NAME && k !== EUROPE_CACHE); + const wasUpgrade = old.length > 0; + await Promise.all(old.map((k) => caches.delete(k))); await self.clients.claim(); if (wasUpgrade) { // Force any open window onto the fresh shell. The page's own JS can't @@ -82,7 +89,12 @@ self.addEventListener("fetch", (event) => { // The train-api timetable replaces /data/*.json for most visitors, so keep the last // copy for offline use the same way (see trainApiBase in src/config.ts). - if (url.origin === TRAIN_API_ORIGIN && url.pathname.startsWith("/train-api/") && url.pathname.endsWith(".json")) { + if ( + url.origin === TRAIN_API_ORIGIN && + url.pathname.startsWith("/train-api/") && + !url.pathname.startsWith("/train-api/v2/") && + url.pathname.endsWith(".json") + ) { event.respondWith(networkFirstWithCacheFallback(request)); return; } diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index df87db0..a4e00fd 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -35,8 +35,64 @@ const TYPES = { ".txt": "text/plain", ".xml": "application/xml", ".ico": "image/x-icon", }; +// A stand-in train-api for Interrail across Europe (v2): SNCF from Paris to Barcelona, Renfe +// on to Madrid, and Finland's VR, which no Paris search should download, every day of the +// booking window. A page opened with e2e=europe in its URL reads it; every other page runs +// on the bundled snapshot alone. EUROPE_HITS records the files asked for; europeDown makes +// the stand-in fail like an unreachable network. +const EUROPE_DATES = Array.from({ length: 33 }, (_, i) => new Date(Date.now() + (i - 1) * 86_400_000).toISOString().slice(0, 10)); +const EVERY_DAY = "f".repeat(Math.ceil(EUROPE_DATES.length / 4)); +const EUROPE_OPERATORS = { + sncf: { + booking: "https://www.sncf-connect.com/en-en/", + stations: [["PARIS (intramuros)", null, null], ["BARCELONA SANTS", null, null]], + families: ["SUD EST"], + trips: [["9713", 0, EVERY_DAY, [0, 1], [480, 480, 870, 870]]], + }, + renfe: { + booking: "https://www.renfe.com/es/en", + stations: [["Barcelona-Sants", 41.3793, 2.1404], ["Zaragoza Delicias", 41.6588, -0.9116], ["Madrid-Puerta de Atocha", 40.4066, -3.6892]], + families: ["AVE"], + trips: [["03063", 0, EVERY_DAY, [0, 1, 2], [930, 930, 1010, 1012, 1095, 1095]]], + }, + fi: { + booking: "https://www.vr.fi/en", + stations: [["Helsinki", 60.1719, 24.9414], ["Tampere", 61.4981, 23.7731]], + families: ["IC"], + trips: [["IC1", 0, EVERY_DAY, [0, 1], [420, 420, 520, 520]]], + }, +}; +const EUROPE_API = { + "/europe-api/v2/index.json": { + version: 2, + updatedAt: new Date().toISOString(), + operators: Object.entries(EUROPE_OPERATORS).map(([id, o]) => ({ + id, + passes: [{ id: "interrail", bookingUrl: o.booking, bytes: JSON.stringify(o.trips).length }], + })), + }, +}; +for (const [id, o] of Object.entries(EUROPE_OPERATORS)) { + EUROPE_API[`/europe-api/v2/${id}/stations.json`] = o.stations; + EUROPE_API[`/europe-api/v2/${id}/interrail.json`] = { + v: 2, operator: id, pass: "interrail", dates: EUROPE_DATES, stations: o.stations, families: o.families, trips: o.trips, + }; +} +const EUROPE_HITS = new Set(); +let europeDown = false; + // Serve dist, mirroring the GitHub-Pages /Railmap/ base-path rewrite. const server = http.createServer((req, res) => { + const path = (req.url || "/").split("?")[0]; + if (path.startsWith("/europe-api/")) { + EUROPE_HITS.add(path); + if (europeDown) return res.writeHead(503).end("down"); + const json = EUROPE_API[path]; + if (!json) return res.writeHead(404).end("not found"); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(json)); + } + const europe = /[?&]e2e=europe\b/.test(req.url || ""); let p = decodeURIComponent((req.url || "/").split("?")[0]) .replace(/^\/Railmap\//, "/") .replace(/^\/+/, ""); @@ -47,7 +103,8 @@ const server = http.createServer((req, res) => { res.writeHead(200, { "Content-Type": TYPES[extname(file)] || "application/octet-stream" }); // Runs on the bundled snapshot, never the live train-api (see trainApiBase in src/config.ts). const body = readFileSync(file); - res.end(file.endsWith(".html") ? body.toString("utf-8").replace(/]*>/, "") : body); + const meta = europe ? `` : ""; + res.end(file.endsWith(".html") ? body.toString("utf-8").replace(/]*>/, meta) : body); }); await new Promise((r) => server.listen(0, "127.0.0.1", r)); const PORT = server.address().port; @@ -832,6 +889,68 @@ await scenario( }, ); +// Interrail across Europe: the trip search changes from SNCF to Renfe in Barcelona, books +// the Renfe train on Renfe's site, and explore lists Madrid, which SNCF's trains never reach. +EUROPE_HITS.clear(); +await scenario( + "interrail europe: Paris to Madrid changes to Renfe, and explore reaches Madrid", + `${BASE}?mode=od&from=${enc(P)}&to=madrid-puerta-de-atocha&date=${DATE}&card=interrail&e2e=europe`, + async (page) => { + const shown = await until(async () => ((await $text(page, ".results")) || "").includes("03063") || null, 30_000); + assert(shown, `no Paris to Madrid journey with the Renfe train (results "${(await $text(page, ".results"))?.slice(0, 120)}")`); + assert((await $count(page, "article.journey")) > 0, "no journey card"); + assert(((await $text(page, ".results")) || "").includes("9713"), "the SNCF train to Barcelona is missing"); + // The calendar fills in behind the list: another day shows a train too. + const days = await until(async () => ((await $count(page, ".results .cal-cell.ok")) > 1) || null, 30_000); + assert(days, "the possible-days calendar did not fill in"); + // It scales by downloading only the operators on the way, and every station list. + assert(EUROPE_HITS.has("/europe-api/v2/fi/stations.json"), "Finland's stations were not listed"); + assert(!EUROPE_HITS.has("/europe-api/v2/fi/interrail.json"), "Paris to Madrid downloaded Finland's timetable"); + await page.goto(`${BASE}?mode=from&from=${enc(P)}&date=${DATE}&card=interrail&e2e=europe`, { waitUntil: "networkidle2" }); + const madrid = await until(async () => ((await $text(page, ".results")) || "").includes("Madrid-Puerta de Atocha") || null, 30_000); + assert(madrid, `explore did not reach Madrid (results "${(await $text(page, ".results"))?.slice(0, 120)}")`); + }, +); + +// Offline mode (Settings, opt-in): every country's timetable is kept on the device, and +// a search still finds Paris to Madrid when train-api can't be reached. +await scenario( + "interrail europe: offline mode keeps every country and searches without train-api", + `${BASE}?mode=od&from=${enc(P)}&to=madrid-puerta-de-atocha&date=${DATE}&card=interrail&e2e=europe`, + async (page) => { + europeDown = false; + assert(await until(async () => ((await $text(page, ".results")) || "").includes("03063") || null, 30_000), "precondition: no Paris to Madrid journey"); + await page.click("button.settings-btn"); + assert(await until(async () => (await $count(page, ".settings-modal")) > 0 || null, 5_000), "the settings dialog did not open"); + const turnedOn = await page.evaluate(() => { + const row = [...document.querySelectorAll(".settings-modal .set-row")].find((r) => + /offline/i.test(r.querySelector(".set-row-label")?.textContent || ""), + ); + const sw = row?.querySelector("button[role=switch]"); + sw?.click(); + return !!sw; + }); + assert(turnedOn, "no offline switch in the settings"); + const kept = await until( + () => + page.evaluate(async () => { + const c = await caches.open("railmap-europe"); + return (await c.keys()).filter((r) => r.url.includes("/interrail.json")).length === 3 || null; + }), + 30_000, + ); + assert(kept, "offline mode did not keep every country's timetable"); + europeDown = true; + try { + await page.goto(`${BASE}?mode=od&from=${enc(P)}&to=madrid-puerta-de-atocha&date=${DATE2}&card=interrail&e2e=europe`, { waitUntil: "networkidle2" }); + const offline = await until(async () => ((await $text(page, ".results")) || "").includes("03063") || null, 30_000); + assert(offline, `no journey with train-api unreachable (results "${(await $text(page, ".results"))?.slice(0, 120)}")`); + } finally { + europeDown = false; + } + }, +); + // 12. PWA manifest is served and parseable, icon reference resolves. await scenario("pwa: manifest is served and valid JSON", BASE, async (page) => { const manifestHref = await page.$eval('link[rel="manifest"]', (el) => el.getAttribute("href")); diff --git a/src/app.ts b/src/app.ts index 2cd577a..dc9d30f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ import { loadDataset, type Dataset } from "./data/dataset"; -import { SNCF_PROFILE, parseCard, profileForCard, type DatasetProfile } from "./data/profile"; -import { StationRegistry } from "./data/stations"; +import { SNCF_PROFILE, INTERRAIL_PROFILE, parseCard, profileForCard, type DatasetProfile } from "./data/profile"; +import { operatorById, setOperatorBooking } from "./data/operators"; +import { StationRegistry, prettyLabel } from "./data/stations"; import type { CardType, SearchQuery, SearchMode, MaxTrain, Journey, SortKey, CalendarDay, StayChoice } from "./types"; import { stayNights, stayFromNights } from "./core/roundtrip"; import { @@ -20,7 +21,7 @@ import { type Getaway, } from "./core/getaways"; import { planTours, planTourInOrder, planTourGreedy, arrivalDate, type Tour } from "./core/tour"; -import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, catchableAfter, toJourney, MAX_RESULTS } from "./core/connections"; +import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, catchableAfter, toJourney, setJourneySource, MAX_RESULTS } from "./core/connections"; import type { ConnectionOptions } from "./core/connections"; import { availabilityCalendar, reachableCountCalendar, dateRange } from "./core/calendar"; import { findHiddenTrains } from "./core/hidden"; @@ -64,6 +65,17 @@ import { } from "./config"; import { filterOptsFor, odConnOptsFor, odJourneyOptsFor, getawayOptsFor } from "./core/queryOpts"; import { warmSearch } from "./search/searchClient"; +import { + loadEurope, + europeDirectory, + cachedJourneys, + searchJourneys, + cachedReach, + searchReach, + returnDates, + setEuropeOffline, +} from "./europe/client"; +import type { EuropeDirectory, OfflineState } from "./europe/europe.worker"; import { notify } from "./pwa/register"; interface Deps { @@ -100,6 +112,8 @@ const BOOKING_WINDOW_DAYS = 30; const APP_TITLE = document.title; // Cap on connection-only ("via") destinations appended to a browse list. const MAX_VIA_RESULTS = 30; +/** The same on Interrail across Europe, where many more places are a train or two away. */ +const EUROPE_VIA_RESULTS = 80; // Step-wise Back INSIDE a multi-step flow (the round-trip Aller/Retour accordion, the // multi-city legs). While a later step is active, Back re-opens the previous step first // — walking the flow backwards — instead of exiting it. The current render registers a @@ -642,6 +656,7 @@ export function initApp(root: HTMLElement, dataset: Dataset, registry: StationRe // Every station present in the dataset becomes searchable (the curated registry // only covers map coordinates for the major ones). registry.addMissing(dataset.trains.flatMap((t) => [t.origin, t.destination])); + void startEurope(); today = new Date().toISOString().slice(0, 10); query = store.urlHasQuery() @@ -769,12 +784,153 @@ function useDataset(dataset: Dataset): void { }; setSeatUnknown(!deps.profile.seatKnown); deps.registry.setPresent(dataset.trains.flatMap((tr) => [tr.origin, tr.destination])); + setJourneySource(null); + void startEurope(); formApi.setStationLabels(deps.registry.list().map((s) => s.label)); const updated = rootRef.querySelector(".updated"); if (updated) updated.textContent = footerUpdatedText(); deferFormCalRepaint(); } +// --- Interrail across Europe ----------------------------------------------------- + +/** Interrail searches every operator's trains (train-api's trip bundles), not SNCF's alone. */ +function europeOn(): boolean { + return deps.profile.id === INTERRAIL_PROFILE.id && europeDirectory() !== null; +} + +/** + * On Interrail, load every operator's trains in the background (once) and add their + * stations. Resolves when they are in, or right away for MAX or when they can't load. + */ +function startEurope(): Promise { + if (deps.profile.id !== INTERRAIL_PROFILE.id) return Promise.resolve(); + return loadDirectory().then((dir) => { + if (dir && deps.profile.id === INTERRAIL_PROFILE.id) applyEurope(dir); + }); +} + +/** Europe's station directory, loaded once (each search then loads only the timetables it needs). */ +function loadDirectory(): Promise { + // SNCF's open data has no positions: the app's own place its stations among the others. + const coords: Record = {}; + for (const s of deps.registry.all()) { + const at = deps.registry.coords(s.id); + if (at) coords[s.id] = at; + } + // The page's train-api base names its v1 files; the trip bundles sit beside them, under /v2/. + const loading = loadEurope(trainApiBase().replace(/\/v1$/, ""), coords, settings.offlineEurope); + // Offline mode refreshes its copy on each visit, once train-api has published a newer one. + if (settings.offlineEurope && !offlineSynced) { + offlineSynced = true; + void loading.then((dir) => dir && syncOffline()); + } + return loading; +} + +// What offline mode keeps on the device, as last reported; the download under way. +let offlineState: OfflineState | null = null; +let offlineBusy = false; +let offlineSynced = false; + +/** Bring the device's copy of Europe's timetables in line with the setting. */ +function syncOffline(): void { + offlineBusy = settings.offlineEurope; + void setEuropeOffline(settings.offlineEurope).then((state) => { + offlineState = state; + offlineBusy = false; + }); +} + +/** The offline switch's status line: how big, downloading, or how fresh the kept copy is. */ +function offlineNote(): string { + if (offlineBusy) return t("set_offline_loading"); + if (settings.offlineEurope && offlineState?.updatedAt) { + return t("set_offline_kept", { date: formatDate(offlineState.updatedAt.slice(0, 10)) }); + } + const bytes = europeDirectory()?.bytes ?? 0; + return bytes > 0 ? t("set_offline_size", { mb: Math.max(1, Math.round(bytes / 1e6)) }) : ""; +} + +/** Make Europe's stations searchable and let the trip searches read the engine's journeys. */ +function applyEurope(dir: EuropeDirectory): void { + setOperatorBooking(dir.booking); + deps.registry.addPlaces( + dir.places.map((p) => ({ id: p.id, label: p.name, city: p.town, lat: p.lat ?? NaN, lng: p.lon ?? NaN })), + ); + setJourneySource(cachedJourneys); + formApi?.setStationLabels(deps.registry.list().map((s) => s.label)); + // A link naming a station only Europe's network has can resolve now, and the form shows + // its name where it showed the bare id (a field the user has since edited stays as is). + if (query) { + const station = (id: string | undefined): string | undefined => + id && !deps.registry.get(id) ? (deps.registry.resolve(id) ?? id) : id; + const before = { origin: query.origin, destination: query.destination }; + query.origin = station(query.origin); + query.destination = station(query.destination); + if (refs) { + for (const [input, was, now] of [ + [refs.origin, before.origin, query.origin], + [refs.destination, before.destination, query.destination], + ] as const) { + if (was && now && input.value === prettyLabel(was)) input.value = deps.registry.label(now); + } + deferFormCalRepaint(); + } + } +} + +/** The days a search shows: the date, or the ±flex window around it inside the bookable days. */ +function shownDates(q: SearchQuery): string[] { + const flex = q.flexDays ?? 0; + const last = addDays(today, BOOKING_WINDOW_DAYS - 1); + const out: string[] = []; + for (let i = -flex; i <= flex; i++) { + const d = addDays(q.date, i); + if (i === 0 || (d >= today && d <= last)) out.push(d); + } + return out.sort(); +} + +/** A first download of Europe's trains may take a while; past this, show SNCF's alone. */ +const EUROPE_WAIT_MS = 20000; + +/** Search what the first paint of `q` shows on Europe's network: its days' journeys, or its explore list. */ +async function europeWarm(q: SearchQuery): Promise { + if (deps.profile.id !== INTERRAIL_PROFILE.id) return; + await Promise.race([startEurope(), new Promise((r) => setTimeout(r, EUROPE_WAIT_MS))]); + if (!europeOn()) return; + if (q.mode === "od" && q.origin && q.destination) { + const tasks = [searchJourneys(q.origin, q.destination, shownDates(q), q.maxConnections, true)]; + // A round trip's return list: the same day, or the return day picked. + if (q.stay) tasks.push(searchJourneys(q.destination, q.origin, returnDates([q.date, q.returnDate ?? q.date], 0), q.maxConnections, true)); + await Promise.all(tasks); + } else if (q.mode === "from" && q.origin && !q.stay) { + await searchReach(q.origin, shownDates(q), q.maxConnections); + } +} + +/** + * After an exact trip paints, search the rest of the bookable days in the background (a + * few at a time, so a new search overtakes it), then repaint so its calendars fill in. + */ +async function europeFill(q: SearchQuery, token: number): Promise { + if (!europeOn() || q.mode !== "od" || !q.origin || !q.destination) return; + const days = dateRange(today, BOOKING_WINDOW_DAYS); + const routes: [string, string, string[]][] = [[q.origin, q.destination, days]]; + if (q.stay) routes.push([q.destination, q.origin, returnDates(days)]); + let found = false; + for (const [from, to, dates] of routes) { + for (let i = 0; i < dates.length; i += 3) { + if (token !== searchToken) return; + found = (await searchJourneys(from, to, dates.slice(i, i + 3), q.maxConnections, false)) || found; + } + } + if (!found || token !== searchToken || searchLoading) return; + refreshInPlace(); + deferFormCalRepaint(); +} + /** Dim the page's results under a spinner while another pass's timetable loads and searches. */ function setPassLoading(on: boolean): void { rootRef.classList.toggle("pass-loading", on); @@ -937,7 +1093,8 @@ function ctx(): RenderCtx { formatWeekday, // Deep-link to SNCF Connect with the trip pre-filled (clean station names, the // journey date, and the departure time). Connecting trips book per-leg instead. - bookUrl: (origin, destination, date, time) => + bookUrl: (origin, destination, date, time, operator) => + operatorById(operator)?.bookingUrl ?? generateBookingUrl(deps.registry.label(origin), deps.registry.label(destination), date, time), cityInfoUrl, onOpenRoute: (origin, destination, open) => { @@ -1880,9 +2037,12 @@ function runSearch(scrollTo?: number): void { // Otherwise pre-compute the heavy search primitives on the background worker so the // main thread stays responsive; the render then reads them straight from cache. If // the worker can't help, warmSearch resolves quickly and the render computes on-thread. - void warmSearch(deps.trains, query, today, deps.apiBase).then(() => { + // On Interrail, Europe's network answers the trip and explore searches alongside. + const searched = query; + void Promise.all([warmSearch(deps.trains, query, today, deps.apiBase), europeWarm(query)]).then(() => { if (token !== searchToken) return; paint(); + void europeFill(searched, token); }); } @@ -2108,6 +2268,22 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void { // the direct list (keeping the shortest journey per station) — otherwise widening // flexibility surfaces more direct places but never any extra connecting ones. const connecting = ((): ReachTrip[] => { + // Interrail across Europe: every town the engine reaches that the direct list hasn't. + const europe = dir === "from" && europeOn() ? windowDates.map((d) => cachedReach(anchor, d, query.maxConnections)) : []; + if (europe.length > 0 && europe.every((rows) => rows !== undefined)) { + const maxMin = query.maxDurationMin; + const byStation = new Map(); + for (const rows of europe) { + for (const tr of rows ?? []) { + if (directStations.has(tr.station) || (maxMin && tr.journey.totalDurationMin > maxMin)) continue; + const cur = byStation.get(tr.station); + if (!cur || tr.journey.totalDurationMin < cur.journey.totalDurationMin) byStation.set(tr.station, tr); + } + } + return [...byStation.values()] + .sort((a, b) => a.journey.totalDurationMin - b.journey.totalDurationMin) + .slice(0, EUROPE_VIA_RESULTS); + } if (query.maxConnections <= 0) return []; const viaOpts = { ...filterOpts(), maxConnections: query.maxConnections }; const byStation = new Map(); @@ -3583,6 +3759,17 @@ function openSettings(): void { store.saveSettings(settings); refreshInPlace(); }, + offlineEurope: settings.offlineEurope, + offlineNote: offlineNote(), + onOfflineEurope: (v) => { + settings = { ...settings, offlineEurope: v }; + store.saveSettings(settings); + offlineBusy = v; + offlineSynced = true; + // On a MAX pass the directory isn't loaded yet: load it to download the timetables. + if (v) void loadDirectory().then((dir) => (dir ? syncOffline() : void (offlineBusy = false))); + else syncOffline(); + }, // Master "Low-end mode": flip all three savers together. ON = the light experience // (no motion, no map, compact); OFF = the full experience (motion, map, comfortable). onLowEnd: (v) => { diff --git a/src/core/connections.ts b/src/core/connections.ts index 97aae58..2aaa8ca 100644 --- a/src/core/connections.ts +++ b/src/core/connections.ts @@ -43,6 +43,43 @@ export interface ConnectionOptions { export const MAX_RESULTS = 200; const MAX_DFS_EXPANSIONS = 400_000; +/** + * Journeys another search already found for a route and day, or undefined when it has + * none: Interrail across Europe, where the trip-bundle engine answers instead of this + * file's search over SNCF's trains (see src/europe/client.ts). `maxConnections` is the + * query's. + */ +export type JourneySource = (origin: string, destination: string, date: string, maxConnections: number) => Journey[] | undefined; + +let journeySource: JourneySource | null = null; + +/** Let {@link findJourneys} answer from `source` first; null goes back to the trains alone. */ +export function setJourneySource(source: JourneySource | null): void { + journeySource = source; +} + +/** A found journey list narrowed to the options the source didn't search with. */ +function narrowFound(list: Journey[], opts: ConnectionOptions): Journey[] { + const after = opts.departAfter ? parseTimeToMinutes(opts.departAfter) : undefined; + const before = opts.departBefore ? parseTimeToMinutes(opts.departBefore) : undefined; + const arriveBy = opts.arriveBefore ? parseTimeToMinutes(opts.arriveBefore) : undefined; + return list + .filter((j) => { + const last = j.legs[j.legs.length - 1]; + if (!last) return false; + if (after !== undefined && j.departMin < after) return false; + if (before !== undefined && j.departMin > before) return false; + if (arriveBy !== undefined && journeyArriveAbs(j) > arriveBy) return false; + if (opts.maxDurationMin != null && j.totalDurationMin > opts.maxDurationMin) return false; + if (opts.minDurationMin != null && j.totalDurationMin < opts.minDurationMin) return false; + if (opts.trainType && j.legs.some((l) => (l.axe ?? "") !== opts.trainType)) return false; + if (opts.excludeNight && j.legs.some(isNightTrain)) return false; + if (opts.onlyNight && !isNightTrain(last)) return false; + return true; + }) + .sort((a, b) => a.departMin - b.departMin || a.totalDurationMin - b.totalDurationMin || a.legs.length - b.legs.length); +} + // Cache of available trains grouped by date, keyed by the (stable, loaded-once) // trains array. Avoids re-scanning the whole dataset on every journey lookup — // matters when callers run findJourneys many times (30-day calendar, best mode). @@ -170,6 +207,8 @@ export function findJourneys( opts: ConnectionOptions = {}, ): Journey[] { const maxConn = opts.maxConnections ?? 1; + const found = journeySource?.(origin, destination, date, maxConn); + if (found) return narrowFound(found, opts); const hubSet = new Set(opts.hubs ?? HUB_STATIONS); const minC = opts.minConnectionMin ?? MIN_CONNECTION_MIN; // Pool `span` calendar days (≥2). A multi-day span also raises the layover diff --git a/src/data/fees.ts b/src/data/fees.ts index d7ac8fb..fc8ec84 100644 --- a/src/data/fees.ts +++ b/src/data/fees.ts @@ -72,11 +72,15 @@ export function interrailFee(axe: string | undefined, origin: string, destinatio return axe ? BY_AXE[axe] : undefined; } -/** The fees of every train in a journey added up, or undefined when one train has none. */ +/** + * The fees of every train in a journey added up, or undefined when one train has none. + * The table covers SNCF's trains only, so another operator's train leaves the journey without one. + */ export function journeyFee(j: Journey): Fee | undefined { let min = 0; let max = 0; for (const leg of j.legs) { + if (leg.operator) return undefined; const fee = interrailFee(leg.axe, leg.origin, leg.destination); if (!fee) return undefined; min += fee.min; diff --git a/src/data/operators.ts b/src/data/operators.ts new file mode 100644 index 0000000..4982ffa --- /dev/null +++ b/src/data/operators.ts @@ -0,0 +1,41 @@ +/** + * The operators whose trains an Interrail pass takes across Europe, by train-api's + * operator id: their names and where pass holders book. train-api's v2 index carries the + * booking sites too (they win when present); these are the fallback. + */ +export interface Operator { + /** train-api's operator id. */ + id: string; + /** Its name, as on its booking site. */ + name: string; + /** Where pass holders book its trains. */ + bookingUrl: string; +} + +const OPERATORS: Operator[] = [ + { id: "renfe", name: "Renfe", bookingUrl: "https://www.renfe.com/es/en" }, + { id: "nl", name: "NS", bookingUrl: "https://www.ns.nl/en" }, + { id: "de", name: "DB", bookingUrl: "https://int.bahn.de/en" }, + { id: "it", name: "Trenitalia", bookingUrl: "https://www.trenitalia.com/en.html" }, + { id: "ch", name: "SBB", bookingUrl: "https://www.sbb.ch/en" }, + { id: "no", name: "Vy", bookingUrl: "https://www.vy.no/en" }, + { id: "fi", name: "VR", bookingUrl: "https://www.vr.fi/en" }, + { id: "ie", name: "Irish Rail", bookingUrl: "https://www.irishrail.ie/en-ie/" }, + { id: "se", name: "SJ", bookingUrl: "https://www.sj.se/en" }, + { id: "gb", name: "National Rail", bookingUrl: "https://www.nationalrail.co.uk/" }, +]; + +/** Booking sites train-api published, by operator id; they replace the ones above. */ +let published: Record = {}; + +export function setOperatorBooking(booking: Record): void { + published = booking; +} + +/** An operator by its train-api id; undefined for SNCF (booked on SNCF Connect) and unknown ids. */ +export function operatorById(id: string | undefined): Operator | undefined { + if (!id || id === "sncf") return undefined; + const op = OPERATORS.find((o) => o.id === id) ?? { id, name: id.toUpperCase(), bookingUrl: "" }; + const url = published[id] ?? op.bookingUrl; + return url ? { ...op, bookingUrl: url } : undefined; +} diff --git a/src/data/stations.ts b/src/data/stations.ts index 96873c7..7a6bb7b 100644 --- a/src/data/stations.ts +++ b/src/data/stations.ts @@ -149,6 +149,20 @@ export class StationRegistry { } } + /** + * Add stations another network serves (Interrail across Europe), bookable and placed by + * that network. Known ids stay as they are; {@link setPresent} drops the new ones again. + */ + addPlaces(stations: Station[]): void { + this.exact = null; + for (const s of stations) { + this.present.add(s.id); + if (this.byId.has(s.id)) continue; + this.extra.add(s.id); + this.add(s); + } + } + /** * Swap in another dataset's stations (a pass switch): forget the stations the previous * dataset added and what it marked bookable, then {@link addMissing} this one's. diff --git a/src/europe/cache.ts b/src/europe/cache.ts new file mode 100644 index 0000000..45b5c06 --- /dev/null +++ b/src/europe/cache.ts @@ -0,0 +1,2 @@ +/** The Cache Storage name offline mode keeps Europe's files in (public/sw.js leaves it alone). */ +export const OFFLINE_CACHE = "railmap-europe"; diff --git a/src/europe/client.ts b/src/europe/client.ts new file mode 100644 index 0000000..9cb4032 --- /dev/null +++ b/src/europe/client.ts @@ -0,0 +1,242 @@ +// Main-thread side of the Interrail Europe search: starts the worker, keeps what it has +// answered in caches the synchronous render reads, and turns its journeys into the app's. +// +// Off (every call answers "nothing") when the page has no Worker or no train-api base: +// Interrail then searches SNCF's trains alone, as before. + +import type { Journey, MaxTrain } from "../types"; +import { toJourney } from "../core/connections"; +import { addDays, dayIndex, minutesToHHMM } from "../util/time"; +import type { EuropeDirectory, EuropeReply, EuropeRequest, OfflineState } from "./europe.worker"; +import { OFFLINE_CACHE } from "./cache"; +import type { EuropeJourney, ReachRow } from "./network"; + +/** A day's journeys between two stations are searched with up to this many trains. */ +export const EUROPE_MAX_TRAINS = 7; + +/** One leg's local time "YYYY-MM-DDTHH:MM" as a date and minutes past that date's midnight. */ +function minutesFrom(date: string, stamp: string): number { + return (dayIndex(stamp.slice(0, 10)) - dayIndex(date)) * 1440 + Number(stamp.slice(11, 13)) * 60 + Number(stamp.slice(14, 16)); +} + +/** The app's journey for one the engine found: legs between app station ids, local times. */ +export function toAppJourney(j: EuropeJourney): Journey | null { + if (j.legs.length === 0) return null; + const legs: MaxTrain[] = j.legs.map((l) => { + const date = l.departure.slice(0, 10); + const departMin = minutesFrom(date, l.departure); + const arriveMin = Math.max(departMin, minutesFrom(date, l.arrival)); + const train: MaxTrain = { + date, + origin: l.from, + destination: l.to, + depart: minutesToHHMM(departMin), + arrive: minutesToHHMM(arriveMin), + departMin, + arriveMin, + durationMin: arriveMin - departMin, + trainNo: l.trainNo, + available: true, + }; + if (l.category) train.axe = l.category; + if (l.operator !== "sncf") train.operator = l.operator; + return train; + }); + const out = toJourney(legs); + // Local clocks differ across borders; the engine's duration counts real minutes. + out.totalDurationMin = j.duration; + return out; +} + +type Pending = { msg: EuropeRequest; done: (r: EuropeReply | null) => void; urgent: boolean }; + +let worker: Worker | null = null; +let disabled = false; +let seq = 0; +let inFlight: Pending | null = null; +const queue: Pending[] = []; + +function ensureWorker(): Worker | null { + if (disabled) return null; + if (worker) return worker; + if (typeof Worker === "undefined") { + disabled = true; + return null; + } + try { + worker = new Worker(new URL("./europe.worker.ts", import.meta.url), { type: "module" }); + worker.onmessage = (e: MessageEvent) => { + const p = inFlight; + inFlight = null; + if (p && p.msg.id === e.data.id) p.done(e.data); + pump(); + }; + worker.onerror = () => { + disabled = true; + worker = null; + const all = [...(inFlight ? [inFlight] : []), ...queue.splice(0)]; + inFlight = null; + for (const p of all) p.done(null); + }; + } catch { + disabled = true; + worker = null; + } + return worker; +} + +/** Send the next request: one at a time, so a search asked for now overtakes a calendar filling in. */ +function pump(): void { + if (inFlight || !worker) return; + const next = queue.shift(); + if (!next) return; + inFlight = next; + try { + worker.postMessage(next.msg); + } catch { + inFlight = null; + next.done(null); + pump(); + } +} + +/** The request without its id (each type's own fields). */ +type RequestBody = EuropeRequest extends infer R ? (R extends EuropeRequest ? Omit : never) : never; + +function ask(body: RequestBody, urgent: boolean): Promise { + const w = ensureWorker(); + if (!w) return Promise.resolve(null); + const msg = { ...body, id: ++seq } as EuropeRequest; + return new Promise((done) => { + const p: Pending = { msg, done, urgent }; + // Urgent requests go after the other urgent ones, ahead of background fills. + const at = urgent ? queue.findIndex((q) => !q.urgent) : -1; + if (at < 0) queue.push(p); + else queue.splice(at, 0, p); + pump(); + }); +} + +let directory: Promise | null = null; +let loaded: EuropeDirectory | null = null; + +/** + * Download the station directory once (every operator's stations; each search then loads + * only the timetables it needs). `offline` reads and keeps them on the device too. Null + * when it can't load. + */ +export function loadEurope(base: string, sncfCoords: Record, offline: boolean): Promise { + if (!base) return Promise.resolve(null); + directory ??= ask({ type: "init", base, sncfCoords, offline }, true).then((r) => { + loaded = r?.type === "init" ? r.directory : null; + return loaded; + }); + return directory; +} + +/** + * Keep every operator's timetables on the device (download what's missing or outdated), or + * drop them. Resolves with what is kept; null without the network loaded. + */ +export async function setEuropeOffline(on: boolean): Promise { + if (!loaded) { + // Never loaded this visit: dropping the kept copy needs no worker. + if (!on && typeof caches !== "undefined") await caches.delete(OFFLINE_CACHE).catch(() => false); + return null; + } + const r = await ask({ type: "offline", on }, false); + return r?.type === "offline" ? r.state : null; +} + +/** The loaded network's places, or null before (or without) it. */ +export function europeDirectory(): EuropeDirectory | null { + return loaded; +} + +// Journeys by "from>to@date#trains", and explore rows by "from@date#trains". +const journeyCache = new Map(); +const reachCache = new Map(); +const CACHE_LIMIT = 400; +// Routes ("from>to") and origins the network doesn't serve: SNCF's trains answer those. +const outside = new Set(); + +function remember(cache: Map, key: string, value: T): void { + cache.set(key, value); + while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value as string); +} + +function trainsFor(maxConnections: number | undefined): number { + return Math.min(EUROPE_MAX_TRAINS, Math.max(1, (maxConnections ?? 1) + 1)); +} + +/** The journeys found from `from` to `to` on `date`, or undefined when not searched yet. */ +export function cachedJourneys(from: string, to: string, date: string, maxConnections?: number): Journey[] | undefined { + if (!loaded) return undefined; + return journeyCache.get(`${from}>${to}@${date}#${trainsFor(maxConnections)}`); +} + +/** + * Search the dates not searched yet; true when some were. `urgent` puts it ahead of + * background fills. + */ +export async function searchJourneys( + from: string, + to: string, + dates: string[], + maxConnections: number | undefined, + urgent: boolean, +): Promise { + if (!loaded) return false; + const maxTrains = trainsFor(maxConnections); + const todo = [...new Set(dates)].filter((d) => !journeyCache.has(`${from}>${to}@${d}#${maxTrains}`)); + if (todo.length === 0) return false; + if (outside.has(`${from}>${to}`)) return false; + const r = await ask({ type: "journeys", from, to, dates: todo, maxTrains }, urgent); + if (r?.type !== "journeys") return false; + const byDate = r.byDate; + if (!byDate) { + outside.add(`${from}>${to}`); + return false; + } + for (const d of todo) { + const list = (byDate[d] ?? []).flatMap((j) => toAppJourney(j) ?? []); + remember(journeyCache, `${from}>${to}@${d}#${maxTrains}`, list); + } + return true; +} + +/** Every town reachable from `from` on `date` with its fastest journey, or undefined when not searched yet. */ +export function cachedReach(from: string, date: string, maxConnections?: number): { station: string; journey: Journey }[] | undefined { + if (!loaded) return undefined; + return reachCache.get(`${from}@${date}#${trainsFor(maxConnections)}`); +} + +export async function searchReach(from: string, dates: string[], maxConnections: number | undefined): Promise { + if (!loaded) return; + const maxTrains = trainsFor(maxConnections); + const todo = [...new Set(dates)].filter((d) => !reachCache.has(`${from}@${d}#${maxTrains}`)); + if (todo.length === 0) return; + if (outside.has(from)) return; + const r = await ask({ type: "reach", from, dates: todo, maxTrains }, true); + if (r?.type !== "reach") return; + const byDate = r.byDate; + if (!byDate) { + outside.add(from); + return; + } + for (const d of todo) { + const rows = (byDate[d] ?? []).flatMap((row: ReachRow) => { + const journey = toAppJourney(row.journey); + return journey ? [{ station: row.station, journey }] : []; + }); + remember(reachCache, `${from}@${d}#${maxTrains}`, rows); + } +} + +/** The dates a round trip leaving on `date` may come back on, for the return lists and calendar. */ +export function returnDates(dates: string[], extra = 3): string[] { + const last = dates.reduce((a, d) => (d > a ? d : a), ""); + const out = new Set(dates); + for (let i = 1; i <= extra && last; i++) out.add(addDays(last, i)); + return [...out].sort(); +} diff --git a/src/europe/engine.ts b/src/europe/engine.ts new file mode 100644 index 0000000..2a063cc --- /dev/null +++ b/src/europe/engine.ts @@ -0,0 +1,845 @@ +// Vendored from train-api (github.com/offware-apps/train-api, src/search.ts): keep the two +// files identical apart from this header, so fixes flow both ways. + +/** + * Journey search on trip bundles (v2): the fastest ways from one place to another on a + * date, with changes, across every operator loaded. + * + * Stations are matched across operators by position (and by name where a source has no + * coordinates), and grouped into towns, so a change can cross town (Paris Nord to Gare de + * Lyon) without any hand-written list. The search is a connection scan with one label per + * number of trains used: one pass over the day's hops, sorted by departure, finds the + * earliest arrival with 1, 2, … trains at once. Repeating it from just after each found + * departure gives the next journeys; the ones another beats (leaving later, arriving + * earlier, with no more changes) are dropped. + * + * Pure: no imports. It runs in a browser worker, Node or a server; copy this file. + */ + +/** A trip bundle as published under /v2/ (see src/bundle.ts). */ +export interface SearchBundle { + v: 2; + operator: string; + dates: string[]; + stations: [string, number | null, number | null][]; + families: string[]; + trips: [string, number, string, number[], number[]][]; +} + +/** One station, as every operator serving it names it. */ +export interface Place { + id: string; + name: string; + lat: number | null; + lon: number | null; + /** The town's id: every place of one town shares it. */ + city: string; + stations: { operator: string; name: string }[]; + /** How many trips call here: how busy the place is, to rank places. */ + calls: number; +} + +/** What a name or an id resolves to: one station, or every station of a town. */ +export interface Match { + id: string; + name: string; + kind: "city" | "station"; + /** Indexes into `places`. */ + places: number[]; +} + +export interface Leg { + operator: string; + trainNo: string; + category?: string; + from: string; + to: string; + /** Local times at each end, "YYYY-MM-DDTHH:MM". */ + departure: string; + arrival: string; + /** Stops after `from`, `to` included. */ + stops: number; +} + +export interface Journey { + departure: string; + arrival: string; + /** Minutes door to door, time zones accounted for. */ + duration: number; + changes: number; + legs: Leg[]; +} + +export interface NetworkOptions { + /** Minutes to change trains within one station. Default 10. */ + change?: number; + /** Minutes to change at a station two operators serve. Default 15. */ + changeOperators?: number; + /** Minutes each operator's local time is ahead of Central European Time. */ + utcOffsets?: Record; +} + +export interface SearchOptions { + /** Earliest departure, local "HH:MM" at the origin. Default 00:00. */ + time?: string; + /** Most trains in one journey. Default 4 (3 changes). */ + maxTrains?: number; + /** How many journeys to return, earliest departures first. Default 5. */ + results?: number; +} + +/** Operators whose clock is not Central European Time, in minutes ahead of it. */ +export const UTC_OFFSETS: Record = { gb: -60, ie: -60, pt: -60, fi: 60, ee: 60, lv: 60, lt: 60, gr: 60, ro: 60, bg: 60 }; + +/** Words that say "station" in some language and don't tell stations apart. */ +const GENERIC = new Set([ + "hbf", "hauptbahnhof", "bahnhof", "bf", "hb", "centraal", "centrale", "central", "centralstation", "c", + "station", "stazione", "gare", "estacion", "estacio", "sbb", "cff", "ffs", "intramuros", "s", "h", +]); + +/** Words that can start a station's name without naming its town. */ +const NOT_A_TOWN = new Set(["saint", "st", "ste", "sainte", "san", "santa", "sankt", "le", "la", "les", "el", "los", "las", "de", "den", "der"]); + +/** Accents folded, lowercase, punctuation to spaces, generic words dropped. "Zürich HB" → "zurich". */ +export function normalize(name: string): string { + return name + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, " ") + .trim() + .split(" ") + .filter((w) => w && !GENERIC.has(w)) + .join(" "); +} + +function slug(s: string): string { + return s.replace(/ /g, "-") || "station"; +} + +function title(s: string): string { + return s.replace(/\b[a-z]/g, (c) => c.toUpperCase()); +} + +/** Kilometres between two points. */ +export function distanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const r = Math.PI / 180; + const x = (lon2 - lon1) * r * Math.cos(((lat1 + lat2) / 2) * r); + const y = (lat2 - lat1) * r; + return 6371 * Math.sqrt(x * x + y * y); +} + +/** Two stations closer than this are one station. */ +const SAME_STATION_KM = 0.3; +/** Two stations of one name closer than this are one station. */ +const SAME_NAME_KM = 3; +/** Stations of one town are at most this far apart. */ +const TOWN_KM = 20; +/** Minutes to cross town when a station's position is unknown. */ +const CROSS_TOWN = 60; + +class UnionFind { + private readonly parent: Int32Array; + constructor(n: number) { + this.parent = Int32Array.from({ length: n }, (_, i) => i); + } + find(i: number): number { + let r = i; + while (this.parent[r] !== r) r = this.parent[r] as number; + while (this.parent[i] !== r) { + const next = this.parent[i] as number; + this.parent[i] = r; + i = next; + } + return r; + } + union(a: number, b: number): void { + const ra = this.find(a); + const rb = this.find(b); + if (ra !== rb) this.parent[Math.max(ra, rb)] = Math.min(ra, rb); + } +} + +interface RawStation { + operator: string; + name: string; + norm: string; + lat: number | null; + lon: number | null; +} + +/** Every pair of located items closer than `km`, by a sweep over latitude. */ +function closePairs(items: { lat: number; lon: number; i: number }[], km: number, onPair: (a: number, b: number, d: number) => void): void { + const sorted = [...items].sort((a, b) => a.lat - b.lat); + const dLat = km / 111; + for (let x = 0; x < sorted.length; x++) { + const a = sorted[x] as { lat: number; lon: number; i: number }; + for (let y = x + 1; y < sorted.length; y++) { + const b = sorted[y] as { lat: number; lon: number; i: number }; + if (b.lat - a.lat > dLat) break; + const d = distanceKm(a.lat, a.lon, b.lat, b.lon); + if (d <= km) onPair(a.i, b.i, d); + } + } +} + +/** A search date's hops, sorted by departure; times in minutes from midnight CET of that date. */ +interface Hops { + dep: Int32Array; + arr: Int32Array; + from: Int32Array; + to: Int32Array; + /** Trip instance: global trip index + day slot × trip count. */ + inst: Int32Array; + /** Position of the hop's first stop in its trip. */ + hop: Int16Array; +} + +const INF = 0x3fffffff; +/** Service days around the search date whose trips can matter: the day before runs past midnight. */ +const DAY_SLOTS = [-1, 0, 1]; + +function addDays(date: string, n: number): string { + return new Date(Date.parse(`${date}T12:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10); +} + +function hhmm(min: number): string { + const m = ((min % 1440) + 1440) % 1440; + return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; +} + +/** Local "YYYY-MM-DDTHH:MM" for minutes from midnight of `date` (may run past 1440 or below 0). */ +function stamp(date: string, min: number): string { + return `${addDays(date, Math.floor(min / 1440))}T${hhmm(min)}`; +} + +function runsOn(mask: string, i: number): boolean { + const digit = mask.charCodeAt(i >> 2); + if (Number.isNaN(digit)) return false; + const n = digit <= 57 ? digit - 48 : (digit | 32) - 87; + return (n & (1 << (i & 3))) !== 0; +} + +/** One scan's labels, per number of trains k (index k*n + place). */ +interface Labels { + n: number; + K: number; + arrival: Int32Array; + legBoard: Int32Array; + legAlight: Int32Array; + readyFrom: Int32Array; + readyLevel: Int8Array; + atTarget: Int32Array; + targetPlace: Int32Array; +} + +interface Found { + dep: number; + arr: number; + journey: Journey; +} + +export class Network { + readonly places: Place[] = []; + private readonly bundles: SearchBundle[]; + /** Per bundle: each station index → place index. */ + private readonly placeOf: Int32Array[]; + /** Per bundle: the global index of its first trip. */ + private readonly tripBase: number[]; + private readonly tripCount: number; + private readonly offsets: number[]; + /** Minutes to change trains at each place. */ + private readonly change: Int32Array; + /** Per place: the other places of its town, with the minutes to cross. */ + private readonly walks: { to: number; minutes: number }[][]; + private readonly cities = new Map(); + private readonly hopsCache = new Map(); + + constructor(bundles: SearchBundle[], opts: NetworkOptions = {}) { + this.bundles = bundles; + const offsets = { ...UTC_OFFSETS, ...opts.utcOffsets }; + this.offsets = bundles.map((b) => offsets[b.operator] ?? 0); + let base = 0; + this.tripBase = bundles.map((b) => { + const at = base; + base += b.trips.length; + return at; + }); + this.tripCount = base; + + // 1. Every operator's stations, each once. + const raw: RawStation[] = []; + const rawIdx = new Map(); + const stationRaw = bundles.map((b) => + Int32Array.from(b.stations, ([name, lat, lon]) => { + const key = `${b.operator}\u0000${name}`; + let i = rawIdx.get(key); + if (i === undefined) { + i = raw.length; + rawIdx.set(key, i); + raw.push({ operator: b.operator, name, norm: normalize(name) || name.toLowerCase(), lat, lon }); + } + return i; + }), + ); + + // 2. One place per station: close together, or the same name nearby (or with no position). + const same = new UnionFind(raw.length); + const located = raw.flatMap((s, i) => (s.lat === null || s.lon === null ? [] : [{ lat: s.lat, lon: s.lon, i }])); + closePairs(located, SAME_NAME_KM, (a, b, d) => { + if (d <= SAME_STATION_KM || (raw[a] as RawStation).norm === (raw[b] as RawStation).norm) same.union(a, b); + }); + const byNorm = new Map(); + raw.forEach((s, i) => { + const list = byNorm.get(s.norm); + if (list) list.push(i); + else byNorm.set(s.norm, [i]); + }); + for (const list of byNorm.values()) { + const unlocated = list.filter((i) => (raw[i] as RawStation).lat === null); + if (unlocated.length === 0) continue; + // A name with no position joins the station of that name, when only one has it. + const roots = new Set(list.filter((i) => (raw[i] as RawStation).lat !== null).map((i) => same.find(i))); + const target = roots.size === 1 ? [...roots][0] as number : unlocated[0] as number; + for (const i of unlocated) same.union(i, target); + } + // How many trips call at each station: a place is named after its busiest one. + const calls = new Int32Array(raw.length); + bundles.forEach((b, bi) => { + const ids = stationRaw[bi] as Int32Array; + for (const t of b.trips) for (const si of t[3]) calls[ids[si] as number] = (calls[ids[si] as number] as number) + 1; + }); + const rawPlace = new Int32Array(raw.length); + const rootPlace = new Map(); + const namedBy: number[] = []; + raw.forEach((s, i) => { + const root = same.find(i); + let p = rootPlace.get(root); + if (p === undefined) { + p = this.places.length; + rootPlace.set(root, p); + this.places.push({ id: "", name: s.name, lat: s.lat, lon: s.lon, city: "", stations: [], calls: 0 }); + namedBy.push(i); + } + const place = this.places[p] as Place; + place.stations.push({ operator: s.operator, name: s.name }); + place.calls += calls[i] as number; + // Named and placed after its busiest station with a position (GTFS names read best). + const cur = raw[namedBy[p] as number] as RawStation; + const located = s.lat !== null && s.lon !== null; + const curLocated = cur.lat !== null && cur.lon !== null; + if ((located && !curLocated) || (located === curLocated && (calls[i] as number) > (calls[namedBy[p] as number] as number))) { + namedBy[p] = i; + place.name = s.name; + place.lat = s.lat; + place.lon = s.lon; + } + rawPlace[i] = p; + }); + // A name the place's other stations agree on beats a busier one: first the name most + // of them normalize to ("Zürich HB" over the quay's "Zürich, Bahnhofquai"), then one + // sharing a word with most (Munich is "München Hbf", not the tram stop "Hauptbahnhof + // (U, Tram)" that merged into it), then the busiest. + const byPlace: number[][] = this.places.map(() => []); + raw.forEach((_, i) => byPlace[rawPlace[i] as number]?.push(i)); + const words = raw.map((s) => new Set(s.norm.split(" ").filter((w) => w.length >= 3))); + byPlace.forEach((list, p) => { + if (list.length < 3) return; + const located = (i: number): boolean => raw[i]?.lat !== null && raw[i]?.lon !== null; + const score = (i: number): [number, number, number] => { + let same = 0; + let shared = 0; + for (const j of list) { + if (j === i) continue; + if (raw[j]?.norm === raw[i]?.norm) same++; + if ([...(words[i] as Set)].some((w) => words[j]?.has(w))) shared++; + } + return [same, shared, calls[i] as number]; + }; + const better = (a: [number, number, number], b: [number, number, number]): boolean => + a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2]; + const cur = namedBy[p] as number; + let best = cur; + let bestScore = score(cur); + for (const i of list) { + if (located(i) !== located(cur)) continue; + const sc = score(i); + if (better(sc, bestScore)) { + best = i; + bestScore = sc; + } + } + if (best === cur) return; + const s = raw[best] as RawStation; + const place = this.places[p] as Place; + namedBy[p] = best; + place.name = s.name; + place.lat = s.lat; + place.lon = s.lon; + }); + this.placeOf = stationRaw.map((ids) => Int32Array.from(ids, (i) => rawPlace[i] as number)); + const n = this.places.length; + const change = opts.change ?? 10; + const changeOperators = opts.changeOperators ?? 15; + this.change = Int32Array.from(this.places, (p) => + new Set(p.stations.map((s) => s.operator)).size > 1 ? changeOperators : change, + ); + + // 3. Towns: places whose names start with one town's name, near each other. + const townWord = this.places.map((p) => { + const w = normalize(p.name).split(" ")[0] ?? ""; + return w.length >= 3 && !NOT_A_TOWN.has(w) && !/^\d+$/.test(w) ? w : ""; + }); + const town = new UnionFind(n); + const placedLocated = this.places.flatMap((p, i) => (p.lat === null || p.lon === null ? [] : [{ lat: p.lat, lon: p.lon, i }])); + closePairs(placedLocated, TOWN_KM, (a, b) => { + if (townWord[a] && townWord[a] === townWord[b]) town.union(a, b); + }); + const townsByWord = new Map>(); + placedLocated.forEach(({ i }) => { + const w = townWord[i] as string; + if (!w) return; + const set = townsByWord.get(w) ?? new Set(); + set.add(town.find(i)); + townsByWord.set(w, set); + }); + this.places.forEach((p, i) => { + const w = townWord[i] as string; + if (p.lat !== null || !w) return; + const roots = townsByWord.get(w); + if (roots?.size === 1) town.union(i, [...roots][0] as number); + }); + const members = new Map(); + for (let i = 0; i < n; i++) { + const r = town.find(i); + const list = members.get(r); + if (list) list.push(i); + else members.set(r, [i]); + } + this.walks = Array.from({ length: n }, () => []); + const usedIds = new Set(); + for (const list of members.values()) { + if (list.length < 2) continue; + for (const a of list) { + for (const b of list) { + if (a === b) continue; + const pa = this.places[a] as Place; + const pb = this.places[b] as Place; + const minutes = + pa.lat === null || pa.lon === null || pb.lat === null || pb.lon === null + ? CROSS_TOWN + : Math.min(CROSS_TOWN, Math.max(20, Math.round(15 + 8 * distanceKm(pa.lat, pa.lon, pb.lat, pb.lon)))); + (this.walks[a] as { to: number; minutes: number }[]).push({ to: b, minutes }); + } + } + const word = townWord[list[0] as number] as string; + let id = slug(word); + for (let k = 2; usedIds.has(id); k++) id = `${slug(word)}-${k}`; + usedIds.add(id); + this.cities.set(id, { name: title(word), places: list }); + for (const i of list) (this.places[i] as Place).city = id; + } + this.places.forEach((p) => { + const base = slug(normalize(p.name) || p.name.toLowerCase()); + let id = base; + for (let k = 2; usedIds.has(id); k++) id = `${base}-${k}`; + usedIds.add(id); + p.id = id; + if (!p.city) p.city = id; + }); + } + + /** The place of one operator's station, or -1. */ + placeIndex(operator: string, name: string): number { + const bi = this.bundles.findIndex((b) => b.operator === operator); + if (bi < 0) return -1; + const si = (this.bundles[bi] as SearchBundle).stations.findIndex((s) => s[0] === name); + return si < 0 ? -1 : ((this.placeOf[bi] as Int32Array)[si] ?? -1); + } + + /** Every service date some bundle covers, sorted. */ + dates(): string[] { + return [...new Set(this.bundles.flatMap((b) => b.dates))].sort(); + } + + /** Towns and stations whose names contain the query, best first. */ + find(query: string, limit = 10): Match[] { + const q = normalize(query) || query.toLowerCase().trim(); + if (!q) return []; + const scored: { m: Match; score: number }[] = []; + const score = (names: string[]): number => { + let best = Infinity; + for (const n of names) { + if (n === q) best = Math.min(best, 0); + else if (n.startsWith(q)) best = Math.min(best, 1); + else if (n.includes(q)) best = Math.min(best, 2); + } + return best; + }; + for (const [id, c] of this.cities) { + const s = score([normalize(c.name)]); + // A town ranks above its own stations. + if (s < Infinity) scored.push({ m: { id, name: c.name, kind: "city", places: c.places }, score: s * 1000 - 500 }); + } + this.places.forEach((p, i) => { + const s = score([normalize(p.name), ...p.stations.map((st) => normalize(st.name))]); + if (s < Infinity) scored.push({ m: { id: p.id, name: p.name, kind: "station", places: [i] }, score: s * 1000 - p.stations.length }); + }); + return scored.sort((a, b) => a.score - b.score).slice(0, limit).map((s) => s.m); + } + + /** A town or station by id, else the best match for a name. */ + resolve(idOrName: string): Match | null { + const city = this.cities.get(idOrName); + if (city) return { id: idOrName, name: city.name, kind: "city", places: city.places }; + const i = this.places.findIndex((p) => p.id === idOrName); + if (i >= 0) return { id: idOrName, name: (this.places[i] as Place).name, kind: "station", places: [i] }; + return this.find(idOrName, 1)[0] ?? null; + } + + /** The hops of every trip that can run on `date`, on one clock: minutes from midnight CET of `date`. */ + private hops(date: string): Hops { + const hit = this.hopsCache.get(date); + if (hit) return hit; + // Two passes over the trips running: count, then fill typed arrays (a day of Europe is + // hundreds of thousands of hops; plain arrays of numbers would cost several times more). + const each = (visit: (dep: number, arr: number, from: number, to: number, inst: number, hop: number) => void): void => { + this.bundles.forEach((b, bi) => { + const places = this.placeOf[bi] as Int32Array; + const offset = this.offsets[bi] as number; + DAY_SLOTS.forEach((d, slot) => { + const di = b.dates.indexOf(addDays(date, d)); + if (di < 0) return; + const shift = d * 1440 - offset; + b.trips.forEach((t, ti) => { + if (!runsOn(t[2], di)) return; + const ids = t[3]; + const min = t[4]; + const inst = (this.tripBase[bi] as number) + ti + slot * this.tripCount; + for (let i = 0; i + 1 < ids.length; i++) { + const from = places[ids[i] as number] ?? -1; + const to = places[ids[i + 1] as number] ?? -1; + const dep = min[2 * i + 1] as number; + const arr = min[2 * i + 2] as number; + if (from < 0 || to < 0 || arr < dep) continue; + visit(dep + shift, arr + shift, from, to, inst, i); + } + }); + }); + }); + }; + let count = 0; + let minDep = INF; + let maxDep = -INF; + each((dep) => { + count++; + if (dep < minDep) minDep = dep; + if (dep > maxDep) maxDep = dep; + }); + // Counting sort by departure minute: times span a few days, so it's linear. + const buckets = new Int32Array(count ? maxDep - minDep + 2 : 1); + each((dep) => { + buckets[dep - minDep + 1] = (buckets[dep - minDep + 1] as number) + 1; + }); + for (let i = 1; i < buckets.length; i++) buckets[i] = (buckets[i] as number) + (buckets[i - 1] as number); + const hops: Hops = { + dep: new Int32Array(count), + arr: new Int32Array(count), + from: new Int32Array(count), + to: new Int32Array(count), + inst: new Int32Array(count), + hop: new Int16Array(count), + }; + each((dep, arr, from, to, inst, hop) => { + const at = buckets[dep - minDep] as number; + buckets[dep - minDep] = at + 1; + hops.dep[at] = dep; + hops.arr[at] = arr; + hops.from[at] = from; + hops.to[at] = to; + hops.inst[at] = inst; + hops.hop[at] = hop; + }); + this.hopsCache.set(date, hops); + // Keep the last few dates searched. + while (this.hopsCache.size > 4) this.hopsCache.delete(this.hopsCache.keys().next().value as string); + return hops; + } + + /** Journeys from one town or station to another on `date` (local "YYYY-MM-DD" at the origin). */ + journeys(from: Match | number[], to: Match | number[], date: string, opts: SearchOptions = {}): Journey[] { + const origins = Array.isArray(from) ? from : from.places; + const targets = Array.isArray(to) ? to : to.places; + if (origins.length === 0 || targets.length === 0 || origins.some((p) => targets.includes(p))) return []; + const wanted = opts.results ?? 5; + const maxTrains = Math.min(8, Math.max(1, opts.maxTrains ?? 4)); + const hops = this.hops(date); + const originOffset = this.offsetAt(origins[0] as number); + let start = (opts.time ? Number(opts.time.slice(0, 2)) * 60 + Number(opts.time.slice(3, 5)) : 0) - originOffset; + // Journeys leave on the date asked for, by the origin's clock; they may arrive later. + const dayEnd = 1440 - originOffset; + const found: Found[] = []; + const seen = new Set(); + for (let pass = 0; pass < wanted * 4 && start < dayEnd; pass++) { + const batch = this.scan(hops, origins, targets, start, maxTrains, date).filter((j) => j.dep < dayEnd); + if (batch.length === 0) break; + let first = Infinity; + for (const j of batch) { + first = Math.min(first, j.dep); + const key = j.journey.legs.map((l) => `${l.operator}/${l.trainNo}/${l.departure}`).join("|"); + if (seen.has(key)) continue; + seen.add(key); + found.push(j); + } + start = first + 1; + if (paretoFront(found).length >= wanted) break; + } + return paretoFront(found) + .sort((a, b) => a.dep - b.dep || a.arr - b.arr) + .slice(0, wanted) + .map((j) => j.journey); + } + + private offsetAt(place: number): number { + const op = this.places[place]?.stations[0]?.operator ?? ""; + const bi = this.bundles.findIndex((b) => b.operator === op); + return bi >= 0 ? (this.offsets[bi] as number) : 0; + } + + /** + * One connection scan from `start`, with one label per number of trains. With targets, + * it stops once nothing leaving later can reach one sooner; without, it labels every + * place reached before `until`. + */ + private labels(h: Hops, origins: number[], isTarget: Uint8Array | null, start: number, K: number, until = INF): Labels { + const n = this.places.length; + // ready[k*n + p]: earliest time to board a train at p having used at most k trains. + const ready = new Int32Array((K + 1) * n).fill(INF); + // How it was reached: -1 the origin, -2 by arriving there, else the place walked from. + const readyFrom = new Int32Array((K + 1) * n).fill(-1); + const readyLevel = new Int8Array((K + 1) * n); + // arrival[k*n + p], and the leg that got there: boarding hop and alighting hop. + const arrival = new Int32Array((K + 1) * n).fill(INF); + const legBoard = new Int32Array((K + 1) * n); + const legAlight = new Int32Array((K + 1) * n); + const atTarget = new Int32Array(K + 1).fill(INF); + const targetPlace = new Int32Array(K + 1).fill(-1); + for (const o of origins) { + for (let k = 0; k <= K; k++) ready[k * n + o] = start; + for (const w of this.walks[o] ?? []) { + if (isTarget?.[w.to] || origins.includes(w.to)) continue; + for (let k = 0; k <= K; k++) ready[k * n + w.to] = Math.min(ready[k * n + w.to] as number, start + w.minutes); + } + } + // Per trip instance: the fewest trains used once on it, and where it was boarded. + const onTrip = new Int8Array(this.tripCount * DAY_SLOTS.length).fill(127); + const boardHop = new Int32Array(this.tripCount * DAY_SLOTS.length); + + let lo = 0; + let hi = h.dep.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if ((h.dep[mid] as number) < start) lo = mid + 1; + else hi = mid; + } + let best = until; + for (let c = lo; c < h.dep.length; c++) { + const dep = h.dep[c] as number; + if (dep >= best) break; + const inst = h.inst[c] as number; + const from = h.from[c] as number; + let k = onTrip[inst] as number; + for (let j = 1; j < Math.min(k, K + 1); j++) { + if ((ready[(j - 1) * n + from] as number) <= dep) { + k = j; + onTrip[inst] = j; + boardHop[inst] = c; + break; + } + } + if (k > K) continue; + const to = h.to[c] as number; + const arr = h.arr[c] as number; + const at = k * n + to; + if (arr >= (arrival[at] as number)) continue; + arrival[at] = arr; + legBoard[at] = boardHop[inst] as number; + legAlight[at] = c; + if (isTarget?.[to]) { + for (let kk = k; kk <= K; kk++) { + if (arr < (atTarget[kk] as number)) { + atTarget[kk] = arr; + targetPlace[kk] = to; + } + } + best = Math.min(best, arr); + continue; + } + const r = arr + (this.change[to] as number); + for (let kk = k; kk <= K; kk++) { + if (r < (ready[kk * n + to] as number)) { + ready[kk * n + to] = r; + readyFrom[kk * n + to] = -2; + readyLevel[kk * n + to] = k; + } + for (const w of this.walks[to] ?? []) { + const rw = arr + w.minutes; + if (rw < (ready[kk * n + w.to] as number)) { + ready[kk * n + w.to] = rw; + readyFrom[kk * n + w.to] = to; + readyLevel[kk * n + w.to] = k; + } + } + } + } + return { n, K, arrival, legBoard, legAlight, readyFrom, readyLevel, atTarget, targetPlace }; + } + + /** For each number of trains, the earliest arrival at a target, where one more train arrives sooner. */ + private scan(h: Hops, origins: number[], targets: number[], start: number, maxTrains: number, date: string): Found[] { + const isTarget = new Uint8Array(this.places.length); + for (const t of targets) isTarget[t] = 1; + const l = this.labels(h, origins, isTarget, start, maxTrains); + const out: Found[] = []; + let previous = INF; + for (let k = 1; k <= maxTrains; k++) { + const a = l.atTarget[k] as number; + if (a >= previous) continue; + previous = a; + const hops = this.trace(h, l, k, l.targetPlace[k] as number); + const journey = hops && this.journeyOf(h, hops, date); + if (journey) out.push(journey); + } + return out; + } + + /** + * Every place reachable from `from` on `date`, each with its fastest journey (the + * shortest door to door, then the earliest). Departures are tried every `step` minutes + * from `opts.time`, and journeys may take up to `maxMinutes`. + */ + reachable( + from: Match | number[], + date: string, + opts: Omit & { step?: number; maxMinutes?: number } = {}, + ): Map { + const origins = Array.isArray(from) ? from : from.places; + const out = new Map(); + if (origins.length === 0) return out; + const K = Math.min(8, Math.max(1, opts.maxTrains ?? 4)); + const step = Math.max(15, opts.step ?? 60); + const maxMinutes = opts.maxMinutes ?? 24 * 60; + const h = this.hops(date); + const originOffset = this.offsetAt(origins[0] as number); + const first = (opts.time ? Number(opts.time.slice(0, 2)) * 60 + Number(opts.time.slice(3, 5)) : 0) - originOffset; + const dayEnd = 1440 - originOffset; + const skip = new Set(origins); + for (const o of origins) for (const w of this.walks[o] ?? []) skip.add(w.to); + const best = new Map(); + for (let start = first; start < dayEnd; start += step) { + const l = this.labels(h, origins, null, start, K, start + maxMinutes); + for (let p = 0; p < l.n; p++) { + if (skip.has(p)) continue; + let k = 0; + let a = INF; + for (let kk = 1; kk <= K; kk++) { + const v = l.arrival[kk * l.n + p] as number; + if (v < a) { + a = v; + k = kk; + } + } + if (k === 0) continue; + const hops = this.trace(h, l, k, p); + if (!hops?.length) continue; + const dep = h.dep[(hops[0] as [number, number])[0]] as number; + if (dep >= dayEnd) continue; + const prev = best.get(p); + if (!prev || a - dep < prev.arr - prev.dep || (a - dep === prev.arr - prev.dep && a < prev.arr)) best.set(p, { dep, arr: a, hops }); + } + } + for (const [p, b] of best) { + const j = this.journeyOf(h, b.hops, date); + if (j) out.set(p, j.journey); + } + return out; + } + + /** The hops (boarding, alighting) of the journey labelled at `place` with `level` trains. */ + private trace(h: Hops, l: Labels, level: number, place: number): [number, number][] | null { + const { n, arrival, legBoard, legAlight, readyFrom, readyLevel } = l; + const hops: [number, number][] = []; + let p = place; + let k = level; + // The label at the fewest trains that got there. + while (k > 1 && (arrival[(k - 1) * n + p] as number) <= (arrival[k * n + p] as number)) k--; + for (let guard = 0; guard < 16; guard++) { + const at = k * n + p; + if ((arrival[at] as number) >= INF) return null; + const board = legBoard[at] as number; + hops.unshift([board, legAlight[at] as number]); + const boardPlace = h.from[board] as number; + if (k === 1) return hops; + const r = (k - 1) * n + boardPlace; + const how = readyFrom[r] as number; + if (how === -1) return hops; + p = how === -2 ? boardPlace : how; + k = readyLevel[r] as number; + } + return null; + } + + private journeyOf(h: Hops, hops: [number, number][], date: string): Found | null { + const legs: Leg[] = []; + for (const [b, a] of hops) { + const inst = h.inst[b] as number; + const g = inst % this.tripCount; + let bi = 0; + while (bi + 1 < this.bundles.length && (this.tripBase[bi + 1] as number) <= g) bi++; + const bundle = this.bundles[bi] as SearchBundle; + const trip = bundle.trips[g - (this.tripBase[bi] as number)] as SearchBundle["trips"][number]; + const offset = this.offsets[bi] as number; + const first = h.hop[b] as number; + const last = (h.hop[a] as number) + 1; + const leg: Leg = { + operator: bundle.operator, + trainNo: trip[0], + from: bundle.stations[trip[3][first] as number]?.[0] ?? "", + to: bundle.stations[trip[3][last] as number]?.[0] ?? "", + departure: stamp(date, (h.dep[b] as number) + offset), + arrival: stamp(date, (h.arr[a] as number) + offset), + stops: last - first, + }; + if (trip[1] >= 0) leg.category = bundle.families[trip[1]]; + legs.push(leg); + } + const first = hops[0]; + const last = hops[hops.length - 1]; + const l0 = legs[0]; + const ln = legs[legs.length - 1]; + if (!first || !last || !l0 || !ln) return null; + const dep = h.dep[first[0]] as number; + const arr = h.arr[last[1]] as number; + return { + dep, + arr, + journey: { departure: l0.departure, arrival: ln.arrival, duration: arr - dep, changes: legs.length - 1, legs }, + }; + } +} + +/** Drop every journey another beats: leaving no earlier, arriving no later, with no more changes. */ +function paretoFront(list: Found[]): Found[] { + return list.filter( + (j) => + !list.some( + (k) => + k !== j && + k.dep >= j.dep && + k.arr <= j.arr && + k.journey.changes <= j.journey.changes && + (k.dep > j.dep || k.arr < j.arr || k.journey.changes < j.journey.changes), + ), + ); +} diff --git a/src/europe/europe.worker.ts b/src/europe/europe.worker.ts new file mode 100644 index 0000000..1609068 --- /dev/null +++ b/src/europe/europe.worker.ts @@ -0,0 +1,190 @@ +/// +// The Interrail Europe worker. At start it downloads train-api's v2 index and every +// operator's station list (small), for the station fields and the map. Each search then +// downloads only the trip bundles of the operators it needs (see Europe.operatorsFor), +// once per visit, and answers off the main thread. +// +// Offline mode (opt-in, from Settings): every file is also kept in Cache Storage and read +// from there when the network fails; a sync downloads every operator's files, again only +// when train-api has published newer ones. + +import type { SearchBundle } from "./engine"; +import { OFFLINE_CACHE } from "./cache"; +import { Europe, type EuropeJourney, type EuropePlace, type ReachRow, type StationList } from "./network"; + +export type EuropeRequest = + | { id: number; type: "init"; base: string; sncfCoords: Record; offline: boolean } + | { id: number; type: "journeys"; from: string; to: string; dates: string[]; maxTrains: number } + | { id: number; type: "reach"; from: string; dates: string[]; maxTrains: number } + | { id: number; type: "offline"; on: boolean }; + +export interface EuropeDirectory { + places: EuropePlace[]; + /** Each operator's booking site for pass holders, by train-api operator id. */ + booking: Record; + updatedAt: string; + /** Bytes of every operator's Interrail trips: what offline mode keeps on the device. */ + bytes: number; +} + +/** What offline mode holds: the timetables' date, or nothing kept. */ +export interface OfflineState { + on: boolean; + /** train-api's updatedAt of the copy kept, "" when none is complete. */ + updatedAt: string; +} + +export type EuropeReply = + | { id: number; type: "init"; directory: EuropeDirectory | null } + // byDate is null when the network doesn't serve a station asked for (SNCF's trains answer). + | { id: number; type: "journeys"; byDate: Record | null } + | { id: number; type: "reach"; byDate: Record | null } + | { id: number; type: "offline"; state: OfflineState }; + +interface IndexV2 { + updatedAt?: string; + operators?: { id: string; passes?: { id: string; bookingUrl?: string; bytes?: number }[] }[]; +} + +const PASS = "interrail"; + +let base = ""; +let offline = false; +let europe: Europe | null = null; + +const hasCaches = (): boolean => typeof caches !== "undefined"; + +/** A train-api file: from the network, kept for offline mode; from the kept copy when the network fails. */ +async function get(path: string): Promise { + const url = `${base}/v2/${path}`; + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + if (offline && hasCaches()) await (await caches.open(OFFLINE_CACHE)).put(url, res.clone()).catch(() => undefined); + return (await res.json()) as T; + } catch (err) { + const kept = hasCaches() ? await (await caches.open(OFFLINE_CACHE)).match(url).catch(() => undefined) : undefined; + if (kept) return (await kept.json()) as T; + throw err; + } +} + +function isBundle(x: unknown): x is SearchBundle { + const b = x as Partial | null; + return !!b && b.v === 2 && typeof b.operator === "string" && Array.isArray(b.dates) && Array.isArray(b.stations) && Array.isArray(b.trips); +} + +function interrailOperators(index: IndexV2): { id: string; bookingUrl?: string; bytes?: number }[] { + return (index.operators ?? []).flatMap((o) => { + const pass = o.passes?.find((p) => p.id === PASS); + return pass ? [{ id: o.id, bookingUrl: pass.bookingUrl, bytes: pass.bytes }] : []; + }); +} + +async function init(m: Extract): Promise { + base = m.base; + offline = m.offline; + const index = await get("index.json"); + const ops = interrailOperators(index); + // An operator whose stations fail to load is left out; the others still search. + const lists = ( + await Promise.all( + ops.map((o) => + get(`${o.id}/stations.json`) + .then((stations): StationList | null => (Array.isArray(stations) ? { operator: o.id, stations } : null)) + .catch(() => null), + ), + ) + ).filter((l): l is StationList => l !== null); + if (lists.length === 0) return null; + europe = new Europe(lists, m.sncfCoords); + const booking: Record = {}; + for (const o of ops) if (o.bookingUrl) booking[o.id] = o.bookingUrl; + return { + places: europe.directory(), + booking, + updatedAt: index.updatedAt ?? "", + bytes: ops.reduce((n, o) => n + (o.bytes ?? 0), 0), + }; +} + +/** Load the trip bundles of these operators not loaded yet (one that fails is left out). */ +async function ensure(net: Europe, ops: string[]): Promise { + const bundles = await Promise.all(net.missing(ops).map((op) => get(`${op}/${PASS}.json`).catch(() => null))); + for (const b of bundles) if (isBundle(b)) net.addBundle(b); +} + +/** The updatedAt of the complete copy kept, or "". */ +async function keptState(): Promise { + if (!hasCaches()) return ""; + const cache = await caches.open(OFFLINE_CACHE); + const index = (await (await cache.match(`${base}/v2/index.json`))?.json().catch(() => null)) as IndexV2 | null; + if (!index) return ""; + for (const o of interrailOperators(index)) { + if (!(await cache.match(`${base}/v2/${o.id}/stations.json`))) return ""; + if (!(await cache.match(`${base}/v2/${o.id}/${PASS}.json`))) return ""; + } + return index.updatedAt ?? ""; +} + +/** Turn offline mode on (download what's missing or outdated) or off (drop the copy). */ +async function setOffline(on: boolean): Promise { + offline = on; + if (!hasCaches() || !base) return { on, updatedAt: "" }; + if (!on) { + await caches.delete(OFFLINE_CACHE); + return { on, updatedAt: "" }; + } + const cache = await caches.open(OFFLINE_CACHE); + const indexUrl = `${base}/v2/index.json`; + const kept = (await (await cache.match(indexUrl))?.json().catch(() => null)) as IndexV2 | null; + let res: Response; + try { + res = await fetch(indexUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch { + return { on, updatedAt: await keptState() }; // offline now: keep what is there + } + const index = (await res.clone().json()) as IndexV2; + const fresh = kept?.updatedAt !== index.updatedAt; + for (const o of interrailOperators(index)) { + for (const path of [`${o.id}/stations.json`, `${o.id}/${PASS}.json`]) { + const url = `${base}/v2/${path}`; + if (!fresh && (await cache.match(url))) continue; + const file = await fetch(url).catch(() => null); + if (file?.ok) await cache.put(url, file); + } + } + // The index goes in last: a copy is complete only once every file it lists is in. + await cache.put(indexUrl, res); + return { on, updatedAt: await keptState() }; +} + +const ctx = self as unknown as { + postMessage: (m: EuropeReply) => void; + onmessage: ((e: MessageEvent) => void) | null; +}; + +async function answer(m: EuropeRequest): Promise { + if (m.type === "init") return { id: m.id, type: "init", directory: await init(m).catch(() => null) }; + if (m.type === "offline") return { id: m.id, type: "offline", state: await setOffline(m.on).catch(() => ({ on: m.on, updatedAt: "" })) }; + const net = europe; + if (m.type === "journeys") { + if (!net?.knows(m.from) || !net.knows(m.to)) return { id: m.id, type: "journeys", byDate: null }; + const ops = net.operatorsFor(m.from, m.to); + await ensure(net, ops); + const byDate: Record = {}; + for (const d of m.dates) byDate[d] = net.journeys(m.from, m.to, d, m.maxTrains, ops); + return { id: m.id, type: "journeys", byDate }; + } + if (!net?.knows(m.from)) return { id: m.id, type: "reach", byDate: null }; + const ops = net.operatorsFor(m.from); + await ensure(net, ops); + const byDate: Record = {}; + for (const d of m.dates) byDate[d] = net.reachable(m.from, d, m.maxTrains, ops); + return { id: m.id, type: "reach", byDate }; +} + +ctx.onmessage = (e: MessageEvent): void => { + void answer(e.data).then((reply) => ctx.postMessage(reply)); +}; diff --git a/src/europe/network.ts b/src/europe/network.ts new file mode 100644 index 0000000..f9f31a6 --- /dev/null +++ b/src/europe/network.ts @@ -0,0 +1,292 @@ +// Interrail across Europe on train-api's v2 files, with Railmap's station ids. Runs in the +// Europe worker; pure, so tests drive it directly. +// +// It scales by loading little: every operator's station list (small) makes the directory +// (the station fields, the map, and which operators meet in which town). A search then +// needs only the trip bundles of the operators on the way between its two towns (Paris to +// Madrid: SNCF and Renfe, not Finland's VR), and builds a search network over those alone. +// +// Station ids: a place SNCF serves keeps SNCF's name (the id the rest of the app, its map +// and its MAX data already use); any other place takes the directory's id ("amsterdam-centraal"). + +import { Network, type SearchBundle, type Journey as EngineJourney } from "./engine"; + +/** One operator's stations, as `v2//stations.json` lists them. */ +export interface StationList { + operator: string; + stations: [string, number | null, number | null][]; +} + +/** One place of the network, as the app lists and maps it. */ +export interface EuropePlace { + /** The app's station id. */ + id: string; + name: string; + lat: number | null; + lon: number | null; + /** The town's name ("Madrid" for each Madrid station). */ + town: string; +} + +/** One train of a journey, between app station ids, in local times "YYYY-MM-DDTHH:MM". */ +export interface EuropeLeg { + operator: string; + trainNo: string; + category?: string; + from: string; + to: string; + departure: string; + arrival: string; +} + +export interface EuropeJourney { + /** Minutes door to door, time zones accounted for. */ + duration: number; + legs: EuropeLeg[]; +} + +/** The fastest journey to one town, for the explore list. */ +export interface ReachRow { + /** App id of the place the journey arrives at. */ + station: string; + journey: EuropeJourney; +} + +/** Towns an explore search returns at most, busiest first. */ +export const REACH_TOWNS = 150; + +/** Search networks kept built, by operator set: a day's calendar reuses one. */ +const KEEP_NETWORKS = 2; + +/** A search network over some operators, and where each operator's station sits in it. */ +interface SearchNet { + net: Network; + placeOf: Map; +} + +const key = (operator: string, name: string): string => `${operator}\u0000${name}`; + +/** Breadth-first distances from `start` in the operator graph. */ +function distances(graph: Map>, start: Iterable): Map { + const dist = new Map(); + let frontier = [...new Set(start)]; + for (const op of frontier) dist.set(op, 0); + for (let d = 1; frontier.length > 0; d++) { + const next: string[] = []; + for (const op of frontier) { + for (const n of graph.get(op) ?? []) { + if (dist.has(n)) continue; + dist.set(n, d); + next.push(n); + } + } + frontier = next; + } + return dist; +} + +export class Europe { + /** Every operator's stations, no trips: the station directory. */ + private readonly dir: Network; + /** App station id of each directory place. */ + private readonly ids: string[]; + private readonly byId = new Map(); + /** "operator\0station name" → directory place. */ + private readonly byStation = new Map(); + /** Town id → its directory places. */ + private readonly towns = new Map(); + /** Operators that meet in some town: a journey can change from one to the other. */ + private readonly graph = new Map>(); + private readonly sncfCoords: Record; + private readonly bundles = new Map(); + private readonly nets = new Map(); + + /** + * `sncfCoords` places SNCF's stations, which its open data leaves without coordinates, + * so they meet the other operators' stations on the map and in town. + */ + constructor(lists: StationList[], sncfCoords: Record = {}) { + this.sncfCoords = sncfCoords; + this.dir = new Network( + lists.map((l) => ({ v: 2, operator: l.operator, dates: [], stations: this.placed(l.operator, l.stations), families: [], trips: [] })), + ); + this.ids = this.dir.places.map((p, i) => { + for (const s of p.stations) this.byStation.set(key(s.operator, s.name), i); + const town = this.towns.get(p.city); + if (town) town.push(i); + else this.towns.set(p.city, [i]); + return p.stations.find((s) => s.operator === "sncf")?.name ?? p.id; + }); + this.ids.forEach((id, i) => { + if (!this.byId.has(id)) this.byId.set(id, i); + }); + // Every SNCF name answers, even when two share a place. + this.dir.places.forEach((p, i) => { + for (const s of p.stations) if (s.operator === "sncf" && !this.byId.has(s.name)) this.byId.set(s.name, i); + }); + for (const l of lists) this.graph.set(l.operator, new Set()); + for (const members of this.towns.values()) { + const ops = new Set(members.flatMap((i) => (this.dir.places[i]?.stations ?? []).map((s) => s.operator))); + for (const a of ops) for (const b of ops) if (a !== b) this.graph.get(a)?.add(b); + } + } + + /** SNCF's stations with the app's positions. */ + private placed(operator: string, stations: StationList["stations"]): StationList["stations"] { + if (operator !== "sncf") return stations; + return stations.map(([name, lat, lon]) => { + const at = this.sncfCoords[name]; + return lat === null && at ? [name, at[0], at[1]] : [name, lat, lon]; + }); + } + + /** Every place, as the app lists it. */ + directory(): EuropePlace[] { + return this.dir.places.map((p, i) => ({ id: this.ids[i] as string, name: p.name, lat: p.lat, lon: p.lon, town: this.townName(i) })); + } + + private townName(place: number): string { + const p = this.dir.places[place]; + if (!p) return ""; + const members = this.towns.get(p.city) ?? []; + return members.length > 1 ? (this.dir.resolve(p.city)?.name ?? p.name) : p.name; + } + + /** Does the network serve this app station id? */ + knows(id: string): boolean { + return this.byId.has(id); + } + + /** The directory places of the town an app station id belongs to: a search from or to it covers them all. */ + private town(id: string): number[] { + const i = this.byId.get(id); + if (i === undefined) return []; + return this.towns.get((this.dir.places[i] as { city: string }).city) ?? [i]; + } + + private operatorsAt(id: string): Set { + return new Set(this.town(id).flatMap((i) => (this.dir.places[i]?.stations ?? []).map((s) => s.operator))); + } + + /** + * The operators a search needs. Between two towns: every operator on a shortest chain of + * operators that meet in a town, or on one a single operator longer (a detour that may + * still be faster). Explore from a town: its operators and their neighbours. + */ + operatorsFor(from: string, to?: string): string[] { + const a = this.operatorsAt(from); + const fromA = distances(this.graph, a); + if (to === undefined) return [...fromA].filter(([, d]) => d <= 1).map(([op]) => op).sort(); + const b = this.operatorsAt(to); + const fromB = distances(this.graph, b); + let shortest = Infinity; + for (const [op, d] of fromA) shortest = Math.min(shortest, d + (fromB.get(op) ?? Infinity)); + if (shortest === Infinity) return [...new Set([...a, ...b])].sort(); + return [...fromA].filter(([op, d]) => d + (fromB.get(op) ?? Infinity) <= shortest + 1).map(([op]) => op).sort(); + } + + /** Of `operators`, those whose trip bundle isn't loaded yet. */ + missing(operators: string[]): string[] { + return operators.filter((op) => !this.bundles.has(op)); + } + + addBundle(b: SearchBundle): void { + this.bundles.set(b.operator, { ...b, stations: this.placed(b.operator, b.stations) }); + } + + /** The search network over these operators' loaded bundles, built once. */ + private network(operators: string[]): SearchNet | null { + const ops = operators.filter((op) => this.bundles.has(op)).sort(); + if (ops.length === 0) return null; + const k = ops.join(","); + let hit = this.nets.get(k); + if (hit) { + this.nets.delete(k); + this.nets.set(k, hit); + return hit; + } + const net = new Network(ops.map((op) => this.bundles.get(op) as SearchBundle)); + const placeOf = new Map(); + net.places.forEach((p, i) => { + for (const s of p.stations) placeOf.set(key(s.operator, s.name), i); + }); + hit = { net, placeOf }; + this.nets.set(k, hit); + while (this.nets.size > KEEP_NETWORKS) this.nets.delete(this.nets.keys().next().value as string); + return hit; + } + + /** The places of a search network that stand for an app station's town. */ + private places(s: SearchNet, id: string): number[] { + const out = new Set(); + for (const i of this.town(id)) { + for (const st of this.dir.places[i]?.stations ?? []) { + const p = s.placeOf.get(key(st.operator, st.name)); + if (p !== undefined) out.add(p); + } + } + return [...out]; + } + + /** Journeys leaving `from` on `date` with at most `maxTrains` trains, earliest first, over the loaded `operators`. */ + journeys(from: string, to: string, date: string, maxTrains: number, operators = this.operatorsFor(from, to), results = 12): EuropeJourney[] { + const s = this.network(operators); + if (!s) return []; + const a = this.places(s, from); + const b = this.places(s, to); + if (a.length === 0 || b.length === 0) return []; + return s.net.journeys(a, b, date, { maxTrains, results }).map((j) => this.convert(j)); + } + + /** + * Every town reachable from `from` on `date`, each with its fastest journey, keeping the + * {@link REACH_TOWNS} busiest: a pass reaches thousands of village halts in a day. + */ + reachable(from: string, date: string, maxTrains: number, operators = this.operatorsFor(from), step = 120): ReachRow[] { + const s = this.network(operators); + if (!s) return []; + const origins = this.places(s, from); + if (origins.length === 0) return []; + const reached = s.net.reachable(origins, date, { maxTrains, step }); + const best = new Map(); + const townCalls = new Map(); + for (const [place, journey] of reached) { + const p = s.net.places[place]; + const st = p?.stations[0]; + const d = st ? this.byStation.get(key(st.operator, st.name)) : undefined; + const town = d === undefined ? undefined : this.dir.places[d]?.city; + if (!p || d === undefined || !town) continue; + townCalls.set(town, (townCalls.get(town) ?? 0) + p.calls); + const cur = best.get(town); + if (!cur || journey.duration < cur.journey.duration) best.set(town, { place: d, journey }); + } + return [...best.entries()] + .sort((x, y) => (townCalls.get(y[0]) ?? 0) - (townCalls.get(x[0]) ?? 0)) + .slice(0, REACH_TOWNS) + .map(([, b]) => ({ station: this.ids[b.place] as string, journey: this.convert(b.journey) })); + } + + private convert(j: EngineJourney): EuropeJourney { + return { + duration: j.duration, + legs: j.legs.map((l) => { + const leg: EuropeLeg = { + operator: l.operator, + trainNo: l.trainNo, + from: this.stationId(l.operator, l.from), + to: this.stationId(l.operator, l.to), + departure: l.departure, + arrival: l.arrival, + }; + if (l.category) leg.category = l.category; + return leg; + }), + }; + } + + private stationId(operator: string, name: string): string { + if (operator === "sncf") return name; + const i = this.byStation.get(key(operator, name)); + return i === undefined ? name : (this.ids[i] as string); + } +} diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index cbfbc8d..6d66134 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -110,6 +110,11 @@ export const ar: Dict = { set_compact_hint: "مزيد من القطارات في الشاشة.", set_fees: "رسوم حجز إنتررييل", set_fees_hint: "يعرض تكلفة الحجز الإلزامي لكل قطار، في الدرجة الثانية.", + set_offline: "جداول أوروبا دون اتصال", + set_offline_hint: "يحفظ جداول كل دول Interrail على هذا الجهاز لتعمل عمليات البحث دون اتصال. تُحدَّث عند صدور جداول جديدة.", + set_offline_size: "نحو {mb} ميغابايت.", + set_offline_loading: "جارٍ التنزيل…", + set_offline_kept: "على هذا الجهاز: جداول {date}.", set_lowend: "جهاز ضعيف", set_lowend_hint: "تقليل الحركة وإخفاء الخريطة وضغط القائمة بضغطة واحدة.", lowend_prompt_title: "تسريع التطبيق؟", @@ -133,6 +138,7 @@ export const ar: Dict = { lbl_train: "القطار {no}", lbl_dayoffset: "+{n} ي", act_book: "احجز على SNCF Connect", + act_book_on: "احجز على {site}", act_next: "التالي", act_results: "النتائج", act_book_leg: "احجز القطار {n}", @@ -308,8 +314,8 @@ export const ar: Dict = { fee_chip: "الحجز {fee}", fee_note: "رسوم حجز إنتررييل تقريبية، الدرجة الثانية.", fee_link: "رسوم إنتررييل", - tagline_ir: "قطارات SNCF بتذكرة Interrail.", - how_note_ir: "تعرض هذه الأداة القطارات التي تسير (البيانات المفتوحة لـ SNCF)؛ ولا تعرف إن بقي مقعد لحاملي التذكرة. تحقق على SNCF Connect.", + tagline_ir: "قطارات في أنحاء أوروبا بتذكرة Interrail.", + how_note_ir: "تعرض هذه الأداة القطارات التي تسير (البيانات المفتوحة للمشغّلين)؛ ولا تعرف إن بقي مقعد لحاملي التذكرة. تحقق عند الحجز.", multi_incomplete_ir: "بعض المقاطع لا يوجد فيها قطار — هذا المسار غير مكتمل.", tour_none_ir: "لا توجد جولة لهذه المدن والتواريخ.", res_none_ir: "لا يوجد قطار لهذه المعايير.", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index b54f21e..2f7a0ac 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -110,6 +110,11 @@ export const de: Dict = { set_compact_hint: "Mehr Züge pro Bildschirm.", set_fees: "Interrail-Reservierungsgebühren", set_fees_hint: "Zeigt, was die Pflichtreservierung jedes Zuges in der 2. Klasse kostet.", + set_offline: "Europas Fahrpläne offline", + set_offline_hint: "Speichert die Fahrpläne aller Interrail-Länder auf diesem Gerät, damit die Suche ohne Verbindung funktioniert. Sie werden aktualisiert, sobald neue erscheinen.", + set_offline_size: "Etwa {mb} MB.", + set_offline_loading: "Wird heruntergeladen…", + set_offline_kept: "Auf diesem Gerät: Fahrpläne vom {date}.", set_lowend: "Schwaches Gerät", set_lowend_hint: "Bewegung reduzieren, Karte ausblenden und Liste kompakt — mit einem Tipp.", lowend_prompt_title: "App beschleunigen?", @@ -133,6 +138,7 @@ export const de: Dict = { lbl_train: "Zug {no}", lbl_dayoffset: "+{n} T", act_book: "Auf SNCF Connect buchen", + act_book_on: "Auf {site} buchen", act_next: "Weiter", act_results: "Ergebnisse", act_book_leg: "Zug {n} buchen", @@ -309,8 +315,8 @@ export const de: Dict = { fee_chip: "Reservierung {fee}", fee_note: "Richtwerte für Interrail-Reservierungsgebühren, 2. Klasse.", fee_link: "Interrail-Gebühren", - tagline_ir: "SNCF-Züge mit einem Interrail-Pass.", - how_note_ir: "Dieses Tool zeigt fahrende Züge (offene SNCF-Daten); es sieht nicht, ob noch ein Pass-Platz frei ist. Prüfe auf SNCF Connect.", + tagline_ir: "Züge in ganz Europa mit einem Interrail-Pass.", + how_note_ir: "Dieses Tool zeigt fahrende Züge (offene Daten der Bahnen); es sieht nicht, ob noch ein Pass-Platz frei ist. Prüfe es bei der Buchung.", multi_incomplete_ir: "Für einige Abschnitte gibt es keinen Zug — diese Reise ist unvollständig.", tour_none_ir: "Keine Route für diese Städte und Daten.", res_none_ir: "Keine Züge für diese Kriterien.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 227e5ef..846af06 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -114,6 +114,11 @@ export const en: Dict = { set_compact_hint: "More trains per screen.", set_fees: "Interrail reservation fees", set_fees_hint: "Shows what each train's compulsory reservation costs, 2nd class.", + set_offline: "Europe's timetables offline", + set_offline_hint: "Keeps every Interrail country's timetables on this device, so searches work with no connection. They refresh when new ones come out.", + set_offline_size: "About {mb} MB.", + set_offline_loading: "Downloading…", + set_offline_kept: "On this device: timetables of {date}.", set_lowend: "Low-end device", set_lowend_hint: "Reduce motion, hide the map and compact the list, in one tap.", lowend_prompt_title: "Speed up the app?", @@ -139,6 +144,8 @@ export const en: Dict = { lbl_dayoffset: "+{n} d", act_book: "Book on SNCF Connect", + + act_book_on: "Book on {site}", act_next: "Next step", act_results: "Results", act_book_leg: "Book train {n}", @@ -321,8 +328,8 @@ export const en: Dict = { fee_chip: "Reservation {fee}", fee_note: "Indicative Interrail reservation fees, 2nd class.", fee_link: "Interrail fees", - tagline_ir: "SNCF trains on an Interrail pass.", - how_note_ir: "This tool shows trains that run (SNCF open data); it can't see whether a pass-holder seat is left. Check SNCF Connect.", + tagline_ir: "Trains across Europe on an Interrail pass.", + how_note_ir: "This tool shows trains that run (the operators' open data); it can't see whether a pass-holder seat is left. Check when you book.", multi_incomplete_ir: "Some legs have no train — this itinerary is incomplete.", tour_none_ir: "No tour for these cities and dates.", res_none_ir: "No train for these criteria.", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 16954c9..d2d95fb 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -110,6 +110,11 @@ export const es: Dict = { set_compact_hint: "Más trenes por pantalla.", set_fees: "Suplementos de reserva Interrail", set_fees_hint: "Muestra el precio de la reserva obligatoria de cada tren, en 2.ª clase.", + set_offline: "Horarios de Europa sin conexión", + set_offline_hint: "Guarda los horarios de todos los países Interrail en este dispositivo para buscar sin conexión. Se actualizan cuando salen nuevos.", + set_offline_size: "Unos {mb} MB.", + set_offline_loading: "Descargando…", + set_offline_kept: "En este dispositivo: horarios del {date}.", set_lowend: "Dispositivo básico", set_lowend_hint: "Reduce animaciones, oculta el mapa y compacta la lista, de una vez.", lowend_prompt_title: "¿Acelerar la app?", @@ -133,6 +138,7 @@ export const es: Dict = { lbl_train: "Tren {no}", lbl_dayoffset: "+{n} d", act_book: "Reservar en SNCF Connect", + act_book_on: "Reservar en {site}", act_next: "Siguiente", act_results: "Resultados", act_book_leg: "Reservar el tren {n}", @@ -309,8 +315,8 @@ export const es: Dict = { fee_chip: "Reserva {fee}", fee_note: "Suplementos de reserva Interrail orientativos, en 2.ª clase.", fee_link: "Tarifas Interrail", - tagline_ir: "Trenes SNCF con un pase Interrail.", - how_note_ir: "Esta herramienta muestra los trenes que circulan (datos abiertos de SNCF); no sabe si queda plaza de pase. Verifica en SNCF Connect.", + tagline_ir: "Trenes de toda Europa con un pase Interrail.", + how_note_ir: "Esta herramienta muestra los trenes que circulan (datos abiertos de los operadores); no sabe si queda plaza de pase. Compruébalo al reservar.", multi_incomplete_ir: "Algunos trayectos no tienen tren — este itinerario está incompleto.", tour_none_ir: "Ningún itinerario para estas ciudades y fechas.", res_none_ir: "Ningún tren para estos criterios.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 021e793..9cb598e 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -112,6 +112,11 @@ export const fr = { set_compact_hint: "Plus de trains par écran.", set_fees: "Frais de réservation Interrail", set_fees_hint: "Affiche le prix de la réservation obligatoire de chaque train, en 2de classe.", + set_offline: "Horaires d'Europe hors ligne", + set_offline_hint: "Garde les horaires de tous les pays Interrail sur cet appareil, pour chercher sans connexion. Ils se mettent à jour quand de nouveaux sortent.", + set_offline_size: "Environ {mb} Mo.", + set_offline_loading: "Téléchargement…", + set_offline_kept: "Sur cet appareil : horaires du {date}.", set_lowend: "Appareil bas de gamme", set_lowend_hint: "Réduit les animations, masque la carte et compacte la liste, en un geste.", lowend_prompt_title: "Accélérer l'appli ?", @@ -137,6 +142,8 @@ export const fr = { lbl_dayoffset: "+{n} j", act_book: "Réserver sur SNCF Connect", + + act_book_on: "Réserver sur {site}", act_next: "Étape suivante", act_results: "Résultats", act_book_leg: "Réserver le train {n}", @@ -325,8 +332,8 @@ export const fr = { fee_chip: "Résa {fee}", fee_note: "Frais de réservation Interrail indicatifs, en 2de classe.", fee_link: "Tarifs Interrail", - tagline_ir: "Trains SNCF avec un pass Interrail.", - how_note_ir: "Cet outil affiche les trains qui circulent (open data SNCF) ; il ne sait pas s'il reste une place pass. Vérifiez sur SNCF Connect.", + tagline_ir: "Trains de toute l'Europe avec un pass Interrail.", + how_note_ir: "Cet outil affiche les trains qui circulent (open data des opérateurs) ; il ne sait pas s'il reste une place pass. Vérifiez en réservant.", multi_incomplete_ir: "Certains trajets n'ont aucun train — cet itinéraire est incomplet.", tour_none_ir: "Aucun itinéraire pour ces villes et dates.", res_none_ir: "Aucun train pour ces critères.", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 5f1270e..1088fd1 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -110,6 +110,11 @@ export const it: Dict = { set_compact_hint: "Più treni per schermata.", set_fees: "Supplementi di prenotazione Interrail", set_fees_hint: "Mostra quanto costa la prenotazione obbligatoria di ogni treno, in 2ª classe.", + set_offline: "Orari d'Europa offline", + set_offline_hint: "Tiene gli orari di tutti i paesi Interrail su questo dispositivo, per cercare senza connessione. Si aggiornano quando ne escono di nuovi.", + set_offline_size: "Circa {mb} MB.", + set_offline_loading: "Download in corso…", + set_offline_kept: "Su questo dispositivo: orari del {date}.", set_lowend: "Dispositivo economico", set_lowend_hint: "Riduce animazioni, nasconde la mappa e compatta la lista, con un tocco.", lowend_prompt_title: "Velocizzare l'app?", @@ -133,6 +138,7 @@ export const it: Dict = { lbl_train: "Treno {no}", lbl_dayoffset: "+{n} g", act_book: "Prenota su SNCF Connect", + act_book_on: "Prenota su {site}", act_next: "Avanti", act_results: "Risultati", act_book_leg: "Prenota il treno {n}", @@ -309,8 +315,8 @@ export const it: Dict = { fee_chip: "Prenotazione {fee}", fee_note: "Supplementi di prenotazione Interrail indicativi, 2ª classe.", fee_link: "Tariffe Interrail", - tagline_ir: "Treni SNCF con un pass Interrail.", - how_note_ir: "Questo strumento mostra i treni che circolano (dati aperti SNCF); non sa se resta un posto pass. Verifica su SNCF Connect.", + tagline_ir: "Treni in tutta Europa con un pass Interrail.", + how_note_ir: "Questo strumento mostra i treni che circolano (dati aperti degli operatori); non sa se resta un posto pass. Verifica al momento della prenotazione.", multi_incomplete_ir: "Alcune tratte non hanno treni — questo itinerario è incompleto.", tour_none_ir: "Nessun itinerario per queste città e date.", res_none_ir: "Nessun treno per questi criteri.", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index f856d09..b0e22a2 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -110,6 +110,11 @@ export const ja: Dict = { set_compact_hint: "1画面により多くの列車。", set_fees: "インターレイル予約料金", set_fees_hint: "各列車の必須予約にかかる料金を2等車で表示します。", + set_offline: "ヨーロッパの時刻表をオフラインで", + set_offline_hint: "Interrail 全加盟国の時刻表をこの端末に保存し、接続なしで検索できるようにします。新しい時刻表が出ると更新されます。", + set_offline_size: "約 {mb} MB。", + set_offline_loading: "ダウンロード中…", + set_offline_kept: "この端末に保存:{date} の時刻表。", set_lowend: "低スペック端末", set_lowend_hint: "モーション削減・地図非表示・リスト圧縮をまとめて。", lowend_prompt_title: "アプリを軽くしますか?", @@ -133,6 +138,7 @@ export const ja: Dict = { lbl_train: "列車 {no}", lbl_dayoffset: "+{n}日", act_book: "SNCF Connect で予約", + act_book_on: "{site} で予約", act_next: "次へ", act_results: "結果", act_book_leg: "{n} 本目の列車を予約", @@ -308,8 +314,8 @@ export const ja: Dict = { fee_chip: "予約 {fee}", fee_note: "インターレイルの予約料金(2等車)は目安です。", fee_link: "インターレイル料金", - tagline_ir: "Interrail パスで乗る SNCF の列車。", - how_note_ir: "このツールは運行中の列車(SNCF オープンデータ)を表示します。パス席が残っているかは分かりません。SNCF Connect でご確認ください。", + tagline_ir: "Interrail パスで乗るヨーロッパ各地の列車。", + how_note_ir: "このツールは運行中の列車(各鉄道会社のオープンデータ)を表示します。パス席が残っているかは分かりません。予約時にご確認ください。", multi_incomplete_ir: "一部の区間に列車がありません — この行程は不完全です。", tour_none_ir: "これらの都市・日付の旅程はありません。", res_none_ir: "条件に合う列車はありません。", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 7ead45a..858f1e5 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -110,6 +110,11 @@ export const ko: Dict = { set_compact_hint: "화면당 더 많은 열차.", set_fees: "인터레일 예약 요금", set_fees_hint: "각 열차의 필수 예약 요금을 2등석 기준으로 보여 줍니다.", + set_offline: "유럽 시간표 오프라인", + set_offline_hint: "모든 Interrail 국가의 시간표를 이 기기에 저장해 연결 없이 검색할 수 있게 합니다. 새 시간표가 나오면 업데이트됩니다.", + set_offline_size: "약 {mb}MB.", + set_offline_loading: "다운로드 중…", + set_offline_kept: "이 기기에 저장됨: {date} 시간표.", set_lowend: "저사양 기기", set_lowend_hint: "모션 줄이기, 지도 숨김, 목록 압축을 한 번에.", lowend_prompt_title: "앱을 더 빠르게 할까요?", @@ -133,6 +138,7 @@ export const ko: Dict = { lbl_train: "열차 {no}", lbl_dayoffset: "+{n}일", act_book: "SNCF Connect에서 예약", + act_book_on: "{site}에서 예약", act_next: "다음", act_results: "결과", act_book_leg: "{n}번 열차 예약", @@ -309,8 +315,8 @@ export const ko: Dict = { fee_chip: "예약 {fee}", fee_note: "인터레일 예약 요금(2등석)은 참고용입니다.", fee_link: "인터레일 요금", - tagline_ir: "Interrail 패스로 타는 SNCF 열차.", - how_note_ir: "이 도구는 운행하는 열차(SNCF 공개 데이터)를 표시하며, 패스 좌석이 남았는지는 알 수 없습니다. SNCF Connect에서 확인하세요.", + tagline_ir: "Interrail 패스로 타는 유럽 전역의 열차.", + how_note_ir: "이 도구는 운행하는 열차(각 철도 회사의 공개 데이터)를 표시하며, 패스 좌석이 남았는지는 알 수 없습니다. 예약할 때 확인하세요.", multi_incomplete_ir: "일부 구간에 열차가 없습니다 — 이 일정은 미완성입니다.", tour_none_ir: "해당 도시와 날짜에 대한 일정이 없습니다.", res_none_ir: "조건에 맞는 열차가 없습니다.", diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts index 7db40bf..ec1cc76 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -110,6 +110,11 @@ export const nl: Dict = { set_compact_hint: "Meer treinen per scherm.", set_fees: "Interrail-reserveringskosten", set_fees_hint: "Toont wat de verplichte reservering van elke trein kost, 2e klas.", + set_offline: "Dienstregelingen van Europa offline", + set_offline_hint: "Bewaart de dienstregelingen van alle Interrail-landen op dit apparaat, zodat zoeken zonder verbinding werkt. Ze worden bijgewerkt zodra er nieuwe zijn.", + set_offline_size: "Ongeveer {mb} MB.", + set_offline_loading: "Bezig met downloaden…", + set_offline_kept: "Op dit apparaat: dienstregelingen van {date}.", set_lowend: "Zwak apparaat", set_lowend_hint: "Beweging beperken, kaart verbergen en lijst compact — in één tik.", lowend_prompt_title: "App sneller maken?", @@ -133,6 +138,7 @@ export const nl: Dict = { lbl_train: "Trein {no}", lbl_dayoffset: "+{n} d", act_book: "Boeken op SNCF Connect", + act_book_on: "Boeken op {site}", act_next: "Volgende", act_results: "Resultaten", act_book_leg: "Trein {n} boeken", @@ -308,8 +314,8 @@ export const nl: Dict = { fee_chip: "Reservering {fee}", fee_note: "Indicatieve Interrail-reserveringskosten, 2e klas.", fee_link: "Interrail-tarieven", - tagline_ir: "SNCF-treinen met een Interrail-pas.", - how_note_ir: "Deze tool toont treinen die rijden (open data SNCF); hij ziet niet of er nog een passplaats vrij is. Controleer op SNCF Connect.", + tagline_ir: "Treinen door heel Europa met een Interrail-pas.", + how_note_ir: "Deze tool toont treinen die rijden (open data van de vervoerders); hij ziet niet of er nog een passplaats vrij is. Controleer het bij het boeken.", multi_incomplete_ir: "Sommige trajecten hebben geen trein — deze reis is onvolledig.", tour_none_ir: "Geen route voor deze steden en data.", res_none_ir: "Geen trein voor deze criteria.", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index e0c15f2..537e122 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -110,6 +110,11 @@ export const pt: Dict = { set_compact_hint: "Mais comboios por ecrã.", set_fees: "Taxas de reserva Interrail", set_fees_hint: "Mostra quanto custa a reserva obrigatória de cada comboio, em 2.ª classe.", + set_offline: "Horários da Europa offline", + set_offline_hint: "Guarda os horários de todos os países Interrail neste dispositivo, para pesquisar sem ligação. Atualizam-se quando saem novos.", + set_offline_size: "Cerca de {mb} MB.", + set_offline_loading: "A transferir…", + set_offline_kept: "Neste dispositivo: horários de {date}.", set_lowend: "Dispositivo modesto", set_lowend_hint: "Reduz o movimento, oculta o mapa e compacta a lista, num toque.", lowend_prompt_title: "Acelerar o app?", @@ -133,6 +138,7 @@ export const pt: Dict = { lbl_train: "Trem {no}", lbl_dayoffset: "+{n} d", act_book: "Reservar no SNCF Connect", + act_book_on: "Reservar no {site}", act_next: "Seguinte", act_results: "Resultados", act_book_leg: "Reservar o comboio {n}", @@ -308,8 +314,8 @@ export const pt: Dict = { fee_chip: "Reserva {fee}", fee_note: "Taxas de reserva Interrail indicativas, 2.ª classe.", fee_link: "Taxas Interrail", - tagline_ir: "Comboios SNCF com um passe Interrail.", - how_note_ir: "Esta ferramenta mostra os comboios que circulam (dados abertos da SNCF); não sabe se ainda há lugar de passe. Verifique no SNCF Connect.", + tagline_ir: "Comboios por toda a Europa com um passe Interrail.", + how_note_ir: "Esta ferramenta mostra os comboios que circulam (dados abertos dos operadores); não sabe se ainda há lugar de passe. Confirme ao reservar.", multi_incomplete_ir: "Alguns trajetos não têm comboio — este itinerário está incompleto.", tour_none_ir: "Nenhum roteiro para estas cidades e datas.", res_none_ir: "Nenhum comboio para estes critérios.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 5097400..2e852d8 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -110,6 +110,11 @@ export const zh: Dict = { set_compact_hint: "每屏显示更多班次。", set_fees: "Interrail 订座费", set_fees_hint: "显示每趟列车必须订座的费用(二等座)。", + set_offline: "离线保存欧洲时刻表", + set_offline_hint: "将所有 Interrail 国家的时刻表保存在本设备上,无网络也能搜索。有新时刻表时自动更新。", + set_offline_size: "约 {mb} MB。", + set_offline_loading: "正在下载…", + set_offline_kept: "本设备已保存:{date} 的时刻表。", set_lowend: "低端设备", set_lowend_hint: "一键减少动效、隐藏地图并压缩列表。", lowend_prompt_title: "让应用更快?", @@ -133,6 +138,7 @@ export const zh: Dict = { lbl_train: "列车 {no}", lbl_dayoffset: "+{n} 天", act_book: "在 SNCF Connect 预订", + act_book_on: "在 {site} 预订", act_next: "下一步", act_results: "结果", act_book_leg: "预订第 {n} 趟列车", @@ -308,8 +314,8 @@ export const zh: Dict = { fee_chip: "订座 {fee}", fee_note: "Interrail 订座费仅供参考(二等座)。", fee_link: "Interrail 费用", - tagline_ir: "持 Interrail 通票乘坐的 SNCF 列车。", - how_note_ir: "本工具显示运行中的列车(SNCF 开放数据),无法得知是否还有通票座位。请在 SNCF Connect 上核实。", + tagline_ir: "持 Interrail 通票乘坐的欧洲各地列车。", + how_note_ir: "本工具显示运行中的列车(各铁路公司的开放数据),无法得知是否还有通票座位。请在预订时核实。", multi_incomplete_ir: "部分行程没有列车——此行程不完整。", tour_none_ir: "这些城市和日期没有行程。", res_none_ir: "没有符合条件的列车。", diff --git a/src/state/store.ts b/src/state/store.ts index 4e707bd..2b6ad16 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -42,6 +42,8 @@ export interface Settings { map: boolean; /** Show Interrail pass-holder reservation fees on each train (off by default). */ fees: boolean; + /** Keep Europe's Interrail timetables on the device, so searches work offline (off by default). */ + offlineEurope: boolean; } export type Density = "comfortable" | "compact"; @@ -90,6 +92,7 @@ export function loadSettings(): Settings { reduceMotion: s.reduceMotion === true, map: s.map !== false, // default on fees: s.fees === true, + offlineEurope: s.offlineEurope === true, }; } diff --git a/src/types.ts b/src/types.ts index 9a81abb..2b4bba5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,8 @@ export interface MaxTrain { trainNo: string; available: boolean; // bookable with the pass: MAX = od_happy_card "OUI"; Interrail = the train runs axe?: string; + /** train-api's operator id for trains not run by SNCF ("renfe", "de"…); omitted = SNCF. */ + operator?: string; } /** A station with coordinates for the map + autocomplete. */ diff --git a/src/ui/modals.ts b/src/ui/modals.ts index 1d2b852..134e596 100644 --- a/src/ui/modals.ts +++ b/src/ui/modals.ts @@ -140,6 +140,10 @@ export function showSettingsModal(opts: { onMap: (v: boolean) => void; onCompact: (v: boolean) => void; onFees: (v: boolean) => void; + /** Keep Europe's Interrail timetables on the device (off by default), and its status line. */ + offlineEurope: boolean; + offlineNote: string; + onOfflineEurope: (v: boolean) => void; /** Master "Low-end mode": ON = all three savers on, OFF = all three off. */ onLowEnd: (v: boolean) => void; }): void { @@ -183,6 +187,15 @@ export function showSettingsModal(opts: { opts.onFees(v); rebuild({ fees: v }); }), + settingSwitch( + t("set_offline"), + opts.offlineNote ? `${t("set_offline_hint")} ${opts.offlineNote}` : t("set_offline_hint"), + opts.offlineEurope, + (v) => { + opts.onOfflineEurope(v); + rebuild({ offlineEurope: v, offlineNote: v ? t("set_offline_loading") : "" }); + }, + ), ]), el("p", { class: "muted small set-version", @@ -238,7 +251,7 @@ export function showBookingModal(journey: Journey, ctx: RenderCtx): void { ]), el("a", { class: "btn btn-primary book-step-btn", - href: ctx.bookUrl(leg.origin, leg.destination, leg.date, leg.depart), + href: ctx.bookUrl(leg.origin, leg.destination, leg.date, leg.depart, leg.operator), attrs: { target: "_blank", rel: "noopener noreferrer" }, text: t("act_book_leg", { n: i + 1 }), }), diff --git a/src/ui/render.ts b/src/ui/render.ts index 2b54468..e48adcd 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -11,16 +11,24 @@ import { isAirportStation } from "../data/stations"; import { formatDuration, dayIndex, addDays } from "../util/time"; import { t, getLang } from "../i18n"; import { formatFee, type Fee } from "../data/fees"; +import { operatorById } from "../data/operators"; // Monotonic id source for wiring aria-labelledby (a calendar's

to its grid). let uidSeq = 0; +/** "Book on SNCF Connect", or on the site of the operator that runs the train. */ +function bookOn(train: MaxTrain | undefined): string { + const op = operatorById(train?.operator); + return op ? t("act_book_on", { site: op.name }) : t("act_book"); +} + export interface RenderCtx { label: (id: string) => string; formatDate: (iso: string) => string; /** Narrow localized weekday name (e.g. "Sat"). */ formatWeekday: (iso: string) => string; - bookUrl: (origin: string, destination: string, date: string, time?: string) => string; + /** Where to book a train: SNCF Connect, or another operator's site (`operator`, see operators.ts). */ + bookUrl: (origin: string, destination: string, date: string, time?: string, operator?: string) => string; /** External travel-guide (Wikivoyage) URL for a station's city. */ cityInfoUrl: (id: string) => string; /** Open the exact O→D trip. A getaway idea passes `open.date` — its advertised start day — @@ -303,7 +311,7 @@ export function journeyEl( const connecting = j.legs.length > 1; const book = (): void => { if (connecting) ctx.onBookSteps(j); - else window.open(ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart), "_blank", "noopener,noreferrer"); + else window.open(ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart, j.legs[0]?.operator), "_blank", "noopener,noreferrer"); }; // A through-ticket can't be pinned to the exact free trains in a single SNCF @@ -334,7 +342,7 @@ export function journeyEl( ) : el("a", { class: "btn btn-primary book-leg", - href: ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart), + href: ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart, j.legs[0]?.operator), attrs: { target: "_blank", rel: "noopener noreferrer" }, text: opts.bookLabel, }) @@ -353,12 +361,12 @@ export function journeyEl( "a", { class: "book-arrow", - href: ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart), + href: ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart, j.legs[0]?.operator), attrs: { target: "_blank", rel: "noopener noreferrer", - "aria-label": t("act_book"), - title: t("act_book"), + "aria-label": bookOn(j.legs[0]), + title: bookOn(j.legs[0]), }, }, [icon(I.arrow), el("span", { class: "sr-only", text: t("link_newtab") })], diff --git a/tests/europe.test.ts b/tests/europe.test.ts new file mode 100644 index 0000000..6bfa66b --- /dev/null +++ b/tests/europe.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, afterEach } from "vitest"; +import type { SearchBundle } from "../src/europe/engine"; +import { Europe } from "../src/europe/network"; +import { toAppJourney, returnDates } from "../src/europe/client"; +import { findJourneys, setJourneySource, toJourney } from "../src/core/connections"; +import { availabilityCalendar } from "../src/core/calendar"; +import { operatorById, setOperatorBooking } from "../src/data/operators"; +import { journeyFee } from "../src/data/fees"; +import { StationRegistry } from "../src/data/stations"; +import type { Journey, MaxTrain } from "../src/types"; + +const DAY = "2026-11-10"; + +/** One operator's bundle running every trip on DAY; stops are [station, "HH:MM"] or [station, "HH:MM/HH:MM"]. */ +function bundle( + operator: string, + trips: [string, [string, string][], string?][], + coords: Record = {}, +): SearchBundle { + const stations: SearchBundle["stations"] = []; + const idx = new Map(); + const families: string[] = []; + const at = (name: string): number => { + let i = idx.get(name); + if (i === undefined) { + i = stations.length; + idx.set(name, i); + const c = coords[name]; + stations.push(c ? [name, c[0], c[1]] : [name, null, null]); + } + return i; + }; + const min = (hhmm: string): number => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3, 5)); + return { + v: 2, + operator, + dates: [DAY], + stations, + families, + trips: trips.map(([no, stops, family]) => { + let fam = -1; + if (family) { + fam = families.indexOf(family); + if (fam < 0) fam = families.push(family) - 1; + } + return [ + no, + fam, + "1", + stops.map(([s]) => at(s)), + stops.flatMap(([, t]) => { + const [a = t, d = t] = t.split("/"); + return [min(a), min(d)]; + }), + ]; + }), + }; +} + +// SNCF's open data names its stations without positions; Renfe's GTFS places them. +const sncf = bundle("sncf", [ + ["9713", [["PARIS (intramuros)", "08:00"], ["BARCELONA SANTS", "14:30"]], "SUD EST"], + ["6601", [["PARIS (intramuros)", "09:00"], ["LYON (intramuros)", "11:00"]], "SUD EST"], +]); +const renfe = bundle( + "renfe", + [ + ["03063", [["Barcelona-Sants", "15:30"], ["Zaragoza Delicias", "16:50/16:52"], ["Madrid-Puerta de Atocha", "18:15"]], "AVE"], + ["03073", [["Barcelona-Sants", "17:30"], ["Madrid-Chamartín", "20:10"]], "AVE"], + ["00900", [["Madrid-Puerta de Atocha", "23:00"], ["Sevilla-Santa Justa", "25:40"]], "TRENHOTEL"], + ], + { + "Barcelona-Sants": [41.3793, 2.1404], + "Zaragoza Delicias": [41.6588, -0.9116], + "Madrid-Puerta de Atocha": [40.4066, -3.6892], + "Madrid-Chamartín": [40.4722, -3.6825], + "Sevilla-Santa Justa": [37.3918, -5.9757], + }, +); +const SNCF_COORDS: Record = { + "PARIS (intramuros)": [48.8566, 2.3522], + "LYON (intramuros)": [45.7578, 4.832], + "BARCELONA SANTS": [41.3792, 2.1405], +}; + +// Finland's VR: no town in common with SNCF or Renfe. +const vr = bundle("fi", [["IC1", [["Helsinki", "07:00"], ["Tampere", "08:40"]], "IC"]], { Helsinki: [60.1719, 24.9414], Tampere: [61.4981, 23.7731] }); + +/** The directory from the operators' station lists, with their trips loaded. */ +function europeOf(bundles: SearchBundle[]): Europe { + const e = new Europe(bundles.map((b) => ({ operator: b.operator, stations: b.stations })), SNCF_COORDS); + for (const b of bundles) e.addBundle(b); + return e; +} + +describe("Europe's network", () => { + const europe = europeOf([sncf, renfe, vr]); + + it("keeps SNCF's ids where SNCF stops, and the engine's elsewhere", () => { + const dir = europe.directory(); + const ids = dir.map((p) => p.id); + expect(ids).toContain("PARIS (intramuros)"); + // Renfe's Barcelona-Sants is SNCF's BARCELONA SANTS: one place, under SNCF's id. + expect(ids).toContain("BARCELONA SANTS"); + expect(ids).not.toContain("barcelona-sants"); + const atocha = dir.find((p) => p.name === "Madrid-Puerta de Atocha"); + expect(atocha).toMatchObject({ id: "madrid-puerta-de-atocha", town: "Madrid", lat: 40.4066 }); + }); + + it("finds Paris to Madrid, changing from SNCF to Renfe in Barcelona", () => { + const [j] = europe.journeys("PARIS (intramuros)", "madrid-puerta-de-atocha", DAY, 4); + expect(j?.legs.map((l) => [l.operator, l.trainNo, l.from, l.to])).toEqual([ + ["sncf", "9713", "PARIS (intramuros)", "BARCELONA SANTS"], + ["renfe", "03063", "BARCELONA SANTS", "madrid-puerta-de-atocha"], + ]); + }); + + it("searches a whole town: either Madrid station answers", () => { + const list = europe.journeys("PARIS (intramuros)", "madrid-chamartin", DAY, 4); + expect(list.map((j) => j.legs[1]?.trainNo)).toContain("03063"); + }); + + it("keeps the change count the query allows", () => { + expect(europe.journeys("PARIS (intramuros)", "madrid-puerta-de-atocha", DAY, 1)).toEqual([]); + }); + + it("lists the towns reachable from Paris, one row each", () => { + const rows = europe.reachable("PARIS (intramuros)", DAY, 4); + const stations = rows.map((r) => r.station); + expect(stations).toContain("LYON (intramuros)"); + expect(stations).toContain("BARCELONA SANTS"); + // Atocha and Chamartín are one town: Madrid appears once. + expect(stations.filter((s) => s.startsWith("madrid"))).toHaveLength(1); + expect(stations).not.toContain("PARIS (intramuros)"); + }); + + it("needs only the operators on the way", () => { + expect(europe.operatorsFor("PARIS (intramuros)", "madrid-puerta-de-atocha")).toEqual(["renfe", "sncf"]); + expect(europe.operatorsFor("PARIS (intramuros)", "LYON (intramuros)")).toEqual(["sncf"]); + expect(europe.operatorsFor("PARIS (intramuros)")).toEqual(["renfe", "sncf"]); + expect(europe.operatorsFor("helsinki")).toEqual(["fi"]); + }); + + it("lists every station before any timetable loads, and searches once the operators on the way are in", () => { + const e = new Europe([sncf, renfe, vr].map((b) => ({ operator: b.operator, stations: b.stations })), SNCF_COORDS); + expect(e.directory().map((p) => p.id)).toContain("helsinki"); + expect(e.missing(e.operatorsFor("PARIS (intramuros)", "madrid-puerta-de-atocha"))).toEqual(["renfe", "sncf"]); + expect(e.journeys("PARIS (intramuros)", "madrid-puerta-de-atocha", DAY, 4)).toEqual([]); + e.addBundle(sncf); + e.addBundle(renfe); + expect(e.missing(["renfe", "sncf", "fi"])).toEqual(["fi"]); + expect(e.journeys("PARIS (intramuros)", "madrid-puerta-de-atocha", DAY, 4)[0]?.legs.map((l) => l.trainNo)).toEqual(["9713", "03063"]); + }); + + it("answers nothing for a station it doesn't know", () => { + expect(europe.knows("NOWHERE")).toBe(false); + expect(europe.knows("LYON (intramuros)")).toBe(true); + expect(europe.journeys("NOWHERE", "madrid-puerta-de-atocha", DAY, 4)).toEqual([]); + expect(europe.reachable("NOWHERE", DAY, 4)).toEqual([]); + }); +}); + +describe("toAppJourney", () => { + it("turns the engine's legs into the app's trains, operator and all", () => { + const j = toAppJourney({ + duration: 600, + legs: [ + { operator: "sncf", trainNo: "9713", category: "SUD EST", from: "PARIS (intramuros)", to: "BARCELONA SANTS", departure: `${DAY}T08:00`, arrival: `${DAY}T14:30` }, + { operator: "renfe", trainNo: "00900", from: "BARCELONA SANTS", to: "sevilla-santa-justa", departure: `${DAY}T23:00`, arrival: "2026-11-11T06:00" }, + ], + }); + expect(j).not.toBeNull(); + const [a, b] = j!.legs; + expect(a).toMatchObject({ date: DAY, depart: "08:00", arrive: "14:30", departMin: 480, arriveMin: 870, axe: "SUD EST", available: true }); + expect(a?.operator).toBeUndefined(); + expect(b).toMatchObject({ operator: "renfe", depart: "23:00", arrive: "06:00", arriveMin: 1800, durationMin: 420 }); + expect(j).toMatchObject({ origin: "PARIS (intramuros)", destination: "sevilla-santa-justa", hubs: ["BARCELONA SANTS"], layovers: [510] }); + // The engine's duration, which counts each clock's time zone. + expect(j!.totalDurationMin).toBe(600); + }); + + it("drops a journey with no legs", () => { + expect(toAppJourney({ duration: 0, legs: [] })).toBeNull(); + }); +}); + +describe("findJourneys with Europe's journeys", () => { + afterEach(() => setJourneySource(null)); + + const leg = (over: Partial): MaxTrain => ({ + date: DAY, + origin: "PARIS (intramuros)", + destination: "madrid-puerta-de-atocha", + depart: "08:00", + arrive: "18:00", + departMin: 480, + arriveMin: 1080, + durationMin: 600, + trainNo: "1", + available: true, + ...over, + }); + const early = toJourney([leg({})]); + const late = toJourney([leg({ trainNo: "2", depart: "15:00", departMin: 900, arriveMin: 1500, arrive: "01:00", durationMin: 600 })]); + const found: Journey[] = [late, early]; + + it("reads them first, narrowed to the query's times, and falls back on the trains otherwise", () => { + setJourneySource((o, _d, date) => (o === "PARIS (intramuros)" && date === DAY ? found : undefined)); + expect(findJourneys([], "PARIS (intramuros)", "madrid-puerta-de-atocha", DAY).map((j) => j.legs[0]?.trainNo)).toEqual(["1", "2"]); + expect(findJourneys([], "PARIS (intramuros)", "madrid-puerta-de-atocha", DAY, { departAfter: "12:00" })).toEqual([late]); + expect(findJourneys([], "PARIS (intramuros)", "madrid-puerta-de-atocha", DAY, { arriveBefore: "23:00" })).toEqual([early]); + // A day not searched yet: the trains answer (none here). + expect(findJourneys([], "PARIS (intramuros)", "madrid-puerta-de-atocha", "2026-11-11")).toEqual([]); + const cal = availabilityCalendar([], "PARIS (intramuros)", "madrid-puerta-de-atocha", [DAY, "2026-11-11"]); + expect(cal.map((d) => d.count)).toEqual([2, 0]); + }); + + it("passes the query's change count to the source", () => { + let asked = -1; + setJourneySource((_o, _d, _date, maxConnections) => { + asked = maxConnections; + return []; + }); + findJourneys([], "A", "B", DAY, { maxConnections: 3 }); + expect(asked).toBe(3); + }); +}); + +describe("operators", () => { + afterEach(() => setOperatorBooking({})); + + it("books SNCF on SNCF Connect, the others on their own site", () => { + expect(operatorById("sncf")).toBeUndefined(); + expect(operatorById(undefined)).toBeUndefined(); + expect(operatorById("renfe")).toMatchObject({ name: "Renfe", bookingUrl: "https://www.renfe.com/es/en" }); + setOperatorBooking({ renfe: "https://www.renfe.com/es/en/interrail" }); + expect(operatorById("renfe")?.bookingUrl).toBe("https://www.renfe.com/es/en/interrail"); + }); + + it("leaves a journey with another operator's train without a fee", () => { + const j = toAppJourney({ + duration: 135, + legs: [{ operator: "renfe", trainNo: "03063", category: "AVE", from: "BARCELONA SANTS", to: "madrid-puerta-de-atocha", departure: `${DAY}T15:30`, arrival: `${DAY}T17:45` }], + }); + expect(journeyFee(j!)).toBeUndefined(); + }); +}); + +describe("returnDates", () => { + it("adds the days after the last one", () => { + expect(returnDates(["2026-11-10", "2026-11-12"], 2)).toEqual(["2026-11-10", "2026-11-12", "2026-11-13", "2026-11-14"]); + expect(returnDates(["2026-11-10"], 0)).toEqual(["2026-11-10"]); + }); +}); + +describe("StationRegistry.addPlaces", () => { + it("adds Europe's stations, searchable by town, and a pass switch drops them", () => { + const reg = new StationRegistry([]); + reg.addMissing(["PARIS (intramuros)"]); + reg.addPlaces([ + { id: "madrid-puerta-de-atocha", label: "Madrid-Puerta de Atocha", city: "Madrid", lat: 40.4066, lng: -3.6892 }, + { id: "PARIS (intramuros)", label: "Another name", lat: 48.85, lng: 2.35 }, + ]); + expect(reg.coords("madrid-puerta-de-atocha")).toEqual([40.4066, -3.6892]); + expect(reg.search("madrid").map((s) => s.id)).toContain("madrid-puerta-de-atocha"); + expect(reg.resolve("Madrid-Puerta de Atocha")).toBe("madrid-puerta-de-atocha"); + // Known ids keep their entry. + expect(reg.label("PARIS (intramuros)")).not.toBe("Another name"); + reg.setPresent(["PARIS (intramuros)"]); + expect(reg.get("madrid-puerta-de-atocha")).toBeUndefined(); + }); +});