I wanted a map of Britain with every train moving across it: green for on time, amber for the sort of delay that makes you walk faster, red for the one that costs a connection. Network Rail makes the movements available through its open-data feeds. What it does not provide is anything resembling a ready-made map.

A TRUST movement report might tell you loc_stanox: 87701 and a timetable_variation in minutes. It does not say East Croydon, and it certainly does not include the latitude and longitude MapLibre needs to draw a dot. The feed speaks the language of the operating railway: STANOX, TIPLOC, CRS, eastings and northings.

Before TrainTracker could show a train moving, I had to translate that language into ordinary geography.

Two joins and a datum conversion

There is no single authoritative file containing STANOX codes, recognisable names and web-map coordinates. scripts/build-stanox-lookup.ts joins two different views of the railway.

Network Rail’s CORPUS data relates STANOX codes to TIPLOCs and, where one exists, the three-letter CRS code printed on tickets. A separate reference spreadsheet supplies eastings and northings for TIPLOC locations.

Those coordinates use the British National Grid. Its OSGB36 datum is fitted to the Airy 1830 ellipsoid; GPS and ordinary web maps use WGS84. Chris Veness’s geodesy library performs the conversion:

const gridRef = new OsGridRef(easting, northing);
const latLon = gridRef.toLatLon();

Where several TIPLOCs share one STANOX, the build prefers the entry carrying a passenger-facing CRS code, then rounds the result to six decimal places. The first generated stanox-lookup.json contained 8,410 locations. Two rows show the range hidden inside that number:

"87701": { "lat": 51.374834, "lng": -0.092374, "name": "EAST CROYDON", "tiploc": "ECROYDN", "crs": "ECR" }
"72389": { "lat": 51.537386, "lng": -0.14684, "name": "EUSTON SIG WM610", "tiploc": "EUST610" }

The railway between the stations

The companion stations.json had 2,595 passenger stations. The other 5,815 locations belonged to the working railway: signals, junctions, shunting necks, ballast points and carriage sidings. The names are wonderfully specific. CREWE SHUNTING NECK, MANCHESTER PICCADILLY BALLAST and MIDDLESBROUGH CARRIAGE SIDINGS all had a place in the data.

Passengers encounter a railway made of departures and destinations. TRUST describes the machinery between them. A train may report at a signal gantry before it reaches the platform; a freight service passes through places that will never appear on a ticket. Discarding those rows would make the dataset easier to explain by making the railway less true.

Keeping them also changes the map. A dot can advance through actual infrastructure instead of teleporting from one public station name to the next. The view begins to contain the system that makes the journeys possible, not just the places where people get on and off.

An abstract collage of railway diagrams, coloured markers and route lines.
Rail junctions, train dots, and timetable event streams.

Turning reports into movement

A train does more than change coordinates. It enters the system, arrives, departs, may be cancelled and reinstated, and can even change identity during a journey. I built the backend around those events rather than around dots on a screen.

The server subscribes to Network Rail’s /topic/TRAIN_MVT_ALL_TOC feed and parses five TRUST message families: activation, cancellation, movement, reinstatement and identity change. Each event updates an in-memory map of active trains. State is saved to disk every thirty seconds, services that have gone quiet for two hours are removed, and the server records how often it can resolve an incoming STANOX against the lookup.

The browser receives one snapshot when it connects, followed by small update and remove messages. Moving one train therefore sends one train rather than another copy of the entire network. I built the backend to run the Node server and its Network Rail connection inside a Cloudflare Container, with a Worker routing the browser’s WebSocket to it.

That architecture preserves the two very different time scales in the system. The complete set of active trains must survive long enough to serve a newly connected browser, while each movement should cross the wire as a small event almost immediately.

87701 was already taken

The frontend needed synthetic movement while I built the live-data path, so I gave the map twelve demo trains. The finished lookup turned out to be useful for validating that layer as well. Its first placeholder, 87701, was already taken: East Croydon, about nine miles south of the central-London point labelled Euston.

The fallback also set both usingMock and connected, while the interface rendered only the reassuring word “Live”. Synthetic data is useful because it lets the map and feed develop independently. It becomes misleading when it borrows the railway’s real identifier space and the interface conceals its provenance.

The collision made the right boundary obvious. TrainTracker now keeps every demo train, location and operator inside a reserved synthetic: namespace and carries that provenance all the way to the status panel. Fallback mode says Demo data; Live is reserved for a connected WebSocket carrying the real feed. A regression test compares every synthetic location identifier with the current 12,327-row lookup, so a future fixture cannot quietly wander back onto the operating railway.

The collision showed exactly why the supposedly dull translation table matters. A live railway map is only as trustworthy as the chain connecting each coloured dot to the report which placed it there. When a real movement arrives from EUSTON SIG WM610, TrainTracker can put it at 51.537386, -0.14684. It never appears on a departure board because it is a signal, but it belonged on an operating map and appeared in that first 8,410-row lookup.

Chris Chabot · January 2026 · updated July 2026