Build note · Trains Now
A live map from a feed with no map in it
The MTA publishes where every train is, in realtime, with no API key and open CORS. It just does not publish it as a position. A train reports "stopped at 42 St", never a latitude, and a map needs a latitude.
Trains Now draws every train the MTA is currently reporting, on the line it is running, moving between stations. There is no server: the page talks to the agency directly, decodes the feed itself, and works out the geometry in the browser.
01 What the feed actually gives you
The MTA publishes GTFS-realtime, which is Protocol Buffers, split across eight feeds by line group. No key, no proxy, open CORS, which is unusually generous and the reason this can be a static page at all.
const FEEDS = ['', '-ace', '-bdfm', '-g', '-jz', '-nqrw', '-l', '-si'].map(
s => `https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs${s}`);
Each vehicle in that feed carries a route, a trip, a stop_id, and a status
that is one of three values: stopped at, incoming at, or in transit to. There is a
position field in the GTFS-realtime specification. The MTA does not populate it.
So the feed tells you the truth about the system in the vocabulary of the system, which is stations and sequence, not coordinates. That is the whole problem, and it is a more interesting one than a missing API.
02 Decoding protobuf without a library
The usual move is to pull in the protobuf runtime plus the generated GTFS bindings, which is a few hundred kilobytes before the page has drawn anything. For a feed where I need four fields per vehicle, that is a lot of dependency for a little data.
Protobuf's wire format is small enough to read directly. Every field is a varint tag carrying a field number and a wire type, followed by a value whose shape the wire type tells you. If you only care about specific field numbers, you can skip everything else generically without knowing what it was.
const varint = () => {
let result = 0, shift = 0, b;
do { b = u8[p++]; result += (b & 0x7f) * Math.pow(2, shift); shift += 7; }
while (b & 0x80);
return result;
};
key() { const k = varint(); return [k >>> 3, k & 7]; } // field number, wire type
One detail worth flagging, because it is the kind of bug that only shows up in
production: the shift uses Math.pow(2, shift) rather than
<< shift. JavaScript's bitwise operators coerce to 32-bit signed
integers, so a varint past 31 bits silently wraps and produces a plausible wrong number.
Timestamps are exactly that size.
The whole reader is about sixty lines and the page ships no parsing dependency at all.
03 Turning a station name into a place
The realtime feed is only half of GTFS. The other half is the static schedule, which the MTA also publishes, and which contains exactly what the realtime feed lacks: the coordinates of every station, and the running order of stops along each route.
I preprocess that once into a single JSON file the page loads on startup. Then a train that is stopped is easy, because it is at a station whose position I know.
The reconstruction
A moving train reports only the stop it is heading for. Look that stop up in the running order for its route, take the stop before it, and the train is somewhere on the segment between those two. Draw it there.
How far along is a judgement call, not a measurement, so the status picks it: "incoming at" sits close to the platform, "in transit to" sits nearer the middle.
function place(v) {
const here = station(v.stopId.replace(/[NS]$/, ''));
if (v.status === 1) return here; // STOPPED_AT, done
const list = order(v.routeId, v.stopId);
const i = list.indexOf(bare);
const prev = station(list[i - 1]);
const t = v.status === 0 ? 0.85 : 0.55; // INCOMING_AT sits closer in
return [prev[0] + (here[0] - prev[0]) * t,
prev[1] + (here[1] - prev[1]) * t];
}
The N and S suffix on the stop id is the direction of travel,
which matters more than it looks: for a southbound train the "previous" stop is the one
before it in the list, and for a northbound train it is the one after. Get that backwards
and half the system runs the wrong way while looking entirely plausible.
04 Making 25 seconds look like movement
The feed updates every few seconds and I poll it every 25. Drawn naively, that is a map where nothing happens and then every dot teleports.
So the poll sets a target rather than a position, and the dots ease toward it between polls. The motion in between is invented, which I want to be plain about: it is not measured movement, it is a smooth path to the next known truth. It reads as a subway system running, and every 25 seconds reality corrects it.
05 Why it has no server
Because it did not need one. The agency allows direct browser requests, the static schedule compiles to a file, and the arithmetic is a subtraction and a multiply. The entire thing is a static page on a CDN with no backend to run, no key to rotate, and no bill that scales with the number of people watching.
06 What this does not do
- The position between stations is inferred, not reported. A train held in a tunnel will drift toward its next station on the map while standing still in reality.
- It draws along the straight segment between two stations, not the actual track geometry, so tight curves cut corners.
- A train whose feed entry lacks upcoming stops, usually one finishing its run, is drawn at its last known station and says so when you click it.
- If a feed is unreachable the others still draw, and the page tells you the data is stale rather than pretending it is current.