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:
- One PHP hook — register a variant (label + library).
- One library — your CSS and JS bundle, declared in
libraries.yml. - 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;.scheduledflags 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_maplibrary (requires thegtfs_display_mapmodule) and include'route-map'in a role. Injectroute.geodata({ geojson, type?, style? }) viahook_gtfs_route_data_alter(); the basemap style is provided todrupalSettings.gtfs_display_map.styleby 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-imagetemplate,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)¶
- Open a display once with the debug overlay available, press
d, click fixture (saves the current payload for that display). - Reload with
?fixture=1— the app renders from the saved payload and never polls. 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.cssfromcore/normalize- The
gtfs_display/displaylibrary 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 |