GTFS Display Map RT¶
Live realtime vehicle layer for GTFS display maps, and the embeddable map
variant for embedding in another site. Successor to the legacy gtfs_nextbus_map
module: same product (live route/stop maps in an iframe), but vehicles come
from this site's own gtfs_rt REST API (511 GTFS-RT ingest) instead of
browser-side calls to umoiq/nextbus, and rendering rides the shared
gtfs_display / gtfs_display_map stack (MapLibre GL) instead of Leaflet.
What it adds (and what it reuses)¶
Added by this module:
- a map display variant with URL convention
/gtfs/display/{route|stop}/{id}/map/embed
(a group segment of map selects the variant — no query string needed);
- a live-vehicles MapLibre overlay: heading-arrow markers refreshed from
the display store on every poll tick, stop dots with click-for-predictions
popups, route shapes with legacy casing, bounds fitting, zoom controls;
- per-vehicle ETAs in the marker tooltip on stop embeds — see below;
- three alter hooks for agency branding (see
gtfs_display_map_rt.api.php), so nothing agency-specific lives here:
hook_gtfs_display_map_rt_vehicle_icons_alter() for per-vehicle liveries,
hook_gtfs_display_map_rt_default_icons_alter() to replace the Maki glyph
for a whole route_type, and
hook_gtfs_display_map_rt_stop_marker_alter() to recolor the stop dot,
replace it with the agency's own marker image, or show that image only from
a given zoom up;
- a configurable embed basemap (Style URL setting, default
https://styles.gtfs.media/gtfs/basemap.json; empty falls back to the
site-wide gtfs_display_map style — the style host must send CORS headers);
- iframe embed plumbing: X-Frame-Options removed and CSP
frame-ancestors set from config on map-variant responses, plus the
postMessage contract {gtfsLiveMap: {status: 'ready'|'empty', id}} so the
host page can reveal or collapse the embed. An id the feed does not know
still answers 404, but with a bare body that posts 'empty' at once, so a
host never sits on a frame of "Page not found" waiting for a verdict.
Reused, not reimplemented: the display shell and its polling store
(gtfs_display), the route-shape map and basemap wiring
(gtfs_display/route_map + gtfs_display_map), and the RT REST resources
(gtfs_rt). This module contains no map bootstrap of its own, and its
only timer refreshes the vehicles a stop embed, or a registered vehicle source,
fetches outside the store.
Requires gtfs_display with the NS.routeMap handle in route-map.js and
per-entity-type vehicle polling in api.js (both added 2026-07-30).
Route embeds vs. stop embeds¶
A route embed draws that route's shape and every stop on it, then fits the view to the whole line.
Stop markers respond to zoom rather than to context: with an icon_min_zoom
configured, a whole-route view draws plain dots and the agency's full marker
appears once the map is down to a couple of blocks. Ninety-five badges strung
along a route line is noise; ninety-five dots reads as a route. The dot stays
in the layer above the swap zoom as an invisible click target sized to the
image, so hit areas track what is drawn.
A stop embed centers on the stop at zoom 15 and draws the shape of each
route serving it (up to 10), each in its own route_color. It does not fit
bounds — the neighborhood view is the point. Vehicles are the union of every
serving route's live vehicles, so vehicles further along the line exist as
markers you can pan to. Drawing the shapes matters more than it looks: a route
whose two directions run on a street couplet puts half its vehicles a block
off the stop's own street, which reads as a bug without a line under them.
Which vehicles show an ETA¶
On a stop embed the tooltip reads 1 4 min for the next two arrivals per
route, and plain 1 for everyone else. That restraint is deliberate. A
prediction says when a vehicle next serves this stop, so a bus that has just
passed outbound is quoted its return trip: measured on the live feed, a coach
sitting 100 m from the stop read 37 minutes while one 1.3 km out read 4. Both
numbers are correct and the near one looks like a bug, so only the leading
arrivals — the same ones a departure board shows — carry a number.
The join is vehicle_id, which normalizePredictions() already puts on every
canonical prediction; no extra request is made. Note that trip_id does not
match between the vehicle-positions and trip-updates feeds, so it is not a
usable key.
Reading vehicles from the browser¶
By default every vehicle on the map comes through this site's gtfs_rt
resources. An agency whose AVL vendor answers browsers directly, with CORS
headers and a key it is content to publish, can skip the backend by
registering a source from its own library:
window.GtfsDisplay.mapRt = window.GtfsDisplay.mapRt || {};
window.GtfsDisplay.mapRt.vehicleSource = (routeIds) =>
fetchFromVendor(routeIds).then(toVehiclePositions);
The function takes the route ids the embed is showing (one for a route
embed, up to 10 serving routes for a stop embed) and resolves to rows in the
shape /rt/vehicles returns: {vehicle: {id}, trip: {routeId}, position:
{latitude, longitude, bearing}}. A rejection leaves the markers already on
the map where they are. The overlay polls it at the embed refresh interval,
on its own timer.
Attach the library from hook_gtfs_display_shell_attachments_alter() when
the variant is map, give it a dependency on gtfs_display_map_rt/map_embed,
and set drupalSettings.gtfsDisplay.pollVehicles to FALSE there too, so the
display store stops requesting vehicles the map no longer reads.
URL parameters (legacy parity)¶
?highlightStop=<stop_id|stop_code>— open that stop's popup on load?showBusMarkers=false— hide the route label next to vehicle markers
Embedding¶
<iframe title="Live map" src="https://example.org/gtfs/display/route/5R/map/embed"></iframe>
<script>
window.addEventListener('message', (e) => {
if (e.origin !== 'https://example.org') return;
const m = e.data && e.data.gtfsLiveMap;
if (m && m.status === 'empty') { /* hide the container */ }
if (m && m.status === 'ready') { /* reveal the container */ }
});
</script>
Keep the container's iframe invisible (visibility: hidden, not
display: none, which would give the map a 0×0 canvas) until 'ready', and
keep a generous timeout of your own for the case where this site is
unreachable — a frame that never loads never posts. Every other outcome,
including a retired or mistyped id, arrives as a message: the 404 body posts
'empty' as soon as it parses.
Settings¶
/admin/gtfs/display/map-rt — basemap style URL, allowed frame ancestors,
poll interval (default 10 s).
Basemap coverage¶
Read this before deploying outside California.
The default style URL is https://styles.gtfs.media/gtfs/basemap.json, hosted by the
maintainers. It draws its vector tiles from
public.tiles.gtfs.media/california.pmtiles — California only — with glyphs from
fonts.gtfs.media and sprites from sprites.gtfs.media. Anywhere else, the basemap comes
up blank, with the route shape, stops and live vehicles still drawn on top of it.
Planet-wide tiles are in progress. Until then, set the Style URL to your own MapLibre GL style: a self-hosted pmtiles archive, a commercial tile provider, or your own vector tile server. The style host must send CORS headers. Leaving the setting empty falls back to the site-wide gtfs_display_map style instead, which has the same California limit.
The vehicle, stop and shape layers this module draws do not depend on the basemap, so changing it costs nothing but the tiles.
Build¶
The JS is built by gtfs_display's shared frontend build (skin discovery via
frontend/build.conf):
cd ../gtfs_display/frontend && sh build.sh --skin gtfs_display_map_rt
Maintainers¶
This project has been sponsored by: