Live kaart van bussen, trams en metro's
Een kaart waarop elke bus, tram en metro in Nederland live rondrijdt, gekleurd naar vertraging, uit één open bestand van 180 kB en zonder sleutel.
geef_project("ov-live-kaart")geef_bron("ndov-ov-data")
Ik heb het project, de geteste aanroepen en de valkuilen. Ik bouw een kaart met OV-data via het NDOV-loket. Zo kan het eruitzien:
Dit bouw je zelf. Kopieer de prompt naar Claude, ChatGPT of Cursor, met de MCP-server aan, of volg de stappen hieronder.
- −1 tot +3 min1.107
- ≥ 3 min te laat132
- ≥ 1 min te vroeg167
Dit is een mockup, geen werkende versie, met de voertuigposities van OVapi (GTFS-realtime) van zaterdag 17 oktober 2026, 23:34:26, met de lijnnamen uit de dienstregeling (routes.txt). De MCP-server zet je aan met het adres op Voor AI.
Voor wie
Wie een eerste live-kaart wil bouwen, OV-liefhebbers, gemeenten en journalisten die willen zien waar het openbaar vervoer nu rijdt en hoe laat het is.
Wat je krijgt
Een webkaart (Leaflet of MapLibre) met een stip per voertuig, gekleurd naar vertraging, die elke 30 tot 60 seconden ververst. Klik op een stip voor lijn, vervoerder en vertraging. Een filter op soort (bus, tram, metro) en op vervoerder.
Proefdraai op vrijdag 16 oktober 2026 om 23:50: 1.277 voertuigen met een positie (48 zonder). Daarvan 1.042 bussen, 188 trams, 22 metro's en 25 die niet in de dienstregeling te vinden waren. Grootste vervoerders op dat moment: Qbuzz 206, RRReis 171, GVB 146. Van de 1.232 voertuigen met een verse positie reden er 158 drie minuten of meer te laat en 141 een minuut of meer te vroeg. In het centrum van Utrecht reden 28 voertuigen.
Bussen, trams en metro's met een live positie, per vervoerder: het meest Qbuzz (39 voertuigen), dan Connexxion en EBS
Bussen, trams en metro's met een live positie, per vervoerder, oktober 2026, in voertuigen. Totaal: 95; de grootste 7 van 7 categorieën staan in de grafiek. Qbuzz is 41% van het totaal. Momentopname van zondag 25 oktober 2026 om 03:33: bus, tram en metro met een live positie. Treinen staan in een apart bestand.
Zo zou je kunnen starten5 stappen
- 1
Haal de voertuigposities op
openOV maakt van de NDOV-stromen elke minuut een GTFS-Realtime-bestand met de positie van elk voertuig (180 kB). Geen sleutel, licentie CC0. Er zitten alleen bussen, trams, metro's en enkele veerboten in van acht vervoerders (CXX/Connexxion, ARR/Arriva, QBUZZ, GVB, EBS, RET, HTM, KEOLIS); treinen staan in trainUpdates.pb. Vraag hooguit eens per 30 seconden, met een eigen User-Agent; te veel verzoeken achter elkaar geven 429.
https://gtfs.ovapi.nl/nl/vehiclePositions.pb
HTTP/2 200 last-modified: Fri, 16 Oct 2026 21:51:26 GMT content-length: 180742 (protobuf; uitgepakt bijvoorbeeld: id "2026-10-16:CXX:M340:242", trip_id 391357596, route_id 147347, positie 52.25877 / 4.76819, timestamp 1792186396, voertuig 2118, OVapi-vertraging 16 s)
- 2
Lees protobuf zonder bibliotheek
Je kunt gtfs-realtime-bindings gebruiken, maar een eigen lezer is dertig regels: elk veld is een sleutel (veldnummer × 8 + type) gevolgd door een varint, een 4- of 8-byte getal of een lengte plus bytes. FeedMessage veld 2 = entity, FeedEntity veld 4 = vehicle, VehiclePosition veld 1 = trip, 2 = position (1 = lat, 2 = lon, float), 5 = timestamp, 8 = voertuig. De vertraging staat in de OVapi-uitbreiding, veld 1003 → 1, in seconden (negatief = te vroeg).
https://gtfs.ovapi.nl/nl/gtfs-realtime-OVapi.proto
message OVapiVehiclePosition { // Estimated arrival-delay at the next stop (in seconds) can be positive (meaning that the vehicle is late) or // negative (meaning that the vehicle is ahead of schedule). Delay of 0 // means that the vehicle is exactly on time. optional int32 delay = 1; } - 3
Haal alleen routes.txt uit de dienstregeling van 241 MB
Lijnnummer en soort (tram, metro, bus) staan niet in de realtime-data, wel in routes.txt van de GTFS-dienstregeling. Die zip is 252.694.014 bytes, maar de server ondersteunt Range-verzoeken. Vraag de laatste 64 kB op (Range: bytes=-65536), lees daarin de inhoudsopgave van de zip, en haal dan alleen routes.txt op: 45 kB ingepakt, 3.234 lijnen. route_type 0 = tram, 1 = metro, 3 = bus, 4 = veerboot. Koppel op route_id (veld 5 van de TripDescriptor). De zip wordt elke avond rond 21:00 vernieuwd.
https://gtfs.ovapi.nl/nl/gtfs-nl.zip
HTTP 206 (Range: bytes=-65536), 65536 bytes. Daarna routes.txt, uitgepakt: route_id,agency_id,route_short_name,route_long_name,route_desc,route_type,route_color,route_text_color,route_url 134842,ALLGO,326,Lijn 326 (R-net) Almere Stad - Blaricum P+R,,3,e00713,ffffff, 134844,ALLGO,330,Lijn 330 (R-net) Almere Buiten - A'dam Bijlmer,,3,e00713,ffffff, 134845,ALLGO,N22,Lijn N22 (NightGo) A'dam Leidsepl.- Almere Buiten,,3,f59c00,000000,
- 4
Zet de haltes eronder
Het centrale haltebestand (CHB) staat elke nacht in de open map van het NDOV-loket: 13,8 MB gzip, 355 MB XML. Op 16 oktober 2026: 53.195 perrons (quays), waarvan 44.501 'available', met per perron rd-x en rd-y (Rijksdriehoek, omrekenen naar graden), plaats, gemeentecode (G0344), toegankelijkheid en vervoerssoort (51.219 bus, 1.578 tram, 243 metro). Lees het streamend; in één keer laden kost veel geheugen. Makkelijker voor een kaart: stops.txt uit dezelfde GTFS-zip (1,8 MB ingepakt, al in graden) via hetzelfde Range-trucje.
https://data.ndovloket.nl/haltes/
Index of /haltes/ ExportCHB_2026-10-16.xml.gz 16-Oct-2026 01:33 13M PassengerStopAssignmentExportCHB_2026-10-16.xml.gz 16-Oct-2026 01:31 1.0M
- 5
Teken de kaart
Laat de server elke 30 tot 60 seconden een GeoJSON maken (het script schrijft voertuigen.geojson) en laat de pagina dat ophalen. Kleur: groen tot 1 minuut, oranje 1 tot 3 minuten, rood vanaf 3 minuten te laat, grijs als de positie ouder is dan 5 minuten. Laat de stippen niet springen maar schuif ze in een paar seconden naar de nieuwe plek, op basis van het voertuig-id.
Hoe de bronnen samenkomen
- OV-data via het NDOV-loketKerncijfers wijken en buurtenvia
positie in buurt (punt-in-polygoon), of gemeentecode van de halte (CHB: G0344 = GM0344)Tel per buurt de haltes en de voertuigen die er per uur langskomen, en leg dat naast inwoners, inkomen en autobezit uit de kerncijfers wijken en buurten.
- OV-data via het NDOV-loketWijk- en buurtkaartvia
coördinaten (WGS84) in de buurtgrenzenMet de buurtgrenzen van PDOK bepaal je in welke buurt elk voertuig of elke halte ligt.
Laat AI het bouwen
Plak deze prompt in Claude, ChatGPT, Cursor of Lovable. Hij noemt de echte endpoints, de koppelsleutels en de valkuilen, zodat de assistent meteen werkende code schrijft. Controleer de uitkomst altijd met de bron zelf.
Bouw een live kaart van het openbaar vervoer in Nederland: één HTML-pagina met MapLibre GL of Leaflet en een kleine Node-server (Node 20, geen database). Data (open, CC0, geen sleutel): - Voertuigposities: https://gtfs.ovapi.nl/nl/vehiclePositions.pb (GTFS-Realtime protobuf, ~180 kB, elke minuut nieuw). - Lijnen: routes.txt uit https://gtfs.ovapi.nl/nl/gtfs-nl.zip (252 MB!). Download die zip niet: lees met een HTTP Range-verzoek de laatste 64 kB (zip-inhoudsopgave, End of Central Directory), zoek routes.txt en haal alleen dat stuk op (~45 kB), pak uit met zlib.inflateRaw. Eén keer per dag verversen. - Uitbreiding: https://gtfs.ovapi.nl/nl/gtfs-realtime-OVapi.proto. Veld 1003 in VehiclePosition bevat delay (int32, seconden, negatief = te vroeg). Wat je moet weten: - Gebruik gtfs-realtime-bindings of schrijf een mini-protobuflezer (varint, 32-bit float, 64-bit, length-delimited). int32 negatief komt als 10-byte varint: BigInt.asIntN(32, …). - Koppel TripDescriptor.route_id (veld 5) aan routes.txt voor route_short_name en route_type (0 tram, 1 metro, 3 bus, 4 veerboot). Een paar procent vindt geen route: toon die als 'onbekend'. - Sommige voertuigen hebben geen positie: overslaan. Sommige posities zijn uren of zelfs weken oud (in onze proef één van 19 september): laat alles ouder dan 5 minuten weg of maak het grijs. - Treinen zitten hier niet in (zie trainUpdates.pb). - Vraag vehiclePositions.pb hooguit eens per 30 seconden op, met een eigen User-Agent en If-Modified-Since; bij 429 een minuut wachten. Laat de browser nooit zelf bij gtfs.ovapi.nl ophalen: de server haalt één keer en deelt GeoJSON uit. - De entity-id ziet eruit als '2026-10-16:CXX:M340:242' (dienstdag:vervoerder:interne lijncode:ritnummer). De interne lijncode is niet het lijnnummer op de bus. Uitkomst: een kaart van Nederland met een stip per voertuig, kleur naar vertraging (groen < 1 min, oranje 1 tot 3, rood > 3, grijs = verouderd), die elke 30 seconden ververst en stippen vloeiend verschuift. Popup met lijn, soort, vervoerder en vertraging. Filters op soort en vervoerder, en een tellertje 'nu op de kaart'.
Probeer het zelf
De kern van het project in een klein script. Nodig: Node 20+ (fetch en zlib ingebouwd), geen pakketten. Getest op 2026-10-16; hieronder de echte uitvoer van die proefdraai.
// Live OV-kaart: waar rijden de bussen, trams en metro's nu? Node 20+, geen pakketten nodig.
// Bronnen: GTFS-Realtime vehiclePositions.pb en routes.txt uit de GTFS-zip van openOV (gtfs.ovapi.nl, CC0).
import zlib from "node:zlib";
import { writeFileSync } from "node:fs";
const UA = { "User-Agent": "mijn-ov-kaart/1.0 (+jij@voorbeeld.nl)" };
const RT = "https://gtfs.ovapi.nl/nl/vehiclePositions.pb", ZIP = "https://gtfs.ovapi.nl/nl/gtfs-nl.zip";
// 1. Mini-protobuflezer: genoeg voor GTFS-Realtime (varint, 32/64 bit, lengte-velden)
function varint(b, p) { let r = 0n, s = 0n, x; do { x = b[p++]; r |= BigInt(x & 127) << s; s += 7n; } while (x & 128); return [r, p]; }
function velden(b) { const uit = []; let p = 0; while (p < b.length) { let k, v; [k, p] = varint(b, p); const veld = Number(k >> 3n), type = Number(k & 7n);
if (type === 0) [v, p] = varint(b, p); else if (type === 1) { v = b.readDoubleLE(p); p += 8; } else if (type === 5) { v = b.readFloatLE(p); p += 4; }
else if (type === 2) { let l; [l, p] = varint(b, p); v = b.subarray(p, p + Number(l)); p += Number(l); } else throw new Error("onbekend type " + type);
uit.push([veld, v]); } return uit; }
const een = (vs, n) => vs.find((x) => x[0] === n)?.[1], alle = (vs, n) => vs.filter((x) => x[0] === n).map((x) => x[1]);
const int32 = (v) => Number(BigInt.asIntN(32, v)); // negatieve vertraging = te vroeg
// 2. Alleen routes.txt uit de zip van 241 MB halen, met HTTP Range (ongeveer 45 kB)
async function stuk(bereik) {
const r = await fetch(ZIP, { headers: { ...UA, Range: `bytes=${bereik}` } });
if (r.status !== 206) throw new Error(`gtfs-nl.zip: HTTP ${r.status}`); // 429 = te veel verzoeken, wacht een minuut
return Buffer.from(await r.arrayBuffer());
}
async function uitZip(naam) { // drie kleine verzoeken: staart, inhoudsopgave, het bestand zelf
const staart = await stuk("-65536"), e = staart.lastIndexOf(Buffer.from([0x50, 0x4b, 5, 6]));
const cdOff = staart.readUInt32LE(e + 16), cd = await stuk(`${cdOff}-${cdOff + staart.readUInt32LE(e + 12) - 1}`);
for (let p = 0; cd.readUInt32LE(p) === 0x02014b50;) { const n = cd.readUInt16LE(p + 28), lengte = cd.readUInt32LE(p + 20), off = cd.readUInt32LE(p + 42);
if (cd.toString("utf8", p + 46, p + 46 + n) === naam) { const b = await stuk(`${off}-${off + 30 + 1024 + lengte}`);
const start = 30 + b.readUInt16LE(26) + b.readUInt16LE(28);
return zlib.inflateRawSync(b.subarray(start, start + lengte)).toString("utf8"); }
p += 46 + n + cd.readUInt16LE(p + 30) + cd.readUInt16LE(p + 32); }
}
const routes = new Map();
for (const r of (await uitZip("routes.txt")).trim().split("\n").slice(1)) { const k = r.split(","); routes.set(k[0], { lijn: k[2], vervoerder: k[1], soort: k[5] }); }
// 3. Voertuigposities lezen
const res = await fetch(RT, { headers: UA });
if (!res.ok) throw new Error(`vehiclePositions.pb: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
const feed = velden(buf), kop = velden(een(feed, 1)), nu = Number(een(kop, 3));
const SOORT = { 0: "tram", 1: "metro", 2: "trein", 3: "bus", 4: "veerboot" };
const voertuigen = []; let zonderPositie = 0;
for (const e of alle(feed, 2)) {
const ent = velden(e), vp = een(ent, 4); if (!vp) continue;
const v = velden(vp);
if (!een(v, 2)) { zonderPositie++; continue; } // sommige voertuigen hebben (nog) geen positie
const trip = velden(een(v, 1) ?? Buffer.alloc(0)), pos = velden(een(v, 2)), ext = een(v, 1003);
const route = routes.get(een(trip, 5)?.toString()) ?? {};
voertuigen.push({ id: een(ent, 1).toString(), lat: een(pos, 1), lon: een(pos, 2), tijd: Number(een(v, 5)),
vertraging: ext && een(velden(ext), 1) !== undefined ? int32(een(velden(ext), 1)) : null,
lijn: route.lijn ?? "?", vervoerder: route.vervoerder ?? een(ent, 1).toString().split(":")[1], soort: SOORT[route.soort] ?? "onbekend" });
}
// 4. Samenvatten en als GeoJSON wegschrijven voor de kaart
const tel = (f) => Object.entries(voertuigen.reduce((t, v) => ((t[f(v)] = (t[f(v)] ?? 0) + 1), t), {})).sort((a, b) => b[1] - a[1]);
console.log(`Feed van ${new Date(nu * 1000).toLocaleString("nl-NL", { timeZone: "Europe/Amsterdam" })}: ${voertuigen.length} voertuigen met positie (${zonderPositie} zonder), routes.txt: ${routes.size} lijnen`);
console.log("Per soort:", tel((v) => v.soort).map(([k, n]) => `${k} ${n}`).join(", "));
console.log("Per vervoerder:", tel((v) => v.vervoerder).map(([k, n]) => `${k} ${n}`).join(", "));
const oud = voertuigen.filter((v) => nu - v.tijd > 300).length;
const mv = voertuigen.filter((v) => v.vertraging !== null && nu - v.tijd <= 300);
console.log(`Positie ouder dan 5 minuten: ${oud}. Van de rest ${mv.filter((v) => v.vertraging >= 180).length} van ${mv.length} drie minuten of meer te laat, ${mv.filter((v) => v.vertraging <= -60).length} een minuut of meer te vroeg.`);
const bbox = [5.08, 52.07, 5.14, 52.11]; // Utrecht-centrum: west, zuid, oost, noord
const hier = voertuigen.filter((v) => v.lon >= bbox[0] && v.lon <= bbox[2] && v.lat >= bbox[1] && v.lat <= bbox[3]);
console.log(`In Utrecht-centrum nu ${hier.length} voertuigen:`, hier.slice(0, 6).map((v) => `${v.soort} ${v.lijn} (${v.vertraging ?? "?"} s)`).join(", "));
writeFileSync("voertuigen.geojson", JSON.stringify({ type: "FeatureCollection", features: voertuigen.map((v) => ({ type: "Feature",
geometry: { type: "Point", coordinates: [+v.lon.toFixed(5), +v.lat.toFixed(5)] }, properties: { ...v, lat: undefined, lon: undefined } })) }));
console.log("Geschreven: voertuigen.geojson");
Feed van 16-10-2026, 23:50:26: 1277 voertuigen met positie (48 zonder), routes.txt: 3234 lijnen Per soort: bus 1042, tram 188, onbekend 25, metro 22 Per vervoerder: QBUZZ 206, RRREIS 171, GVB 146, UOV 104, CXX 101, RET 101, HTM 101, BRAVO:ARR 85, ARR 62, MEERPLUS 53, BRAVO:CXX 42, EBS 39, TRANSDEV 32, ALLGO 30, KEOLIS 4 Positie ouder dan 5 minuten: 22. Van de rest 158 van 1232 drie minuten of meer te laat, 141 een minuut of meer te vroeg. In Utrecht-centrum nu 28 voertuigen: bus 120 (130 s), bus 28 (28 s), bus 66 (34 s), bus 3 (1163 s), bus 6 (0 s), bus 5 (132 s) Geschreven: voertuigen.geojson
Waar het misgaat
- Spookvoertuigen. In het bestand stond een Qbuzz-bus met een positie van 19 september 2026, bijna een maand oud. 22 van de 1.277 posities waren ouder dan 5 minuten. Kijk altijd naar het veld timestamp.
- Het lijnnummer in de id is intern. 'CXX:M340' is lijn 340 bij Connexxion, maar 'RET:M006' is metrolijn A en 'EBS:4805' is buslijn 5 van RRReis. Gebruik routes.txt voor het nummer dat reizigers kennen.
- De vervoerder in de id is niet altijd het merk op de bus. EBS-ritten staan in routes.txt onder RRREIS of MEERPLUS, ARR onder BRAVO:ARR. Toon agency_id uit routes.txt, of de naam uit agency.txt.
- Niet de hele zip downloaden. 252 MB per dag is zonde en belast de gratis dienst. Met Range-verzoeken haal je alleen routes.txt (45 kB) en eventueel stops.txt (1,8 MB). Doe niet te veel losse Range-verzoeken achter elkaar: na een stuk of acht binnen een minuut kregen we 429.
- Best effort, zonder garantie. De open stromen hebben geen service-afspraak en geen ondersteuning. Laat de kaart netjes 'geen verse data' tonen als het bestand niet ververst.
Verder bouwen
- Drukte per halte: tel per halte (stops.txt) hoeveel voertuigen er per uur langskomen en leg dat per buurt naast inwoners en autobezit (CBS kerncijfers).
- Vertraging per lijn: bewaar een week lang elke minuut de vertraging per voertuig en maak een ranglijst van lijnen die het vaakst meer dan 3 minuten te laat zijn.
- Treinen erbij: lees trainUpdates.pb (zie het project treinvertragingen-dashboard) en zet treinen tussen de stations op de kaart, met de NS-stationscoördinaten.
- Toegankelijkheid: het haltebestand zegt per perron of het rolstoeltoegankelijk is. Kleur de haltes daarop en vergelijk gemeenten.
De bronnen1

OV-data via het NDOV-loket
Het NDOV-loket verzamelt de dienstregelingen en actuele ritinformatie van de Nederlandse vervoerders, van Arriva en Connexxion tot GVB, RET en NS. Stichting OpenGeo draait het loket onder de naam GOVI en zet de data als CC0 open: dienstregelingen in NeTEx, het haltebestand en live posities en vertragingen. Er is ook een kant-en-klare GTFS-versie voor heel Nederland.
Dashboard met files, snelheden en wegwerkzaamheden
Dashboard met treinvertragingen en storingen
Hoe snel rijdt jouw gemeente elektrisch?
Laadpunten per buurt en per elektrische auto
Wat vliegt er nu boven mijn huis?