feat(transit): let a feed name its own modes, and call TTC trams streetcars

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.
This commit is contained in:
Cliff Flood committed 2026-10-15 20:18:15 -04:00
1 parent c5f7fe285b
commit 9fc4a5ac04
4 files changed
+125 -9

No files matched your search

+6
View File
@@ -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,
}),
]);
+42 -4
View File
@@ -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;
}
+25 -5
View File
@@ -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.
+52
View File
@@ -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/);
});