Skip to content

GTFS Display — Developer Guide

How to build a display skin in a downstream module. The examples below use a hypothetical gtfs_myagency_display; substitute your own module name.


Overview

A display skin is three small pieces:

  1. One PHP hook — register a variant (label + library).
  2. One library — your CSS and JS bundle, declared in libraries.yml.
  3. Frontend JS — register/extend template descriptors and roles via NS.*.

Optional extras (ID mapping, payload normalizers, telemetry) are covered below.


1. Register a variant

A variant is a string (lcd, eink, pylon, …) that becomes a CSS body class and a JS behavior flag. Registering it with a library makes the shell attach your CSS/JS automatically whenever a display resolves to that variant — this is usually the only PHP hook a skin needs:

function my_module_gtfs_display_variants_alter(array &$variants): void {
  $variants['my_skin'] = [
    'label' => t('My Skin'),
    'library' => 'my_module/my_skin',
  ];
}

Variant resolution for clean multi-screen URLs

Multi-screen URLs carry a group segment (/gtfs/display/stops/7948/pd1/top). When the group matches the machine name of a saved DisplayContext, core automatically applies that context's variant — create a DisplayContext named pd1 with variant my_skin and the URLs just work. hook_gtfs_display_variant_alter() exists for conventions beyond that (e.g. deriving a variant from a group naming scheme), and ?variant= always wins as an explicit override.


2. Declare the library

In my_module.libraries.yml:

my_skin:
  version: VERSION
  css:
    theme:
      css/my_skin.css: {}
  js:
    js/dist/my-skin.js: { minified: true }
  dependencies:
    - gtfs_display/display          # must come before your JS

Declaring gtfs_display/display as a dependency ensures the core bundle (and NS.* globals) load before your script runs.

Need a library on every display regardless of variant (e.g. an RT normalizer)? That's what hook_gtfs_display_shell_libraries_alter() is for. If your JS also needs to run on regular entity pages, attach it from a preprocess hook, which is how an agency skin attaches its RT normalizer.


3. Register frontend templates and roles

The frontend uses a declarative rendering engine. Downstream modules register template descriptors — the engine handles keyed row creation, DOM diffing, and region auto-provisioning.

// NS is the GtfsDisplay namespace, available on window once the core bundle loads.
var NS = window.GtfsDisplay;
NS.registerTemplate('my-region', {
  region: '[data-gtfs-display="my-region"]',
  // regionParent: '.gtfs-display__content',  // auto-created if absent
  dataPath: function (state) { return state.data && state.data.entities[0]; },
  fields: [
    { selector: '.my-label', tag: 'span', text: function (d) { return d.name; } },
  ],
  // rows: { key: fn, cells: [...] }          // for repeating row lists
  // empty: { dataPath, fields }              // rendered when dataPath returns null/[]
  // onMount: function (container) {}         // called once on first render
  // onUpdate: function (container, data) {}  // called on every state update
});

// Assign templates to roles. Roles are selected by the `surface` URL segment.
NS.roles.my_top    = ['my-region', 'my-header'];
NS.roles.my_bottom = ['my-departures'];

Regions are auto-created in the DOM if the selector is not found (appended to [data-gtfs-display="content"] by default).

Tweaking a built-in template

To adjust an existing template (heading, empty text, region placement, a single cell list) without copying its whole descriptor, use NS.extendTemplate — a shallow merge onto the registered descriptor:

NS.extendTemplate('alerts', {
  heading: 'Alerts <small>alertas</small>',
  emptyText: '',                              // suppress the empty-state <p>
  regionParent: '.gtfs-display__content'
});

Top-level keys replace wholesale: passing rows swaps the entire rows definition. Re-registering a name with registerTemplate also works (last write wins) — the engine logs an informational notice in debug mode so accidental collisions are visible.

Debug-mode validation

With the debug flag on (see Debug overlay in the README), the engine console-warns about descriptor mistakes that silently render nothing in production: missing region/dataPath, unknown descriptor keys, non-array fields, non-function rows.key, and unresolvable field selectors. Zero cost on kiosks.

Timing

Your script must load after the core bundle. The library dependency chain handles this: declare gtfs_display/display as a dependency of your library. Access window.GtfsDisplay at runtime (inside functions/callbacks), not at module evaluation time, to avoid ordering issues.


4. The data contract

api.js polls the static, realtime and annotation endpoints and merges them into one payload with a single canonical prediction shape (see the README Data flow section):

state.data.entities[n].routes[m].predictions = [
  { trip_id, route_id, stop_id, predicted_arrival_epoch, delay,
    vehicle_id, headsign, is_last_stop },
  ...  // sorted by predicted_arrival_epoch
]

Helpers that consume it:

  • NS.theme.activeRoute(state) — the route selected by the current paging tick (lockstep across screens).
  • NS.theme.destinations(route) — predictions grouped by headsign for departure boards; excludes trips that terminate at the displayed stop.
  • NS.theme.formatEta(predictions, nowMs, maxPreds, append) — "3, 12 min", "Now", "--", or a scheduled time string; .scheduled flags fallback data.

Client-side normalizers

To reshape or enrich the merged payload, push a normalizer instead of altering server responses or wrapping renderers:

NS.api.normalizers.push(function (data, settings) {
  // e.g. deterministic route ordering for lockstep paging:
  (data.entities || []).forEach(function (entity) {
    (entity.routes || []).sort(function (a, b) {
      return String(a.route_id).localeCompare(String(b.route_id));
    });
  });
});

Normalizers run on every successful poll, after merging, before the store updates.

Server-side enrichment

To add fields to each route served by the v2 stops/{id}/routes endpoint (logo URLs, uploaded images, geodata…), implement hook_gtfs_route_data_alter(array &$route_data, Route $route), implemented in your module as gtfs_myagency_display_gtfs_route_data_alter().


5. Map static IDs to RT feed IDs

Some agencies use different stop identifiers in their RT feed and static GTFS data. Map them server-side; the mapped IDs reach the frontend as drupalSettings.gtfsDisplay.rtEntityIds and drive the RT polling URLs and prediction matching:

function my_module_gtfs_display_rt_entity_ids_alter(array &$rt_entity_ids, DisplayContext $context): void {
  if ($context->getTargetEntityType() !== 'stops') {
    return;
  }
  foreach ($rt_entity_ids as &$id) {
    $stop = Stop::getByKey((string) $id);
    if ($stop && !$stop->get('stop_code')->isEmpty()) {
      $id = $stop->get('stop_code')->value;
    }
  }
}

A real example: the 511 SF Bay regional feed prefixes stop_code with "1", so a static stop_id of "7948" arrives as "17948" in realtime. Agencies sourcing realtime from an aggregator map between the two here. A JS normalizer in your skin can strip the same prefix on entity pages outside the display shell, and hook_gtfs_display_rt_agency_id_alter() does the equivalent for agency IDs in alert matching.


6. Route maps

Two options for a per-route map region:

  • Live vector map — attach the core gtfs_display/route_map library (requires the gtfs_display_map module) and include 'route-map' in a role. Inject route.geodata ({ geojson, type?, style? }) via hook_gtfs_route_data_alter(); the basemap style is provided to drupalSettings.gtfs_display_map.style by the shell automatically. The map initializes once and only swaps GeoJSON on paging ticks.
  • Curated image — a skin can instead render an uploaded image per route (route-map-image template, field_route_map + route_map_image_url). Both templates target the same region; include only one of them in a role.

7. Custom Twig templates

The shell emits template suggestions you can override without touching the default template:

gtfs-display-page--{variant}.html.twig
gtfs-display-page--role-{role}.html.twig
gtfs-display-page--{entity_type}.html.twig
gtfs-display-page--{variant}--role-{role}.html.twig

Place overrides in your module's templates/ directory. The default template (gtfs-display-page.html.twig) provides the minimum DOM skeleton the JS engine expects:

  • [data-gtfs-display="title"] — entity title
  • [data-gtfs-display="clock"] — live clock
  • [data-gtfs-display="staleness"] — offline/stale banner (hidden by default)
  • [data-gtfs-display="content"] — universal content mount (required)

Only the content region is required. Template descriptors auto-provision any other regions they need inside content (or a specified regionParent).


8. React to device telemetry

Kiosk devices POST heartbeat/error/offline-recovery events to /gtfs/api/v2/display/{context}/event/{event_name}:

function my_module_gtfs_display_event(string $event_name, DisplayContext $context, array $payload): void {
  if ($event_name === 'error') {
    \Drupal::logger('my_module')->warning(
      'Display @id reported: @msg',
      ['@id' => $context->id(), '@msg' => $payload['message'] ?? '(no message)']
    );
  }
}

9. Frontend build

One build script serves the core bundle and every skin: gtfs_display/frontend/build.sh. A skin ships a small frontend/build.conf (paths relative to its frontend/ directory) and nothing else — no build script, no package.json dependencies, no local esbuild or sass install:

# my_module/frontend/build.conf
ENTRY=src/roles.js
OUTFILE=../js/dist/my-skin.js
SCSS_ENTRY=src/scss/my-skin.scss     # optional
SCSS_OUTFILE=../css/my-skin.css      # optional

