GTFS Display¶
General-purpose GTFS entity display module: renders live transit information (predictions, alerts, vehicle positions, annotations) on physical screens — stop displays, route displays, agency-wide and system-wide boards.
Displays bypass Drupal's regular theming, are usually accessed anonymously (public kiosks), run on old/weak hardware, and are offline-resilient.
Replaces the legacy
gtfs_stop_displaymodule (GTFS V3 incompatible). No migration path — clean break.
Dependencies: gtfs, gtfs_rt, office_hours.
Architecture¶
DisplayContext (config entity)¶
Addresses a display. Deployable via config sync.
| Property | Description |
|---|---|
entity_type |
Any GTFS bundle name (stops, routes, trips, agency, …) or system (no filter — all agencies). |
entity_ids |
Array of GTFS key-field values (stop_id, route_id, …). Multiple IDs = multi-entity display. Empty = all of type. |
variant |
String (lcd, eink, mobile, …) — a frontend skin/behavior flag, not a Drupal theme. Downstream modules register additional variants via hook_gtfs_display_variants_alter(); a variant may declare a library that the shell attaches automatically. |
refresh_interval |
Int, default 60 (matches gtfs_rt ingestion cadence). |
options |
Arbitrary key-value map passed to drupalSettings.gtfsDisplay. |
Admin UI: /admin/gtfs/display
DisplayAnnotation (content entity)¶
Comments and overrides attached to GTFS entities.
| Field | Description |
|---|---|
entity_type / entity_id |
Primary target (e.g. a stop). |
secondary_entity_type / secondary_entity_id |
Optional secondary scope (e.g. a route at a stop). |
annotation_type |
comment or override. |
body |
text_long. |
override_entity_id |
Replacement entity for overrides. |
office_hours |
Active time windows (requires office_hours module). |
display_contexts |
Optional entity_reference limiting to specific contexts. |
Admin UI: /admin/gtfs/display/annotations
Routes¶
Display shell (HTML)¶
/gtfs/display/{context}
Bare HTML page shell for a saved DisplayContext. Page-cacheable (no live
data). Attaches the JS bundle and drupalSettings.gtfsDisplay.
/gtfs/display/{entity_type}/{entity_id}
Ad-hoc display without a saved context. Synthesizes an ephemeral context
from URL segments. Variant resolution order: field_display_type on the
entity → the saved DisplayContext whose machine name matches the URL group
segment (canonical multi-screen pattern, handled by core) →
hook_gtfs_display_variant_alter() → an explicit ?variant= query override.
/gtfs/display/{entity_type}/{entity_id}/{group}/{surface}
Multi-screen ad-hoc display. group ties coordinated screens together for
temporal sync; surface (role) selects which renderers run on each screen.
Both screens poll the same data endpoint. Example:
/gtfs/display/stops/7948/pd1/top
/gtfs/display/stops/7948/pd1/bottom
Anonymous shells are sent public, max-age=60, s-maxage=60, so a CDN or
reverse proxy can hold them; authenticated ones stay private. A site that
purges its edge when display config changes can hold shells longer at the
edge:
$settings['gtfs_display_shell_shared_max_age'] = 600;
REST API (v2)¶
All endpoints return JSON. Append ?_format=json if not using an Accept
header. Anonymous access requires the "access all gtfs displays"
permission.
GET /gtfs/api/v2/display/annotations/{entity_type}/{entity_id}
Active DisplayAnnotations for a GTFS entity.
POST /gtfs/api/v2/display/{context}/event/{event_name}
Device telemetry (heartbeat, error, offline-recovery). Stores last_seen
per context in State API; fires hook_gtfs_display_event(). Client queues
events in localStorage while offline and flushes on reconnect.
Other¶
/gtfs/display/sw.js Service worker (served at a root-ish scope).
/gtfs/display/autocomplete/{entity_type}
AJAX autocomplete for entity ID fields in the
DisplayContext admin form.
Data flow¶
The frontend fetches data from three independent sources in parallel:
- Static GTFS —
/gtfs/api/v2/feeds/{alias}/stops/{id},…/stops/{id}/routes, etc. (long-lived cache, served by thegtfsmodule). Per-route server-side enrichment goes throughhook_gtfs_route_data_alter(). - Realtime — short-lived, served by the
gtfs_rtmodule: /gtfs/api/v2/rt/tripupdates/{rt_stop_id}/gtfs/api/v2/rt/vehicles?stop_id={rt_stop_id}/gtfs/api/v2/rt/alerts/stops/{rt_stop_id}- Annotations —
/gtfs/api/v2/display/annotations/{type}/{id}(served by this module; bodies are text-format processed server-side).
api.js merges these into a single state object with one canonical
prediction shape — raw GTFS-RT trip updates are normalized to:
{ trip_id, route_id, stop_id, predicted_arrival_epoch, delay,
vehicle_id, headsign, is_last_stop }
sorted by arrival, attached per route. NS.theme.destinations(route) groups
them by headsign for departure boards (excluding trips that terminate at the
displayed stop). "Override" annotations are applied client-side: affected
predictions carry overridden: true / override_stop_id.
Downstream modules that need to reshape or enrich the merged payload register a client-side normalizer instead of altering server responses:
NS.api.normalizers.push(function (data, settings) { /* mutate data */ });
Static GTFS IDs and RT IDs may differ (feeds sourced from the 511 SF Bay
aggregator, for instance, prefix stop_code with "1"); use hook_gtfs_display_rt_entity_ids_alter() to map them — the
mapped IDs reach the client as drupalSettings.gtfsDisplay.rtEntityIds.
Requesting display data triggers an async RT feed refresh if the feed is stale
(gtfs_rt cacheAsync()), so kiosk polls drive feed freshness.
Permissions¶
- administer gtfs display (restricted)
- access all gtfs displays (wildcard — grant to Anonymous for public kiosks)
- access gtfs display {id} — generated per context via permission deriver.
These appear on the permissions page only after the context is created and caches are rebuilt (same behavior as node types).
- access gtfs display debug (restricted) — enables the in-page developer
debug overlay. Granted to administrators by default. Drives
drupalSettings.gtfsDisplay.debug; without it the overlay can never be toggled, so public kiosks never expose state.
Frontend¶
frontend/src/ — vanilla JS reactive micro-app. No framework, no Proxy, no
runtime dependencies. esbuild bundles it to a single ES2015 IIFE. Designed
for old kiosk hardware.
Stylesheets are authored in SCSS (frontend/src/scss/, with a shared
tokens/mixins partial available to skins) and compiled by the same build to
plain flat CSS — no runtime CSS-variable support required of the panels.
Modules¶
| File | Responsibility |
|---|---|
store.js |
Pub-sub state store. get([key]) (whole state when no key) / set(patch) (notifies subscribers) / patch(patch) (silent, for derived values) / subscribe(fn). |
api.js |
Parallel fetch of static + RT + annotation endpoints; normalizes everything to the canonical payload shape (see Data flow), applies override annotations, then runs NS.api.normalizers. Exponential backoff (5→10→30→60s). localStorage last-known-good. Clock-skew correction from meta.now for lockstep multi-screen sync. Deterministic per-group poll jitter. Fixture mode (?fixture=1) for offline renderer development. |
clock.js |
1s tick: updates state.now; drives countdown interpolation. |
paging.js |
Clock-derived page index for lockstep rotation: index = floor((now + skew) / page_seconds) % count. Configured via options.page_seconds (default 8s). |
theme.js |
Declarative rendering engine. Downstream modules register template descriptors via NS.registerTemplate(name, descriptor) — or tweak existing ones via NS.extendTemplate(name, overrides) — instead of writing imperative DOM code. The engine handles keyed row creation, sweeping, textContent diffing, and region auto-provisioning. Descriptor mistakes are reported to the console when the debug flag is set (silent in production). Shared helpers: activeRoute, destinations, formatEta, stopAnnotations, stripTags. |
render/ |
Built-in template registrations: predictions, alerts, annotations, vehicles. |
route-map.js |
Optional MapLibre route-map template (separate bundle, gtfs_display/route_map library). Skins that want a live vector map attach the library and include 'route-map' in a role; requires the gtfs_display_map module. |
main.js |
Boot: reads drupalSettings.gtfsDisplay, starts polling and clock, selects renderers by role (NS.roles[settings.role] || full), registers service worker (feature-detected, HTTPS required). |
debug.js |
Developer debug overlay (region boundaries, live state inspector, data-freshness indicator, fixture capture). Inert unless toggled on; see Debug overlay. |
sw.js |
Service worker: cache-first shell precache; network-first with cache fallback for API endpoints. |
Staleness UI (mandatory)¶
- After ~2 missed poll cycles:
"Updated X min ago"banner. - After ~5 minutes offline: countdowns hidden,
"Live predictions unavailable"shown. Scheduled times used as fallback.
A frozen
"2 min"is the failure mode this design explicitly prevents.
Debug overlay¶
debug.js ships an in-page diagnostic overlay for developers and operators.
It is inert by default and is gated by the access gtfs display debug
permission: the shell controller only sets drupalSettings.gtfsDisplay.debug =
true for users who hold it (administrators by default), so public kiosks can
never expose it.
When the flag is set, press the d key (ignored while typing in a form
field) to toggle the overlay. It provides:
- Region boundaries — a dashed box and label over every resolved
[data-gtfs-display="…"]region (including those auto-provisioned bytheme.js), showing the region name and its pixelx,y · w×h. - State inspector — a fixed panel pretty-printing the current
NS.store.get()state as JSON, refreshed on every store tick. - Freshness indicator — a header dot that pulses each time
state.lastSuccessincreases (a successful data refresh), plus a live "updated Ns ago" counter (annotated withOFFLINE/cachewhen relevant). - Fixture capture —
copycopies the current payload as JSON;fixturesaves it as this display's fixture. Reload with?fixture=1to boot from the saved payload with polling disabled — the fastest loop for renderer/CSS development (no live feed needed). Console equivalents:GtfsDisplay.api.saveFixture()/loadFixture(data).
The overlay can also be driven from the console via GtfsDisplay.debug.toggle()
/ enable() / disable() (the d shortcut still requires the permission flag).
Hooks¶
See gtfs_display.api.php for full signatures and DEVELOPMENT.md for
worked examples.
Multi-screen / lockstep sync¶
Two screens sharing a group string (e.g. /stops/7948/pd1/top and
/stops/7948/pd1/bottom) poll the same data endpoint and derive the active
page index from wall-clock time:
index = floor((Date.now() + clockSkew) / page_seconds) % routeCount
clockSkew is computed from meta.now in the API response and persisted in
localStorage, keeping screens aligned even with drifting kiosk RTCs. Poll
jitter is derived deterministically from the group string so all screens in a
group refresh against the same feed window.
Configure rotation speed via options.page_seconds (default 8). The -1 index
(no operating routes) triggers the empty/annotations fallback.
Maintainers¶
This project has been sponsored by: