From d9c8540885713caaf42c1f313e4617010f1058f1 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:06:23 +0000 Subject: [PATCH 1/3] Add a journey search on the trip bundles, as a library, a CLI and an API src/search.ts finds journeys with changes across every operator loaded: stations are matched by coordinates (by name where a source has none), a town's stations are grouped so a change can cross town, and a connection scan with one label per number of trains finds the earliest arrival for each number of changes. It has no imports, so it runs in a browser worker on downloaded bundles as well as in Node. npm run search searches from the command line, on the published bundles or a folder of them. src/server.ts serves /v2/places and /v2/journeys with no key, and the Dockerfile runs it anywhere containers run. --- .dockerignore | 4 + .github/workflows/ci.yml | 6 + Dockerfile | 10 + README.md | 7 + docs/developers.md | 36 ++ package.json | 4 +- scripts/search.ts | 39 +++ src/load.ts | 53 +++ src/search.ts | 717 +++++++++++++++++++++++++++++++++++++++ src/server.ts | 120 +++++++ tests/search.test.ts | 139 ++++++++ tests/server.test.ts | 119 +++++++ 12 files changed, 1253 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 scripts/search.ts create mode 100644 src/load.ts create mode 100644 src/search.ts create mode 100644 src/server.ts create mode 100644 tests/search.test.ts create mode 100644 tests/server.test.ts 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..4ef5bfc 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`). 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..71888ee 100644 --- a/docs/developers.md +++ b/docs/developers.md @@ -227,6 +227,42 @@ 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 | + | `/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..42f108d --- /dev/null +++ b/src/search.ts @@ -0,0 +1,717 @@ +/** + * 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 }[]; +} + +/** 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; +} + +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); + } + const rawPlace = new Int32Array(raw.length); + const rootPlace = new Map(); + 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: null, lon: null, city: "", stations: [] }); + } + const place = this.places[p] as Place; + place.stations.push({ operator: s.operator, name: s.name }); + // Named and placed after its first station with a position (GTFS names read best). + if (place.lat === null && s.lat !== null && s.lon !== null) { + place.lat = s.lat; + place.lon = s.lon; + place.name = s.name; + } + 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; + }); + } + + /** 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`: for each number of trains k, the earliest arrival at + * any target. Labels are "with at most k trains", so a journey is returned only where + * one more train arrives earlier. + */ + private scan(h: Hops, origins: number[], targets: number[], start: number, maxTrains: number, date: string): Found[] { + const n = this.places.length; + const K = maxTrains; + const isTarget = new Uint8Array(n); + for (const t of targets) isTarget[t] = 1; + // 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: trip instance, boarding hop, 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 = INF; + 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; + } + } + } + } + + const out: Found[] = []; + let previous = INF; + for (let k = 1; k <= K; k++) { + const a = atTarget[k] as number; + if (a >= previous) continue; + previous = a; + const journey = this.rebuild(h, k, targetPlace[k] as number, n, arrival, legBoard, legAlight, readyFrom, readyLevel, date); + if (journey) out.push(journey); + } + return out; + } + + private rebuild( + h: Hops, + level: number, + place: number, + n: number, + arrival: Int32Array, + legBoard: Int32Array, + legAlight: Int32Array, + readyFrom: Int32Array, + readyLevel: Int8Array, + date: string, + ): Found | null { + const hops: [number, number][] = []; + let p = place; + let k = level; + // Find the level the arrival at the target was recorded 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 this.journeyOf(h, hops, date); + const r = (k - 1) * n + boardPlace; + const how = readyFrom[r] as number; + if (how === -1) return this.journeyOf(h, hops, date); + 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..3915ce9 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,120 @@ +/** + * 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 /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"], + 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/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..69c5981 --- /dev/null +++ b/tests/search.test.ts @@ -0,0 +1,139 @@ +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("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 }); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..401ecba --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,119 @@ +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("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; + }); +}); From 19fa03c1a197e5e1fc1f90a61060e6590e335c5b Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:11:46 +0000 Subject: [PATCH 2/3] List every place reachable from one, and name places after their busiest station Network.reachable() runs the scan from each hour of the day and keeps, for every place reached, the shortest journey: the explore view of an app, and GET /v2/reachable on the API. placeIndex() finds the place of an operator's own station name, so a client can map its stations onto the search's. A place that merges several stations is now named and placed after the one most trips call at (Madrid-Puerta de Atocha rather than its suburban platforms). --- README.md | 2 +- docs/developers.md | 1 + src/search.ts | 145 +++++++++++++++++++++++++++++++++---------- src/server.ts | 33 +++++++++- tests/search.test.ts | 38 ++++++++++++ tests/server.test.ts | 11 ++++ 6 files changed, 196 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 4ef5bfc..a67acae 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Free, no key, CORS open, refreshed daily: start with the [developer guide](docs/ `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`). See the [developer guide](docs/developers.md#journey-search). +`Dockerfile` (`/v2/places`, `/v2/journeys`, `/v2/reachable`). See the [developer guide](docs/developers.md#journey-search). ## How it works diff --git a/docs/developers.md b/docs/developers.md index 71888ee..77506f7 100644 --- a/docs/developers.md +++ b/docs/developers.md @@ -257,6 +257,7 @@ grouped, so a change can cross town. The same file runs in three places: |------|---------| | `/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), diff --git a/src/search.ts b/src/search.ts index 42f108d..963ed90 100644 --- a/src/search.ts +++ b/src/search.ts @@ -216,6 +216,19 @@ function runsOn(mask: string, i: number): boolean { 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; @@ -286,23 +299,35 @@ export class Network { 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: null, lon: null, city: "", stations: [] }); + this.places.push({ id: "", name: s.name, lat: s.lat, lon: s.lon, city: "", stations: [] }); + namedBy.push(i); } const place = this.places[p] as Place; place.stations.push({ operator: s.operator, name: s.name }); - // Named and placed after its first station with a position (GTFS names read best). - if (place.lat === null && s.lat !== null && s.lon !== null) { + // 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; - place.name = s.name; } rawPlace[i] = p; }); @@ -378,6 +403,14 @@ export class Network { }); } + /** 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(); @@ -528,21 +561,18 @@ export class Network { } /** - * One connection scan from `start`: for each number of trains k, the earliest arrival at - * any target. Labels are "with at most k trains", so a journey is returned only where - * one more train arrives earlier. + * 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 scan(h: Hops, origins: number[], targets: number[], start: number, maxTrains: number, date: string): Found[] { + private labels(h: Hops, origins: number[], isTarget: Uint8Array | null, start: number, K: number, until = INF): Labels { const n = this.places.length; - const K = maxTrains; - const isTarget = new Uint8Array(n); - for (const t of targets) isTarget[t] = 1; // 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: trip instance, boarding hop, alighting hop. + // 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); @@ -551,7 +581,7 @@ export class Network { 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; + 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); } } @@ -566,7 +596,7 @@ export class Network { if ((h.dep[mid] as number) < start) lo = mid + 1; else hi = mid; } - let best = INF; + let best = until; for (let c = lo; c < h.dep.length; c++) { const dep = h.dep[c] as number; if (dep >= best) break; @@ -589,7 +619,7 @@ export class Network { arrival[at] = arr; legBoard[at] = boardHop[inst] as number; legAlight[at] = c; - if (isTarget[to]) { + if (isTarget?.[to]) { for (let kk = k; kk <= K; kk++) { if (arr < (atTarget[kk] as number)) { atTarget[kk] = arr; @@ -616,35 +646,86 @@ export class Network { } } } + 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 <= K; k++) { - const a = atTarget[k] as number; + for (let k = 1; k <= maxTrains; k++) { + const a = l.atTarget[k] as number; if (a >= previous) continue; previous = a; - const journey = this.rebuild(h, k, targetPlace[k] as number, n, arrival, legBoard, legAlight, readyFrom, readyLevel, date); + 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; } - private rebuild( - h: Hops, - level: number, - place: number, - n: number, - arrival: Int32Array, - legBoard: Int32Array, - legAlight: Int32Array, - readyFrom: Int32Array, - readyLevel: Int8Array, + /** + * 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, - ): Found | null { + 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; - // Find the level the arrival at the target was recorded at: the fewest trains that got there. + // 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; @@ -652,10 +733,10 @@ export class Network { const board = legBoard[at] as number; hops.unshift([board, legAlight[at] as number]); const boardPlace = h.from[board] as number; - if (k === 1) return this.journeyOf(h, hops, date); + if (k === 1) return hops; const r = (k - 1) * n + boardPlace; const how = readyFrom[r] as number; - if (how === -1) return this.journeyOf(h, hops, date); + if (how === -1) return hops; p = how === -2 ? boardPlace : how; k = readyLevel[r] as number; } diff --git a/src/server.ts b/src/server.ts index 3915ce9..48a46c0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ * * 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, @@ -40,7 +41,11 @@ export function handle(req: IncomingMessage, res: ServerResponse, state: State | 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"], + 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", }); } @@ -57,6 +62,32 @@ export function handle(req: IncomingMessage, res: ServerResponse, state: State | })); 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") ?? ""; diff --git a/tests/search.test.ts b/tests/search.test.ts index 69c5981..e441718 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -72,6 +72,21 @@ describe("station matching", () => { expect(sants[0]).toMatchObject({ name: "Barcelona-Sants", lat: 41.3792 }); }); + it("names a place after its busiest station", () => { + 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 }); + }); + it("groups a town's stations, placed or not, and finds the town first", () => { const paris = net.resolve("paris"); expect(paris?.kind).toBe("city"); @@ -136,4 +151,27 @@ describe("journey search", () => { 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 index 401ecba..e95b998 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -102,6 +102,17 @@ describe("search API", () => { 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); From fcd8f69006c695e45ca8b38597016a8928ce8106 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:16:49 +0000 Subject: [PATCH 3/3] Count the trips calling at each place, to rank places --- src/search.ts | 5 ++++- tests/search.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/search.ts b/src/search.ts index 963ed90..dede748 100644 --- a/src/search.ts +++ b/src/search.ts @@ -32,6 +32,8 @@ export interface Place { /** 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. */ @@ -314,11 +316,12 @@ export class Network { 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: [] }); + 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; diff --git a/tests/search.test.ts b/tests/search.test.ts index e441718..a5d8d86 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -72,7 +72,7 @@ describe("station matching", () => { expect(sants[0]).toMatchObject({ name: "Barcelona-Sants", lat: 41.3792 }); }); - it("names a place after its busiest station", () => { + it("names a place after its busiest station, and counts the trips calling there", () => { const b = bundle( "renfe", [ @@ -84,7 +84,7 @@ describe("station matching", () => { { "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 }); + 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", () => {