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/); +});