Skip to content

Search journeys on the trip bundles, in the browser, a CLI or a self-hosted API - #15

Open
davd-gzl wants to merge 3 commits into
offline-bundlesfrom
offline-search
Open

davd-gzl wants to merge 3 commits into
offline-bundlesfrom
offline-search

Conversation

@davd-gzl

@davd-gzl davd-gzl commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Before: train-api publishes timetables only. Finding Madrid to Amsterdam means matching each operator's station names by hand and joining the trains yourself.

After: src/search.ts finds journeys with changes across every operator on the v2 trip bundles, and lists every place reachable from one on a day. The same file runs three ways:

  • In the browser. Download the countries a trip needs from GitHub Pages and search on the device. This is free and works offline once the files are downloaded. Railmap does this (Search Interrail trains across Europe in the browser Railmap#102).
  • From the command line. Run npm run search -- paris madrid 2026-10-12.
  • As an HTTP API anyone can host. The Dockerfile serves /v2/places, /v2/journeys and /v2/reachable with no key and CORS open.

Stacked on #14 (the bundles).

How:

  • Station matching. Stations are matched across operators by position: within 300 m, or the same name within 3 km. Where a source has no coordinates (SNCF's tgvmax), they're matched by name. A place is named after its busiest located station, and carries how many trips call there (calls) so clients can rank places.
  • Towns. Places whose names start with the same town and lie within 20 km form a town. Crossing it takes 15 min plus 8 min per km, capped at 60 min, or 60 min when a station has no position. "paris" then means every Paris station, and a change can cross town, with no hand-written city list.
  • Search. A connection scan over the day's hops keeps one label per number of trains, so one pass gives the earliest arrival with 1, 2, 3 or 4 trains.
    • Staying on a train needs no change. A change takes 10 min, or 15 min between two operators.
    • The scan repeats from just after each departure it finds, and drops journeys that another one beats.
    • Each operator's clock is respected (Finland +1 h, Great Britain and Ireland −1 h).
  • Reachable. reachable() runs the scan with no target from each departure slot (every hour by default) and keeps the fastest journey to each place.
  • Lookups. placeIndex() finds an operator's station by its own name.
  • Memory. Hops are built per search date into typed arrays and sorted with a counting sort, so a day of Europe stays small. A synthetic network about 3× the size of the real one (12 operators, 96k trips) indexes in 160 ms, builds a day in about 1 s, and searches in 50–270 ms.
  • Loading. src/load.ts loads bundles from a URL or a folder. src/server.ts is the API, and scripts/search.ts is the CLI.
  • CI. The live-feeds job runs Paris–Madrid, Madrid–Amsterdam and Paris–Barcelona on the real bundles and prints the timings: 25–90 ms per search.
  • Docs. The developer guide gains a "Journey search" section, and the README gains a short one.

Checks: npm run check passes with 57 tests. 12 cover the engine: matching, naming, towns, changes across three operators, overnight trains, time zones, reachable places and lookups. The rest cover the loader and the API. The fixture build, the CLI and the server were also run locally.

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.
…est 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).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant