From 9fc4a5ac04d2bcf0265d8ca6cdbd2b82cffe87f9 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Tue, 15 Sep 2026 23:58:51 -0400 Subject: [PATCH] feat(transit): let a feed name its own modes, and call TTC trams streetcars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Toronto 501 and a Helsinki 1010 are both the `tram` mode. Only one of them is called a tram by the people who ride it, and the detection label is documented as "what a person standing at the stop would call it". So the mode stays shared — same silhouette, same speed limits — and a registry entry may carry `modeWords` naming the modes it runs. Everything without an override falls through to the shared table, so this renames one word for one operator and nothing else. Threading the feed to the detection metric needed a resolver in queries.js, since the cached-contact path has only the entry. It answers from live feeds first, so a feed disabled mid-session stops supplying vocabulary. --- src/data/transitFeeds.js | 6 +++ src/layers/transit/policy.js | 46 +++++++++++++++++++++-- src/layers/transit/queries.js | 30 ++++++++++++--- src/layers/transit/vocabulary.test.mjs | 52 ++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 src/layers/transit/vocabulary.test.mjs diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 10d087747..5cbdac82d 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -304,6 +304,12 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ note: 'The City publishes this endpoint from the TTC BusTime NVAS catalogue entry, whose own licence field reads \"License not specified\"; the quote above is the portal-wide Open Government Licence \u2013 Toronto the City applies to its open data, so the credit carries the licence name the way the Metro Transit entry carries its public-domain note. BusTime is the surface system: buses and streetcars report real positions, the subway reports none, so Lines 1/2/4 are simply absent rather than inferred. The endpoint sends no ETag or Last-Modified, so conditional requests fall through to the proxy snapshot TTL.', }), defaultMode: 'bus', + // Toronto rides streetcars. The vehicles are the shared `tram` mode and + // share its silhouette and speed limits; only the word a reader sees + // changes, because "tram" is not what anyone at Queen and Spadina says. + modeWords: Object.freeze({ + tram: Object.freeze({ word: 'Streetcar', abbr: 'STREETCAR' }), + }), routeMode: ttcRouteMode, }), ]); diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index b672581bb..33352bf2d 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -345,7 +345,7 @@ export function buildTransitSelectionCopy( // The mode is said in words on its own line. A glyph and a route id do not // tell a reader that the dot on Tremont Street is a subway train, and a // subway drawn on a street reads as a bug until the card says what it is. - const kindWord = TRANSIT_MODE_WORD[mode] || TRANSIT_MODE_WORD.unknown; + const kindWord = transitModeWord(mode, feed); const details = [`${kindWord} · ${feed.name} · ${feed.region}`]; const motion = []; // What the SCREEN is doing leads, because that is what the reader can check. @@ -479,6 +479,37 @@ export const TRANSIT_MODE_ABBR = Object.freeze({ unknown: 'TRANSIT', }); +/** + * The mode in the operator's own vocabulary. A Toronto 501 and a Helsinki 1010 + * are both the `tram` mode, but only one of them is called a tram by the people + * who ride it. A feed may name the modes it runs; everything else falls through + * to the shared table, so an override renames one word and nothing else. + * @param {string} mode Resolved transit mode. + * @param {object|null} [feed] Registry entry. + * @returns {string} + */ +export function transitModeWord(mode, feed = null) { + return ( + feed?.modeWords?.[mode]?.word || + TRANSIT_MODE_WORD[mode] || + TRANSIT_MODE_WORD.unknown + ); +} + +/** + * The operator's short mode word for the fixed-width detection fields. + * @param {string} mode Resolved transit mode. + * @param {object|null} [feed] Registry entry. + * @returns {string} + */ +export function transitModeAbbr(mode, feed = null) { + return ( + feed?.modeWords?.[mode]?.abbr || + TRANSIT_MODE_ABBR[mode] || + TRANSIT_MODE_ABBR.unknown + ); +} + /** * The bright first line of a vehicle's detection label: its route when the feed * names one, else the fleet number, else the raw id. What a person standing at @@ -521,7 +552,7 @@ export function elideRouteText(text) { * @returns {string} */ export function transitDetectionClass(mode, feed) { - const kind = TRANSIT_MODE_ABBR[mode] || TRANSIT_MODE_ABBR.unknown; + const kind = transitModeAbbr(mode, feed); const operator = typeof feed?.name === 'string' ? feed.name.toUpperCase() : ''; return operator ? `${kind} ${operator}`.slice(0, 20) : kind; @@ -538,10 +569,17 @@ export function transitDetectionClass(mode, feed) { * * @param {object} record Normalized vehicle record. * @param {string} mode Resolved transit mode. + * @param {number} [nowMs] + * @param {object|null} [feed] Registry entry, for its own mode vocabulary. * @returns {string} */ -export function transitDetectionMetric(entry, mode, nowMs = Date.now()) { - const kind = TRANSIT_MODE_ABBR[mode] || TRANSIT_MODE_ABBR.unknown; +export function transitDetectionMetric( + entry, + mode, + nowMs = Date.now(), + feed = null, +) { + const kind = transitModeAbbr(mode, feed); const state = transitVehicleState(entry, nowMs); return state ? `${kind} ${state}` : kind; } diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 68367dc70..470006c85 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -12,6 +12,20 @@ import { } from '../../data/transitFeeds.js'; import { transitModeTier } from '../../data/transitPresetStyle.js'; +/** + * The registry entry behind a vehicle. Live feeds answer first so a feed the + * session disabled stops supplying vocabulary; the registry is the fallback, + * because a vehicle can outlive the poll that brought it. + * @param {object} state Layer state. + * @param {object} entry Vehicle entry. + * @returns {object|null} + */ +function feedForEntry(state, entry) { + const id = entry?.feedId; + if (!id) return null; + return state._activeFeeds.get(id) || getRegisteredTransitFeed(id) || null; +} + /** * The layer row's answer to "what is on screen and is it honest?". * @param {object} context @@ -101,6 +115,7 @@ export function createQueries({ state, parts }) { contact._entry, contact._entry.mode, now, + feedForEntry(state, contact._entry), ); return; } @@ -132,10 +147,10 @@ export function createQueries({ state, parts }) { entry.labelRecord = entry.record; } if (!entry.labelClass || entry.labelMode !== entry.mode) { - const feed = - state._activeFeeds.get(entry.feedId) || - getRegisteredTransitFeed(entry.feedId); - entry.labelClass = transitDetectionClass(entry.mode, feed); + entry.labelClass = transitDetectionClass( + entry.mode, + feedForEntry(state, entry), + ); entry.labelMode = entry.mode; } const contact = { @@ -146,7 +161,12 @@ export function createQueries({ state, parts }) { id: entry.labelId, type: 'VEH', klass: entry.labelClass, - metric: transitDetectionMetric(entry, entry.mode, now), + metric: transitDetectionMetric( + entry, + entry.mode, + now, + feedForEntry(state, entry), + ), // The bracket carries the mode colour in every preset: the detection // canvas sits above the post-FX chain, where the sprites' own colour // is lost under NVG and FLIR. Keyless too — the feeds are keyless. diff --git a/src/layers/transit/vocabulary.test.mjs b/src/layers/transit/vocabulary.test.mjs new file mode 100644 index 000000000..17441e770 --- /dev/null +++ b/src/layers/transit/vocabulary.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildTransitSelectionCopy, + transitDetectionClass, + transitDetectionMetric, + transitModeAbbr, + transitModeWord, +} from './policy.js'; +import { getTransitFeed } from '../../data/transitFeeds.js'; + +const ttc = () => getTransitFeed('ttc-toronto'); +const hsl = () => getTransitFeed('hsl-helsinki'); + +test('an operator that names its own vehicles overrides the shared mode word', () => { + // A Toronto 501 is a streetcar. A Helsinki 1010 is a tram. Both are the + // `tram` mode, so the word a reader sees has to come from the operator, + // not from the mode. + assert.equal(transitModeWord('tram', ttc()), 'Streetcar'); + assert.equal(transitModeAbbr('tram', ttc()), 'STREETCAR'); + assert.equal(transitModeWord('tram', hsl()), 'Tram'); + assert.equal(transitModeAbbr('tram', hsl()), 'TRAM'); +}); + +test('an override renames one mode, never the rest of the feed', () => { + assert.equal(transitModeWord('bus', ttc()), 'Bus'); + assert.equal(transitModeWord('subway', ttc()), 'Subway'); + assert.equal(transitModeAbbr('bus', ttc()), 'BUS'); +}); + +test('a missing feed or unknown mode falls back to the shared table', () => { + assert.equal(transitModeWord('tram', null), 'Tram'); + assert.equal(transitModeWord('spaceship', ttc()), 'Transit vehicle'); + assert.equal(transitModeAbbr('spaceship', ttc()), 'TRANSIT'); + assert.equal(transitModeWord('tram', { name: 'No override' }), 'Tram'); +}); + +test('the selection card calls a Toronto 501 a streetcar', () => { + const record = { id: '4400', routeId: '501', timestamp: 0 }; + const copy = buildTransitSelectionCopy(ttc(), record, 'tram', 0, null, null); + assert.match(copy.details.join(' | '), /Streetcar · TTC/); + assert.doesNotMatch(copy.details.join(' | '), /Tram/); +}); + +test('detection labels carry the operator vocabulary within the field width', () => { + const klass = transitDetectionClass('tram', ttc()); + assert.equal(klass, 'STREETCAR TTC'); + assert.ok(klass.length <= 20, 'fits the detection class field'); + assert.equal(transitDetectionClass('tram', hsl()), 'TRAM HSL'); + const metric = transitDetectionMetric({ mode: 'tram' }, 'tram', 0, ttc()); + assert.match(metric, /^STREETCAR/); +});