Skip to content

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_display module (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:

  1. Static GTFS — /gtfs/api/v2/feeds/{alias}/stops/{id}, …/stops/{id}/routes, etc. (long-lived cache, served by the gtfs module). Per-route server-side enrichment goes through hook_gtfs_route_data_alter().
  2. Realtime — short-lived, served by the gtfs_rt module:
  3. /gtfs/api/v2/rt/tripupdates/{rt_stop_id}
  4. /gtfs/api/v2/rt/vehicles?stop_id={rt_stop_id}
  5. /gtfs/api/v2/rt/alerts/stops/{rt_stop_id}
  6. 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:

  1. Region boundaries — a dashed box and label over every resolved [data-gtfs-display="…"] region (including those auto-provisioned by theme.js), showing the region name and its pixel x,y · w×h.
  2. State inspector — a fixed panel pretty-printing the current NS.store.get() state as JSON, refreshed on every store tick.
  3. Freshness indicator — a header dot that pulses each time state.lastSuccess increases (a successful data refresh), plus a live "updated Ns ago" counter (annotated with OFFLINE / cache when relevant).
  4. Fixture capture — copy copies the current payload as JSON; fixture saves it as this display's fixture. Reload with ?fixture=1 to 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

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

This project has been sponsored by: