303.525 datasets geïndexeerd1.419 uitgevers
Open Data IndexDe index van open data over Nederland
Open Data Index›Bouwen›Live kaart van bussen, trams en metro's
KaartOm te beginnenVerkeer en vervoer

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.

Je AI-assistentOpen Data Index MCP aan
De MCP-server van Open Data Index staat aan in je chat. Zo leest de assistent dit project en de bronnen zelf.
Jij plakt de prompt van dit project
De assistent haalt op bij Open Data Index
  • geef_project("ov-live-kaart")
  • geef_bron("ndov-ov-data")
De assistent

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.

Volg de stappen
ROTTERDAM CENTRAAL23:34
NU BINNEN 1 KMVERTR.
33CENTRAAL STATION - AIRPORT RTHA - MEIJERSPLEIN−1 MIN
44ZUIDPLEIN - CENTRAAL STATION−2 MIN
DMETRO DOP TIJD
38STATION SCHIEDAM - CENTRAAL STATION - CROOSWIJK−1 MIN
3BEVERWAARD - ROTTERDAM CENTRAAL−2 MIN
8SCHIEBROEK - SPANGEN−1 MIN
7WOUDESTEIN - MARCONIPLEIN+1 MIN
Rijdt-ie?live · 23:34:26
Rotterdam C.
1.406voertuigen op de kaart (positie < 5 min oud)
  • −1 tot +3 min1.107
  • ≥ 3 min te laat132
  • ≥ 1 min te vroeg167
1.163bus198tram25metro20onbekend
CXX 348ARR 232QBUZZ 211GVB 158
Mockup

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.

Bron: NDOV-loket via openOV, GTFS-Realtime voertuigposities · opgehaald 25 oktober 2026Open de tabel Los blok

Zo zou je kunnen starten5 stappen

  1. 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. 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. 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. 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. 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

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");
Uitvoer
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

NDOV-loket (GOVI, Stichting OpenGeo), data van de vervoerdersNederland, halte, rit en voertuig

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.

OpenDownloadRealtimeVerkeer en vervoer
Actueel
bijgewerkt minder dan een uur geleden

Meer om te bouwen