diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a0c667f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +data +public +.git diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b35d9f4..92136af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,12 @@ jobs: run: /usr/bin/time -f "%e s, %M KB peak memory" npm run build:api env: NODE_OPTIONS: --max-old-space-size=12288 + - name: Search the real bundles + run: | + day=$(date -u -d tomorrow +%F) + TRAIN_API=public/v2 npm run --silent search -- paris madrid "$day" 07:00 + TRAIN_API=public/v2 npm run --silent search -- madrid amsterdam "$day" 06:00 + TRAIN_API=public/v2 OPERATORS=sncf,renfe npm run --silent search -- paris barcelona "$day" - name: Sizes run: | du -sh public/v1/* public/v2/* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f07f445 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +# The journey search API: docker build -t train-api . && docker run -p 8080:8080 train-api +# It loads the published timetables at start and every 6 hours; see docs/developers.md. +FROM node:22-slim +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY src ./src +ENV NODE_ENV=production PORT=8080 +EXPOSE 8080 +CMD ["npx", "tsx", "src/server.ts"] diff --git a/README.md b/README.md index 9ffb627..a67acae 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ Free, no key, CORS open, refreshed daily: start with the [developer guide](docs/ (endpoints, JS and Python examples, licences and credits, stability promise) and the [OpenAPI spec](docs/openapi.yaml). +## Search journeys + +`src/search.ts` finds journeys with changes across every operator, on the trip bundles. It +runs in the browser on downloaded bundles (free, offline once downloaded), from the command +line (`npm run search -- paris madrid 2026-10-12`), or as an HTTP API you host with the +`Dockerfile` (`/v2/places`, `/v2/journeys`, `/v2/reachable`). See the [developer guide](docs/developers.md#journey-search). + ## How it works 1. `npm run fetch` downloads every source to `data/raw/`: the SNCF `tgvmax` feed (every diff --git a/docs/developers.md b/docs/developers.md index a12105e..77506f7 100644 --- a/docs/developers.md +++ b/docs/developers.md @@ -227,6 +227,43 @@ npm ci && npm run fetch && npm run build:api # writes public/v1/ and public/v2 The national feeds need about 12 GB of memory (`NODE_OPTIONS=--max-old-space-size=12288`). Serve `public/` from any static host. +## Journey search + +The trip bundles come with a journey search, `src/search.ts`: from a town or station to +another on a date, with changes across operators. Stations are matched across operators by +their coordinates (and by name where a source has none), and the stations of one town are +grouped, so a change can cross town. The same file runs in three places: + +- **In the browser, free.** Copy `src/search.ts` (it has no imports), download the bundles + of the countries a trip needs from GitHub Pages, and search on the device. Paris to Madrid + needs `sncf` and `renfe`. Once downloaded, searches work offline. + + ```js + import { Network } from "./search.js"; + const base = "https://offware-apps.github.io/train-api/v2/"; + const bundles = await Promise.all(["sncf", "renfe"].map((op) => fetch(`${base}${op}/interrail.json`).then((r) => r.json()))); + const net = new Network(bundles); + const journeys = net.journeys(net.resolve("paris"), net.resolve("madrid"), "2026-10-12", { time: "07:00" }); + ``` + +- **On the command line:** `npm run search -- paris madrid 2026-10-12 07:00`. Set + `TRAIN_API` to a folder (such as `public/v2` after a build, or a downloaded copy) to search + offline, and `OPERATORS=sncf,renfe` to load only some operators. + +- **As an HTTP API you host:** `docker build -t train-api . && docker run -p 8080:8080 train-api` + (or `npm start`). No key, CORS open. + + | Path | Content | + |------|---------| + | `/v2/places?q=madr` | towns and stations matching a name: `id`, `name`, `kind` (`city` or `station`), and each operator's name for them | + | `/v2/journeys?from=madrid&to=amsterdam&date=2026-10-12` | journeys leaving that day, earliest first; optional `time=08:00`, `results` (1 to 10, default 5), `maxTrains` (1 to 6, default 4). Each has `departure`, `arrival` (local times), `duration` in minutes, `changes` and its `legs`, plus the `sources` to credit | + | `/v2/reachable?from=madrid&date=2026-10-12` | every place reachable that day, each with its fastest journey, fastest first; optional `time` and `maxTrains` (1 to 4, default 3) | + | `/health` | whether the timetables are loaded, and how fresh | + + `from` and `to` take an id from `/v2/places` or a name. Configuration: `PORT` (8080), + `TRAIN_API` (the bundles' base URL or folder; the public API by default), `OPERATORS`, + `PASS` (`interrail`), `RELOAD_HOURS` (6). All of Europe fits in a few hundred MB of memory. + ## Help and requests Open an issue on [offware-apps/train-api](https://github.com/offware-apps/train-api/issues) diff --git a/package.json b/package.json index 41a7079..55b5323 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "train-api", "version": "0.1.0", "private": true, - "description": "Open train data for rail passes (Interrail first), built on SNCF open data and served as static JSON.", + "description": "Open train data for rail passes (Interrail first), served as static JSON, with a journey search that runs in the browser or as a self-hosted API.", "license": "AGPL-3.0-only", "type": "module", "scripts": { @@ -10,6 +10,8 @@ "test": "vitest run", "fetch": "tsx scripts/fetch.ts", "build:api": "tsx scripts/build-api.ts", + "search": "tsx scripts/search.ts", + "start": "tsx src/server.ts", "check": "npm run typecheck && npm test" }, "devDependencies": { diff --git a/scripts/search.ts b/scripts/search.ts new file mode 100644 index 0000000..ff752fc --- /dev/null +++ b/scripts/search.ts @@ -0,0 +1,39 @@ +/** + * Search journeys from the command line, on the published bundles or on a folder of them: + * npm run search -- madrid amsterdam 2026-10-12 [08:00] + * TRAIN_API=public/v2 npm run search -- paris madrid 2026-10-12 + * OPERATORS=sncf,renfe loads only those. Prints how each name matched, then the journeys. + */ +import { DEFAULT_API, loadBundles } from "../src/load"; +import { Network } from "../src/search"; + +const [fromQ = "", toQ = "", date = new Date().toISOString().slice(0, 10), time] = process.argv.slice(2); +const operators = process.env.OPERATORS?.split(",").map((s) => s.trim()).filter(Boolean); +const t0 = Date.now(); +const { index, bundles } = await loadBundles(process.env.TRAIN_API ?? DEFAULT_API, { operators }); +const t1 = Date.now(); +const network = new Network(bundles); +console.log( + `timetables ${index.updatedAt}: ${bundles.map((b) => b.operator).join(", ")}; ${network.places.length} places ` + + `(loaded in ${t1 - t0} ms, indexed in ${Date.now() - t1} ms)`, +); +const from = network.resolve(fromQ); +const to = network.resolve(toQ); +for (const [q, m] of [[fromQ, from], [toQ, to]] as const) { + const names = m ? m.places.map((i) => network.places[i]?.name).join("; ") : ""; + console.log(`"${q}" → ${m ? `${m.kind} ${m.name} (${names})` : "no match"}`); +} +if (!from || !to) process.exit(1); +for (const round of ["first search", "again"]) { + const t2 = Date.now(); + const journeys = network.journeys(from, to, date, { time }); + console.log(`${round}: ${journeys.length} journeys in ${Date.now() - t2} ms`); + if (round !== "first search") break; + for (const j of journeys) { + console.log(`\n${j.departure} → ${j.arrival} ${Math.floor(j.duration / 60)}h${String(j.duration % 60).padStart(2, "0")}, ${j.changes} change(s)`); + for (const l of j.legs) { + console.log(` ${l.departure.slice(11)} ${l.from} → ${l.arrival.slice(11)} ${l.to} [${l.operator} ${l.category ?? ""} ${l.trainNo}]`); + } + } + console.log(); +} diff --git a/src/load.ts b/src/load.ts new file mode 100644 index 0000000..936e526 --- /dev/null +++ b/src/load.ts @@ -0,0 +1,53 @@ +/** + * Load trip bundles (v2) for the search, from a published API (a URL) or from a folder of + * the same files on disk (`public/v2` after `npm run build:api`, or a download). Node only. + */ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { ApiIndexV2 } from "./api"; +import type { SearchBundle } from "./search"; + +export const DEFAULT_API = "https://offware-apps.github.io/train-api/v2/"; + +async function getJson(base: string, path: string): Promise { + if (/^https?:\/\//.test(base)) { + const url = new URL(path, base.endsWith("/") ? base : `${base}/`); + const res = await fetch(url, { signal: AbortSignal.timeout(120_000) }); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + return (await res.json()) as T; + } + return JSON.parse(await readFile(join(base, path), "utf-8")) as T; +} + +export interface Loaded { + index: ApiIndexV2; + bundles: SearchBundle[]; + /** Attribution per operator, for responses. */ + sources: Map; +} + +/** + * The index, then the bundle of each operator that publishes the pass (all of them, or + * those in `operators`). An operator that fails to load is skipped. + */ +export async function loadBundles(base = DEFAULT_API, { pass = "interrail", operators }: { pass?: string; operators?: string[] } = {}): Promise { + const index = await getJson(base, "index.json"); + const wanted = index.operators.filter( + (o) => o.passes.some((p) => p.id === pass) && (!operators?.length || operators.includes(o.id)), + ); + const loaded = await Promise.all( + wanted.map(async (o) => { + try { + return await getJson(base, `${o.id}/${pass}.json`); + } catch (err) { + console.error(`[load] ${o.id}: skipped (${String(err)})`); + return null; + } + }), + ); + return { + index, + bundles: loaded.filter((b): b is SearchBundle => b !== null), + sources: new Map(index.operators.map((o) => [o.id, o.source])), + }; +} diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..dede748 --- /dev/null +++ b/src/search.ts @@ -0,0 +1,801 @@ +/** + * 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; + }); + 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/server.ts b/src/server.ts new file mode 100644 index 0000000..48a46c0 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,151 @@ +/** + * The journey search as an HTTP API, to run anywhere Node or a container runs: + * + * GET /v2/places?q=madr towns and stations matching a name + * GET /v2/journeys?from=madrid&to=amsterdam&date=2026-10-12[&time=08:00][&results=5][&maxTrains=4] + * GET /v2/reachable?from=madrid&date=2026-10-12[&time=06:00] every place reachable that day, fastest journey each + * GET /health + * + * No key, CORS open. It loads the trip bundles from TRAIN_API (the public API by default, + * or a folder such as public/v2) and reloads them every RELOAD_HOURS. + * Configuration: PORT (8080), TRAIN_API, OPERATORS (comma-separated, default all), + * PASS (interrail), RELOAD_HOURS (6). + */ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { DEFAULT_API, loadBundles, type Loaded } from "./load"; +import { Network } from "./search"; + +export interface State { + network: Network; + loaded: Loaded; +} + +const JSON_TYPE = "application/json; charset=utf-8"; + +function send(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "Content-Type": JSON_TYPE, "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store" }); + res.end(JSON.stringify(body)); +} + +/** Handle one request. Exported for tests. */ +export function handle(req: IncomingMessage, res: ServerResponse, state: State | null): void { + const url = new URL(req.url ?? "/", "http://localhost"); + const path = url.pathname; + if (req.method === "OPTIONS") { + res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET" }); + res.end(); + return; + } + if (req.method !== "GET") return send(res, 405, { error: "GET only" }); + if (path === "/health") return send(res, state ? 200 : 503, { ok: !!state, timetables: state?.loaded.index.updatedAt ?? null }); + if (path === "/") { + return send(res, 200, { + name: "train-api journey search", + endpoints: [ + "/v2/places?q=madr", + "/v2/journeys?from=madrid&to=amsterdam&date=YYYY-MM-DD&time=08:00", + "/v2/reachable?from=madrid&date=YYYY-MM-DD", + ], + source: "https://github.com/offware-apps/train-api", + }); + } + if (!state) return send(res, 503, { error: "timetables are loading" }); + const { network, loaded } = state; + + if (path === "/v2/places") { + const q = url.searchParams.get("q") ?? ""; + const places = network.find(q, 10).map((m) => ({ + id: m.id, + name: m.name, + kind: m.kind, + stations: m.places.flatMap((i) => network.places[i]?.stations ?? []), + })); + return send(res, 200, { places }); + } + if (path === "/v2/reachable") { + const fromQ = url.searchParams.get("from") ?? ""; + const date = url.searchParams.get("date") ?? ""; + const time = url.searchParams.get("time") ?? undefined; + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || (time && !/^\d{2}:\d{2}$/.test(time))) { + return send(res, 400, { error: "date must be YYYY-MM-DD and time HH:MM" }); + } + const from = network.resolve(fromQ); + if (!from) return send(res, 404, { error: `unknown place: ${fromQ}` }); + const maxTrains = Math.min(4, Math.max(1, Number(url.searchParams.get("maxTrains") ?? 3) || 3)); + const reach = network.reachable(from, date, { time, maxTrains }); + const places = [...reach] + .map(([i, journey]) => { + const p = network.places[i]; + return { place: { id: p?.id, name: p?.name, lat: p?.lat, lon: p?.lon }, journey }; + }) + .sort((a, b) => a.journey.duration - b.journey.duration); + const used = new Set(places.flatMap((r) => r.journey.legs.map((l) => l.operator))); + return send(res, 200, { + from: { id: from.id, name: from.name, kind: from.kind }, + date, + places, + sources: [...used].map((op) => loaded.sources.get(op) ?? op), + timetables: loaded.index.updatedAt, + }); + } + if (path === "/v2/journeys") { + const fromQ = url.searchParams.get("from") ?? ""; + const toQ = url.searchParams.get("to") ?? ""; + const date = url.searchParams.get("date") ?? ""; + const time = url.searchParams.get("time") ?? undefined; + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || (time && !/^\d{2}:\d{2}$/.test(time))) { + return send(res, 400, { error: "date must be YYYY-MM-DD and time HH:MM" }); + } + const from = network.resolve(fromQ); + const to = network.resolve(toQ); + if (!from || !to) return send(res, 404, { error: `unknown place: ${from ? toQ : fromQ}` }); + const results = Math.min(10, Math.max(1, Number(url.searchParams.get("results") ?? 5) || 5)); + const maxTrains = Math.min(6, Math.max(1, Number(url.searchParams.get("maxTrains") ?? 4) || 4)); + const journeys = network.journeys(from, to, date, { time, results, maxTrains }); + const used = new Set(journeys.flatMap((j) => j.legs.map((l) => l.operator))); + return send(res, 200, { + from: { id: from.id, name: from.name, kind: from.kind }, + to: { id: to.id, name: to.name, kind: to.kind }, + date, + journeys, + // Attribution the open data licences ask for. + sources: [...used].map((op) => loaded.sources.get(op) ?? op), + timetables: loaded.index.updatedAt, + }); + } + return send(res, 404, { error: "not found" }); +} + +async function main(): Promise { + const base = process.env.TRAIN_API ?? DEFAULT_API; + const operators = process.env.OPERATORS?.split(",").map((s) => s.trim()).filter(Boolean); + const pass = process.env.PASS ?? "interrail"; + let state: State | null = null; + const reload = async (): Promise => { + try { + const loaded = await loadBundles(base, { pass, operators }); + if (state && state.loaded.index.updatedAt === loaded.index.updatedAt) return; + const t = Date.now(); + state = { network: new Network(loaded.bundles), loaded }; + console.log( + `[server] timetables ${loaded.index.updatedAt}: ${loaded.bundles.map((b) => b.operator).join(", ")}; ` + + `${state.network.places.length} places, indexed in ${Date.now() - t} ms`, + ); + } catch (err) { + console.error(`[server] load failed, keeping the current timetables: ${String(err)}`); + } + }; + const port = Number(process.env.PORT ?? 8080); + createServer((req, res) => { + try { + handle(req, res, state); + } catch (err) { + console.error(`[server] ${req.method} ${req.url}: ${String(err)}`); + if (!res.headersSent) send(res, 500, { error: "internal error" }); + } + }).listen(port, () => console.log(`[server] listening on :${port}`)); + await reload(); + setInterval(() => void reload(), Number(process.env.RELOAD_HOURS ?? 6) * 3600 * 1000); +} + +if (import.meta.url === `file://${process.argv[1]}`) void main(); diff --git a/tests/search.test.ts b/tests/search.test.ts new file mode 100644 index 0000000..a5d8d86 --- /dev/null +++ b/tests/search.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from "vitest"; +import { encodeBundle, type TripRun } from "../src/bundle"; +import { Network, normalize, type SearchBundle } from "../src/search"; + +const DATES = ["2026-10-12", "2026-10-13"]; +const M = (hhmm: string): number => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3, 5)); + +function run(trainNo: string, stops: [string, string][], dates = DATES): TripRun { + return { + trainNo, + category: "X", + dates, + stops: stops.map(([station, t]) => { + const [arr = t, dep = t] = t.split("/"); + return { station, arr: M(arr), dep: M(dep) }; + }), + }; +} + +function bundle(operator: string, runs: TripRun[], coords: Record = {}): SearchBundle { + return encodeBundle(operator, "interrail", runs, new Map(Object.entries(coords))) as SearchBundle; +} + +const RENFE = bundle( + "renfe", + [ + run("03063", [["Madrid-Puerta de Atocha", "07:00"], ["Zaragoza-Delicias", "08:20/08:22"], ["Barcelona-Sants", "09:30"]]), + run("03071", [["Madrid-Puerta de Atocha", "07:45"], ["Barcelona-Sants", "10:10"]]), + // A night train of the 12th that reaches Barcelona after midnight. + run("00900", [["Madrid-Puerta de Atocha", "23:30"], ["Zaragoza-Delicias", "24:40/24:45"], ["Barcelona-Sants", "25:50"]], ["2026-10-12"]), + ], + { + "Madrid-Puerta de Atocha": [40.4066, -3.6892], + "Zaragoza-Delicias": [41.6582, -0.9112], + "Barcelona-Sants": [41.3792, 2.1404], + }, +); +// SNCF's tgvmax has names only. +const SNCF = bundle("sncf", [ + // Too tight after the 09:30 arrival: changing between two operators takes 15 minutes. + run("9702", [["BARCELONA SANTS", "09:40"], ["PARIS (intramuros)", "16:00"]]), + run("9714", [["BARCELONA SANTS", "10:45"], ["PARIS (intramuros)", "17:20"]]), +]); +const NL = bundle( + "nl", + [ + // Crossing Paris from an unplaced station takes an hour: 18:00 is too early after 17:20. + run("9343", [["Paris Nord", "18:00"], ["Amsterdam Centraal", "21:20"]]), + run("9351", [["Paris Nord", "19:30"], ["Amsterdam Centraal", "22:50"]]), + ], + { "Paris Nord": [48.8809, 2.3553], "Amsterdam Centraal": [52.3791, 4.9003] }, +); +const DE = bundle("de", [run("ICE 9", [["Paris Est", "08:55"], ["Frankfurt (Main) Hbf", "12:45"]])], { + "Paris Est": [48.8768, 2.3592], + "Frankfurt (Main) Hbf": [50.107, 8.6632], +}); + +describe("station matching", () => { + const net = new Network([RENFE, SNCF, NL, DE]); + + it("folds spellings of one station and drops generic words", () => { + expect(normalize("BARCELONA SANTS")).toBe(normalize("Barcelona-Sants")); + expect(normalize("Zürich HB")).toBe("zurich"); + expect(normalize("Frankfurt (Main) Hbf")).toBe("frankfurt main"); + }); + + it("makes one place of a station two operators name differently", () => { + const sants = net.places.filter((p) => p.stations.some((s) => normalize(s.name) === "barcelona sants")); + expect(sants).toHaveLength(1); + expect(sants[0]?.stations.map((s) => s.operator).sort()).toEqual(["renfe", "sncf"]); + // Named and placed after the operator that publishes a position. + expect(sants[0]).toMatchObject({ name: "Barcelona-Sants", lat: 41.3792 }); + }); + + it("names a place after its busiest station, and counts the trips calling there", () => { + const b = bundle( + "renfe", + [ + run("C1", [["Madrid-Atocha Cercanías", "07:00"], ["Getafe", "07:20"]]), + run("AVE1", [["Madrid-Puerta de Atocha", "08:00"], ["Sevilla", "10:30"]]), + run("AVE2", [["Madrid-Puerta de Atocha", "09:00"], ["Sevilla", "11:30"]]), + ], + // Two platforms' worth apart: one station. + { "Madrid-Atocha Cercanías": [40.4065, -3.6895], "Madrid-Puerta de Atocha": [40.4066, -3.6892], Getafe: [40.3, -3.7], Sevilla: [37.39, -5.97] }, + ); + const atocha = new Network([b]).places.find((p) => p.stations.length === 2); + expect(atocha).toMatchObject({ name: "Madrid-Puerta de Atocha", lat: 40.4066, calls: 3 }); + }); + + it("groups a town's stations, placed or not, and finds the town first", () => { + const paris = net.resolve("paris"); + expect(paris?.kind).toBe("city"); + expect(paris?.places.map((i) => net.places[i]?.name).sort()).toEqual(["PARIS (intramuros)", "Paris Est", "Paris Nord"]); + expect(net.find("paris")[0]?.id).toBe("paris"); + expect(net.resolve("Amsterdam Centraal")?.kind).toBe("station"); + }); +}); + +describe("journey search", () => { + const net = new Network([RENFE, SNCF, NL, DE]); + const go = (from: string, to: string, date = "2026-10-12", time?: string) => + net.journeys(net.resolve(from) as never, net.resolve(to) as never, date, { time }); + + it("finds Madrid to Amsterdam across three operators and across Paris", () => { + const first = go("madrid", "amsterdam")[0]; + expect(first?.changes).toBe(2); + expect(first?.legs.map((l) => [l.operator, l.trainNo, l.from, l.to])).toEqual([ + // The later of two trains that make the same connection. + ["renfe", "03071", "Madrid-Puerta de Atocha", "Barcelona-Sants"], + ["sncf", "9714", "BARCELONA SANTS", "PARIS (intramuros)"], + ["nl", "9351", "Paris Nord", "Amsterdam Centraal"], + ]); + expect(first).toMatchObject({ departure: "2026-10-12T07:45", arrival: "2026-10-12T22:50", duration: 905 }); + }); + + it("stays on one train through its stops", () => { + const j = go("Madrid-Puerta de Atocha", "Barcelona-Sants")[0]; + expect(j?.legs).toEqual([ + { + operator: "renfe", + trainNo: "03063", + category: "X", + from: "Madrid-Puerta de Atocha", + to: "Barcelona-Sants", + departure: "2026-10-12T07:00", + arrival: "2026-10-12T09:30", + stops: 2, + }, + ]); + }); + + it("offers each later departure that arrives later too, and nothing before the time asked", () => { + const list = go("Madrid-Puerta de Atocha", "Barcelona-Sants", "2026-10-12", "07:01"); + expect(list.map((j) => j.legs[0]?.trainNo)).toEqual(["03071", "00900"]); + }); + + it("catches the previous day's night train after midnight", () => { + const j = go("Zaragoza-Delicias", "Barcelona-Sants", "2026-10-13")[0]; + expect(j).toMatchObject({ departure: "2026-10-13T00:45", arrival: "2026-10-13T01:50" }); + }); + + it("returns nothing between places with no train", () => { + expect(go("frankfurt", "madrid")).toEqual([]); + }); + + it("uses each operator's own clock", () => { + const fi = bundle("fi", [run("IC 1", [["Helsinki", "10:00"], ["Tampere", "11:30"]])], { + Helsinki: [60.1719, 24.9414], + Tampere: [61.4986, 23.7737], + }); + const j = new Network([fi]).journeys([0], [1], "2026-10-12", { time: "09:30" })[0]; + expect(j).toMatchObject({ departure: "2026-10-12T10:00", arrival: "2026-10-12T11:30", duration: 90 }); + }); + + it("lists every place reachable from a town, each with its fastest journey", () => { + const madrid = net.resolve("madrid"); + const reach = net.reachable(madrid as never, "2026-10-12"); + const byName = new Map([...reach].map(([p, j]) => [net.places[p]?.name, j])); + expect([...byName.keys()].sort()).toEqual([ + "Amsterdam Centraal", + "Barcelona-Sants", + "PARIS (intramuros)", + "Zaragoza-Delicias", + ]); + // Paris Nord is only walked to: no train arrives there. + // The night train takes 2h20, 03071 2h25, 03063 2h30. + expect(byName.get("Barcelona-Sants")?.legs.map((l) => l.trainNo)).toEqual(["00900"]); + expect(byName.get("Amsterdam Centraal")).toMatchObject({ changes: 2, arrival: "2026-10-12T22:50" }); + }); + + it("finds a station's place by the operator's own name", () => { + const p = net.placeIndex("sncf", "BARCELONA SANTS"); + expect(net.places[p]?.name).toBe("Barcelona-Sants"); + expect(net.placeIndex("renfe", "Barcelona-Sants")).toBe(p); + expect(net.placeIndex("sncf", "Nowhere")).toBe(-1); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..e95b998 --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createServer, type Server } from "node:http"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildIndexV2, buildOperatorBundles } from "../src/api"; +import type { Trip } from "../src/format"; +import { loadBundles } from "../src/load"; +import { Network } from "../src/search"; +import { handle, type State } from "../src/server"; + +const INTERRAIL = { interrail: { bookable: true, seat: "unknown" as const } }; +const trip = (operator: string, trainNo: string, stops: [string, number][]): Trip => ({ + operator, + trainNo, + category: "X", + dates: ["2026-10-12"], + stops: stops.map(([station, t]) => ({ station, arr: t, dep: t })), + passes: INTERRAIL, +}); + +/** A v2 folder as build-api writes it: index, then each operator's files. */ +function writeApi(): string { + const dir = mkdtempSync(join(tmpdir(), "train-api-v2-")); + const renfe = buildOperatorBundles( + "renfe", + "Renfe (CC BY 4.0)", + [trip("renfe", "03071", [["Madrid-Puerta de Atocha", 465], ["Barcelona-Sants", 610]])], + new Map([ + ["Madrid-Puerta de Atocha", [40.4066, -3.6892]], + ["Barcelona-Sants", [41.3792, 2.1404]], + ]), + ); + const sncf = buildOperatorBundles("sncf", "SNCF (Licence Ouverte)", [trip("sncf", "9714", [["BARCELONA SANTS", 645], ["PARIS (intramuros)", 1040]])], new Map()); + for (const f of [...renfe.files, ...sncf.files, buildIndexV2("2026-10-11T12:00:00Z", [renfe.entry, sncf.entry])]) { + const p = join(dir, f.path.replace(/^v2\//, "")); + mkdirSync(join(p, ".."), { recursive: true }); + writeFileSync(p, JSON.stringify(f.body)); + } + return dir; +} + +describe("loading bundles", () => { + it("reads a folder of v2 files, every operator or only some", async () => { + const dir = writeApi(); + const all = await loadBundles(dir); + expect(all.bundles.map((b) => b.operator).sort()).toEqual(["renfe", "sncf"]); + expect(all.sources.get("renfe")).toBe("Renfe (CC BY 4.0)"); + expect((await loadBundles(dir, { operators: ["renfe"] })).bundles.map((b) => b.operator)).toEqual(["renfe"]); + }); +}); + +describe("search API", () => { + let server: Server; + let base = ""; + let state: State | null = null; + + beforeAll(async () => { + const loaded = await loadBundles(writeApi()); + state = { network: new Network(loaded.bundles), loaded }; + server = createServer((req, res) => handle(req, res, state)); + await new Promise((r) => server.listen(0, r)); + const addr = server.address(); + base = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`; + }); + afterAll(() => new Promise((r) => server.close(() => r()))); + + const get = async (path: string) => { + const res = await fetch(base + path); + return { status: res.status, cors: res.headers.get("access-control-allow-origin"), body: (await res.json()) as Record }; + }; + + it("finds places by name", async () => { + const { status, cors, body } = await get("/v2/places?q=barcelona"); + expect(status).toBe(200); + expect(cors).toBe("*"); + expect(body.places).toEqual([ + { + id: "barcelona-sants", + name: "Barcelona-Sants", + kind: "station", + stations: [ + { operator: "renfe", name: "Barcelona-Sants" }, + { operator: "sncf", name: "BARCELONA SANTS" }, + ], + }, + ]); + }); + + it("finds journeys with changes and credits the sources used", async () => { + const { status, body } = await get("/v2/journeys?from=madrid&to=paris&date=2026-10-12"); + expect(status).toBe(200); + expect(body).toMatchObject({ + from: { name: "Madrid-Puerta de Atocha", kind: "station" }, + to: { id: "paris", name: "PARIS (intramuros)" }, + date: "2026-10-12", + sources: ["Renfe (CC BY 4.0)", "SNCF (Licence Ouverte)"], + timetables: "2026-10-11T12:00:00Z", + }); + const journeys = body.journeys as { changes: number; departure: string; arrival: string }[]; + expect(journeys).toHaveLength(1); + expect(journeys[0]).toMatchObject({ changes: 1, departure: "2026-10-12T07:45", arrival: "2026-10-12T17:20" }); + }); + + it("lists the places reachable from one, fastest first", async () => { + const { status, body } = await get("/v2/reachable?from=madrid&date=2026-10-12"); + expect(status).toBe(200); + const places = body.places as { place: { name: string }; journey: { changes: number } }[]; + expect(places.map((r) => [r.place.name, r.journey.changes])).toEqual([ + ["Barcelona-Sants", 0], + ["PARIS (intramuros)", 1], + ]); + expect(body.sources).toEqual(["Renfe (CC BY 4.0)", "SNCF (Licence Ouverte)"]); + }); + + it("says what is wrong with a request", async () => { + expect((await get("/v2/journeys?from=madrid&to=paris&date=12/10/2026")).status).toBe(400); + expect((await get("/v2/journeys?from=madrid&to=atlantis&date=2026-10-12")).status).toBe(404); + expect((await get("/nowhere")).status).toBe(404); + expect((await get("/health")).body).toEqual({ ok: true, timetables: "2026-10-11T12:00:00Z" }); + }); + + it("answers 503 until the timetables are loaded", async () => { + const saved = state; + state = null; + expect((await get("/v2/places?q=a")).status).toBe(503); + expect((await get("/health")).status).toBe(503); + state = saved; + }); +});