From gtfs_display/frontend (one-time npm install there):

npm run build   # minified: core + every skin with a build.conf
npm run dev     # unminified one-shot build of everything
npm run watch   # parallel watchers — edit ANY skin or core source, it rebuilds

Build one skin only: sh build.sh --skin my_module (add --dev/--watch); core only: sh build.sh --core. Skins may declare package.json scripts that delegate to this build, so npm run watch works from either directory.

esbuild traces the import graph from the entry point and emits one minified IIFE targeting ES2015 — no file list to maintain. The core bundle is ~36 KB minified (including the debug overlay); keep skin bundles lean — a typical skin bundle is around 5 KB.

SCSS

Stylesheets are authored in SCSS (frontend/src/scss/) and compiled by the same build to the committed css/*.css files — edit the SCSS, never the CSS output. Everything Sass offers ($variables, nesting, mixins, @each) compiles away at build time, so the output is plain flat CSS safe for the oldest panels — no runtime CSS-custom-property support required.

Skin SCSS compiles with the core frontend/src/scss/ directory on the load path, so shared tokens and mixins are one import away:

@use 'shared';

.gtfs-display--my-skin {
  color: shared.$color-ink;
  @include shared.no-motion;   // mandatory for e-ink/slow-LCD variants

  .gtfs-display__departure-eta {   // nest descendant hooks
    font-weight: 500;
  }
  &__custom-element { … }          // BEM: compiles to the flat class
}

Production builds emit compressed CSS (declare minified: true in libraries.yml); --dev/--watch emit expanded CSS to the same path — rebuild without --dev before committing.

Tests

npm test (from gtfs_display/frontend) runs the node test suite in frontend/test/ — no framework, just node --test. It covers the canonical prediction normalization, destination grouping, ETA formatting, lockstep paging math, the store, and template registry semantics. If you change the canonical payload shape, these tests are the contract you're breaking.

Cache busting

gtfs_display_library_info_alter() stamps a content hash as the version of every display library (core bundles and any library depending on gtfs_display/display), so asset URLs change whenever a bundle is rebuilt (?v=<hash>). Kiosks pick up new code on the next shell refresh even though the service worker serves assets cache-first. No manual version bumps needed.

The dev loop without a kiosk (fixtures)

  1. Open a display once with the debug overlay available, press d, click fixture (saves the current payload for that display).
  2. Reload with ?fixture=1 — the app renders from the saved payload and never polls.
  3. npm run watch + browser refresh: iterate on renderers/CSS completely offline. GtfsDisplay.api.loadFixture(json) in the console swaps payloads live.

Shell page — no theme CSS

DisplayContextController::buildShellResponse() constructs the HTML response directly — it does not call renderBarePage() and never touches the active Drupal theme stack. The page contains only:

  • normalize.css from core/normalize
  • The gtfs_display/display library CSS and JS
  • The resolved variant's registered library (if any), plus libraries from hook_gtfs_display_shell_libraries_alter
  • An inline window.drupalSettings = {...}; script

drupalSettings is inlined as a plain script tag in <head> before the module JS files. The core/drupalSettings Drupal library is not included. If downstream modules need to add settings, use hook_gtfs_display_shell_attachments_alter to append to $attached['drupalSettings'] before the response is built.


CSS conventions

Author styles in SCSS (see Frontend build → SCSS above). Target stable data-gtfs-display attributes and BEM classes — avoid selecting by element or position:

[data-gtfs-display="content"] { … }
.gtfs-display--my-skin         { … }
.gtfs-display--role-top        { … }
.gtfs-display__eta--scheduled  { … }  /* fallback when no live prediction */
.gtfs-display__staleness--offline { … }

Do not use CSS animations or transitions on eink variants — layout thrash kills slow e-ink panels.


Hook reference

See gtfs_display.api.php for full signatures and docblocks.

Hook When to use
hook_gtfs_display_variants_alter Register a variant (label + auto-attached library)
hook_gtfs_display_variant_alter Custom group/surface → variant conventions (the saved-context match is handled by core)
hook_gtfs_display_shell_libraries_alter Attach non-variant libraries to the display shell
hook_gtfs_display_shell_attachments_alter Append drupalSettings to the shell
hook_gtfs_display_rt_entity_ids_alter Map static GTFS IDs to RT feed IDs
hook_gtfs_display_rt_agency_id_alter Map static agency ID to the RT feed's agency ID
hook_gtfs_route_data_alter Enrich per-route data on the v2 routes endpoint (provided by gtfs)
hook_gtfs_display_annotation_applies_alter Override annotation applicability
hook_gtfs_display_event React to device telemetry