Every tram and bus in Basel, moving live on a map, or in a photorealistic 3D city.
Real tracks, official line colours, real-life delays.
183 vehicles on a Monday evening · updated every 30 s · no build step, no npm dependencies
- Live positions for all BVB and BLT trams and buses, interpolated along the real tracks between stops.
- Official line colours (tram 8 is pink, 11 is red, 10 is yellow…) straight from the OpenStreetMap route relations.
- Real delays: a tram running 3 minutes late is 3 minutes behind on the map. Late vehicles get a red glow, cancelled trips disappear.
- Click any station for its next 8 departures with live delays.
- 3D from zoom 16: tilt with right-drag (or ctrl-drag) and every tram and bus becomes a 3D model in its operator's livery, articulated sections bending through the curves, with the line colour along its flank. Flexity, Combino, Tango, Tina and Citaro are modelled at their real lengths; which one runs a trip is a stable guess, since the feed doesn't say.
- Aerial photos: the Aerial button swaps the dark map for swisstopo's 10 cm SWISSIMAGE photos (Swiss side only). The dark map gets extruded 3D buildings instead.
- Real light: the 3D scene is lit by the sun's actual position over Basel; after dusk the street lights take over and the passenger compartments glow.
- Screensaver mode: the HUD fades out after 20 seconds without input, and after 45 seconds the camera starts a tour, gliding after one tram after another (Tour button to turn it off). The view is kept in the URL, so a link opens the same spot.
Switzerland publishes no vehicle GPS feed, so positions come from the national GTFS-RT trip updates (per-stop delays) applied to the timetable. In practice this is within a few dozen metres of the real vehicle.
You need Python 3.9+ and a free opentransportdata.swiss token. Nothing else: MapLibre loads from a CDN, the server is Python standard library.
git clone https://github.com/dev-ale/basel-live.git && cd basel-live- Register at https://api-manager.opentransportdata.swiss/ (free).
- Open the GTFS-RT product and click Access with this plan.
- Copy the token into a
.envfile in the project folder:
echo "OTD_TOKEN=paste-your-token-here" > .envDownloads the Swiss GTFS zip (~290 MB) and filters it to BVB + BLT. Takes about a minute.
mkdir -p gtfs && curl -L -o gtfs/gtfs.zip 'https://data.opentransportdata.swiss/dataset/3d2c18f9-9ef1-463f-a249-5c67604efd74/resource/112426e8-d1d1-4244-92ff-a6a14329c256/download/gtfs_fp2026_20261010.zip' && python3 prepare_gtfs.py gtfs/gtfs.zipThe timetable is republished twice a week at https://data.opentransportdata.swiss/dataset/timetable-2026-gtfs2020. If vehicles start showing as dashed "no live data", grab the newest zip and re-run this step.
python3 server.pyOpen http://localhost:8765. That's it.
No token? Run it without the server
python3 -m http.server 8765The page then falls back to polling transport.opendata.ch stationboards for 24 hub stops. You get real delays but only the vehicles passing those hubs, roughly a third of the network.
Click Enter 3D Basel for a CesiumJS world with the same live vehicles as articulated 3D models that follow the real track, the real sun over Basel (shadows, night lighting, a ☀︎ slider to preview other times of day) and a ride-along camera: click any tram for chase or cab view. Idle for 20 s and it rides a random tram on its own.
-
Photorealistic city (recommended): create a free Cesium ion account, add Google Photorealistic 3D Tiles from the Asset Depot to your assets, and put your token in
.env:echo "CESIUM_ION_TOKEN=paste-your-token-here" >> .env
The token is passed to the browser, which needs it to stream the tiles. The free Community plan covers about 1,000 3D sessions a month for personal use.
-
Without a token it falls back to swisstopo open data: swissBUILDINGS3D, swissTLM3D, terrain and SWISSIMAGE (untextured buildings).
-
Faster start (optional): keep a local copy of CesiumJS (22 MB, git-ignored) instead of loading it from the CDN:
npm pack cesium@1.144.0 && mkdir -p vendor/cesium && tar -xzf cesium-1.144.0.tgz -C vendor/cesium --strip-components=3 package/Build/Cesium && rm cesium-1.144.0.tgz
Vehicle heights come from the rendered 3D surface, sampled every 8 m along each track near the camera, so trams cross bridges on the deck and pass under overpasses.
opentransportdata.swiss ──GTFS zip──▶ prepare_gtfs.py ──▶ gtfs/basel.json (BVB+BLT timetable, 83 MB)
──GTFS-RT ──▶ server.py ──/vehicles.json──▶ app.js ──▶ MapLibre map (2D, 3D vehicles from zoom 16)
└──▶ world3d.js ──▶ CesiumJS (photoreal 3D city)
OpenStreetMap (Overpass) ──route relations──▶ build_routes.py ──▶ routes.json (tracks + colours)
transport.opendata.ch ◀──station click── app.js
Cesium ion (Google 3D Tiles) / swisstopo ──3D city, terrain──▶ world3d.js
| Piece | Role |
|---|---|
prepare_gtfs.py |
Streams the 4.5 GB Swiss GTFS out of the zip and keeps only BVB/BLT: 59 routes, ~188k trips/year, 1451 platforms. |
server.py |
Serves the page. Every 30 s it fetches the national GTFS-RT feed (one request, ~10k trips for all of Switzerland), keeps the Basel ones and merges the delays into today's schedule. Exposes /vehicles.json and /stops.json. |
app.js |
Polls /vehicles.json every 10 s and animates each vehicle between its last and next stop along the track. Each line's OSM ways form a graph; the vehicle follows the shortest path between the two stops (falling back to the whole tram or bus network for diversions). |
vehicles3d.js |
The vehicles: procedural models per type (Flexity, Combino, Tango, Tina, Citaro), drawn instanced in a MapLibre custom layer with three.js on the map, and exported as GLB sections for the 3D world. |
world3d.js |
Photoreal 3D mode. Lazy-loads CesiumJS, streams the 3D city, places each vehicle's sections along the track with heights sampled from the rendered surface, and runs the sun, night lighting and ride-along camera. |
models/ |
Optional real glTF models replacing the procedural ones; see models/README.md. |
spike3d.html |
The Phase 0 test page: one tram at Barfüsserplatz with live scale, heading and float32-jitter checks. |
build_routes.py |
Turns an Overpass dump of route relations into routes.json. Already included for Basel; see below to refresh. |
Only needed if OSM changes or you want another city. Uses the Swiss Overpass mirror, which is far less loaded than the main one.
curl -s 'https://overpass.osm.ch/api/interpreter' --data-urlencode 'data=[out:json][timeout:180];relation[route=tram](47.48,7.50,47.62,7.72);out geom;' -o osm_tram.jsoncurl -s 'https://overpass.osm.ch/api/interpreter' --data-urlencode 'data=[out:json][timeout:180];relation[route=bus][operator~"BVB|BLT"](47.50,7.53,47.61,7.67);out geom;' -o osm_bus.jsonpython3 build_routes.py osm_tram.json osm_bus.json > routes.jsonThe real-time feed covers all of Switzerland. Change AGENCY_RE in prepare_gtfs.py, fetch that city's OSM routes with a new bounding box, and adjust the hub list and initial view in app.js.
- Timetable and real-time data: opentransportdata.swiss (Open Data, free token required for GTFS-RT).
- Tracks, colours and basemap: © OpenStreetMap contributors, ODbL; vector tiles by OpenFreeMap.
- Aerial photos: © swisstopo SWISSIMAGE, free geodata of the Swiss Confederation.
- Rendering: MapLibre GL JS and three.js, loaded from jsDelivr.
- Station departures: transport.opendata.ch.
- 3D: CesiumJS (Apache 2.0); Google Photorealistic 3D Tiles via Cesium ion; © swisstopo 3D data, terrain and SWISSIMAGE.
- Line colours are the official BVB/BLT colours as tagged in OSM; buses are grey as in OSM.
This started as a POC for Tram Chase, a Scotland-Yard-style multiplayer game on the real network where delays in real life become delays in the game. The game layer (board, hop off, checkpoints) was prototyped and removed; it can sit on top of this data model.

