Skip to content

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

  • Tyler Brown Cifu Shuster - tbcs
  • Jason P. Salter - jpsalter

This project has been sponsored by: