From 2dd96e1f25f37a1464cf84ddbd162c677e546f87 Mon Sep 17 00:00:00 2001 From: MiguelGFerreira Date: Sat, 12 Sep 2026 14:24:33 -0300 Subject: [PATCH 01/24] =?UTF-8?q?feat(data):=20add=20seven=20Brazilian=20B?= =?UTF-8?q?ike=20Ita=C3=BA=20cities=20to=20the=20GBFS=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and Curitiba run on the same PBSC platform as the existing Austin, Honolulu and Chattanooga entries, so they need no new feed handling and no proxy allowlist change — isAllowedGbfsHost already accepts *.publicbikesystem.net. These are the layer's first stations outside the United States: 1,017 stations across the seven systems. --- CHANGELOG.md | 4 ++ DATA_SOURCES.md | 2 +- src/data/dataCredits.js | 2 +- src/layers/bikeshare/registry.js | 84 ++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f8245b4..3c451b4f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Add seven Brazilian Bike Itaú (Tembici) cities to the GBFS bikeshare registry: + São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and + Curitiba. These are the layer's first stations outside the United States. + - Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source. - Separate submarine cable sources and rendering components, and export bundled geography lookup modules. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 650d3fe6b..2dc8ba0d7 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -35,7 +35,7 @@ How to read this: | **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" | | **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) | | **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" | -| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **GBFS (Lyft / BCycle / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle, Bike Itaú/Tembici) + its `license_url` | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 009ff6d01..817261608 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -137,7 +137,7 @@ export const DATA_CREDITS = [ }, { key: 'gbfs', - html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', + html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle, Bike Itaú/Tembici)', }, { key: 'radio-browser', diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index ff752f664..a5a1ee32c 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -167,6 +167,90 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', provider: 'Bike Chattanooga', }, + { + id: 'sao-paulo-bike-itau', + city: 'São Paulo, SP', + centerLat: -23.5505, + centerLon: -46.6333, + loadRadiusKm: 100, + stationInformationUrl: + 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'rio-de-janeiro-bike-itau', + city: 'Rio de Janeiro, RJ', + centerLat: -22.9068, + centerLon: -43.1729, + loadRadiusKm: 100, + stationInformationUrl: + 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'salvador-bike-itau', + city: 'Salvador, BA', + centerLat: -12.9777, + centerLon: -38.5016, + loadRadiusKm: 90, + stationInformationUrl: + 'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'recife-bike-itau', + city: 'Recife, PE', + centerLat: -8.0476, + centerLon: -34.877, + loadRadiusKm: 90, + stationInformationUrl: + 'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'porto-alegre-bike-itau', + city: 'Porto Alegre, RS', + centerLat: -30.0346, + centerLon: -51.2177, + loadRadiusKm: 90, + stationInformationUrl: + 'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'brasilia-bike-itau', + city: 'Brasília, DF', + centerLat: -15.7939, + centerLon: -47.8828, + loadRadiusKm: 90, + stationInformationUrl: + 'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'curitiba-bike-itau', + city: 'Curitiba, PR', + centerLat: -25.4284, + centerLon: -49.2733, + loadRadiusKm: 90, + stationInformationUrl: + 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, bcycleEntry({ id: 'boulder-bcycle', city: 'Boulder, CO', From ccdf8f0dc287129bf0e45158807cec26bf478bff Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Mon, 14 Sep 2026 13:45:20 +0200 Subject: [PATCH 02/24] feat(cctv): enrich identified cameras with datasheet FOV + specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a CCTV source pack (or a seed) names its hardware `model`, resolve it against a camera-model spec table: the coverage frustum then uses the manufacturer's horizontal FOV instead of the pose estimate, and the HUD meta line gains the model's resolution, night-vision, and PTZ specs. Cameras with no identified model are unchanged — unknown never implies anything, matching the layer's existing honesty about RAW PRIOR poses. Spec records are vendored verbatim from the CCTV Camera Database (https://github.com/ch-bas/cctv-camera-database, CC0-1.0), an open dataset of 12,000+ camera models transcribed from manufacturer datasheets. This lands a small starter table plus the join + parsing helpers; source packs that publish their hardware can grow it. Records describe camera products (optics, resolution, night vision) only — no endpoints or access data. - src/layers/cctv/modelSpecs.js: spec table, case-insensitive/alias lookup, wide-end FOV parser, HUD summary formatter - src/layers/cctv/modelSpecs.test.mjs: lookup/alias/unknown, FOV parsing across the real fov string shapes, summary rendering - src/layers/cctv/catalog.js: apply enrichment in both catalog builders - src/layers/cctv/presentation.js: label a datasheet FOV, append the spec summary, and pass fovSource + spec through the public camera state --- src/layers/cctv/catalog.js | 27 ++++++ src/layers/cctv/modelSpecs.js | 129 ++++++++++++++++++++++++++++ src/layers/cctv/modelSpecs.test.mjs | 63 ++++++++++++++ src/layers/cctv/presentation.js | 11 ++- 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/layers/cctv/modelSpecs.js create mode 100644 src/layers/cctv/modelSpecs.test.mjs diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 1a6ebe7c6..be6dc7e90 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,4 +1,25 @@ import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; +import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js'; + +/** + * Applies datasheet enrichment to a camera in place: when it names a known + * hardware `model`, the manufacturer's horizontal FOV replaces the pose + * estimate and the spec record is attached for the HUD. Unknown models — the + * common case — change nothing. + * @param {Object} camera - Camera being built (mutated). + * @param {string} [modelName] - Hardware model from a source or seed. + * @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper. + */ +function applyModelEnrichment(camera, modelName, clamp) { + const spec = modelName ? lookupModelSpec(modelName) : null; + if (!spec) return; + const fov = horizontalFovDeg(spec); + if (fov) { + camera.fovDeg = clamp(fov, 20, 125); + camera.fovSource = 'datasheet'; + } + camera.spec = spec; +} export function createCatalog({ state: layerState, services, parts, source }) { const { CITY_POIS } = services.locations; @@ -45,6 +66,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { parts.model.clamp(seed.elevationM ?? 22, 8, 80), pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4), }; + applyModelEnrichment(camera, seed.model, parts.model.clamp); parts.model.ensureCameraPose(camera); catalog.push(camera); } @@ -205,6 +227,11 @@ export function createCatalog({ state: layerState, services, parts, source }) { : null, poseSource, }; + applyModelEnrichment( + camera, + source.model || seed?.model, + parts.model.clamp, + ); parts.model.ensureCameraPose(camera); catalog.push(camera); } diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js new file mode 100644 index 000000000..e9bb48856 --- /dev/null +++ b/src/layers/cctv/modelSpecs.js @@ -0,0 +1,129 @@ +/** + * @module cctvModelSpecs + * + * Hardware-model spec enrichment for the CCTV layer. + * + * When a catalog camera names its hardware model (source packs may set + * `model`; hand-authored seeds may too), the model resolves to a datasheet + * record: the camera's frustum then uses the manufacturer's horizontal FOV + * instead of an estimate, and the HUD meta line gains the model's real specs. + * Cameras without an identified model are untouched — unknown never implies + * anything. + * + * Spec records are vendored verbatim from the CCTV Camera Database + * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset + * of 12,000+ camera models transcribed from manufacturer datasheets (84% with + * FOV). This module ships a small starter table; city packs that publish + * their hardware models can grow it, or vendor the full extract. + * + * Scope guardrail: records describe camera PRODUCTS (optics, resolution, + * night vision) — never endpoints, stream paths, or access/config data. + */ + +/** Verbatim records from the dataset's model-enrichment extract (v2.19.0). */ +const RECORDS = [ + { + id: 'axis-q6135-le', + brand: 'Axis', + model: 'Q6135-LE', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6135-le/', + aliases: ['01959-004'], + fov_deg: '58.3-2.4 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'ir', range_m: 250 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, + { + id: 'hanwha-xnp-6400rw', + brand: 'Hanwha', + model: 'XNP-6400RW', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/hanwha-xnp-6400rw/', + aliases: ['Wisenet PTZ Plus 2MP 40x IR PTZ'], + fov_deg: '65.66-1.88 horizontal / 39.4-1.09 vertical', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'ir', range_m: 200 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, + { + id: 'hikvision-ds-2cd2087g3-li2uy', + brand: 'Hikvision', + model: 'DS-2CD2087G3-LI2UY', + type: 'bullet', + url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/', + fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)', + resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' }, + night_vision: { type: 'color', range_m: 40 }, + environment: ['outdoor'], + ndaa_compliant: false, + }, + { + id: 'axis-f4105-lre', + brand: 'Axis', + model: 'F4105-LRE', + type: 'dome', + url: 'https://www.cctv-database.com/camera/axis-f4105-lre/', + fov_deg: '110 horizontal, 60 vertical', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + night_vision: { type: 'ir', range_m: 10 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, +]; + +const BY_MODEL = new Map(); +for (const record of RECORDS) { + BY_MODEL.set(record.model.toLowerCase(), record); + for (const alias of record.aliases || []) + BY_MODEL.set(alias.toLowerCase(), record); +} + +/** + * Looks up a spec record by model string (case-insensitive; aliases match too). + * @param {string} model - Hardware model as named by a source pack or seed. + * @returns {Object|null} The verbatim spec record, or null when unknown. + */ +export function lookupModelSpec(model) { + if (!model) return null; + return BY_MODEL.get(String(model).trim().toLowerCase()) || null; +} + +/** + * Datasheet horizontal FOV in degrees: the FIRST number in the `fov_deg` + * string, which for varifocal/PTZ ranges ('58.3-2.4 horizontal') is the wide + * end — the honest default for a coverage cone. + * @param {Object} spec - A record from this module. + * @returns {number|null} Wide-end horizontal FOV, or null when unstated. + */ +export function horizontalFovDeg(spec) { + const match = String(spec?.fov_deg || '').match(/\d+(?:\.\d+)?/); + const value = match ? Number(match[0]) : NaN; + return Number.isFinite(value) && value > 0 ? value : null; +} + +/** + * One-line spec summary for HUD meta text. + * @param {Object} spec - A record from this module. + * @returns {string} e.g. 'Axis Q6135-LE · 1080p · IR 250m · PTZ' ('' for null). + */ +export function specSummary(spec) { + if (!spec) return ''; + const parts = [`${spec.brand} ${spec.model}`]; + if (spec.resolution?.label) parts.push(spec.resolution.label); + if (spec.night_vision) { + parts.push( + `${spec.night_vision.type.toUpperCase()}${spec.night_vision.range_m ? ` ${spec.night_vision.range_m}m` : ''}`, + ); + } + if (spec.ptz) parts.push('PTZ'); + return parts.join(' · '); +} diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs new file mode 100644 index 000000000..4d6f80370 --- /dev/null +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -0,0 +1,63 @@ +// src/layers/cctv/modelSpecs.test.mjs +// Hardware-model spec enrichment: lookup semantics (case-insensitive, alias +// matching, unknown → null — never a guess), datasheet-FOV parsing across the +// real fov_deg string shapes, and the HUD spec-summary line. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + lookupModelSpec, + horizontalFovDeg, + specSummary, +} from './modelSpecs.js'; + +test('lookupModelSpec matches case-insensitively and trims', () => { + const spec = lookupModelSpec(' q6135-le '); + assert.ok(spec); + assert.equal(spec.brand, 'Axis'); + assert.equal(lookupModelSpec('Q6135-LE'), spec); +}); + +test('lookupModelSpec matches aliases', () => { + const byAlias = lookupModelSpec('01959-004'); + assert.ok(byAlias); + assert.equal(byAlias.model, 'Q6135-LE'); +}); + +test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { + assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); + assert.equal(lookupModelSpec(''), null); + assert.equal(lookupModelSpec(null), null); + assert.equal(lookupModelSpec(undefined), null); +}); + +test('horizontalFovDeg takes the wide end of a varifocal/PTZ range', () => { + // '58.3-2.4 horizontal' → 58.3 (wide), not 2.4 (tele). + assert.equal(horizontalFovDeg(lookupModelSpec('Q6135-LE')), 58.3); +}); + +test('horizontalFovDeg reads multi-variant and plain fov strings', () => { + // '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)' → first variant. + assert.equal(horizontalFovDeg(lookupModelSpec('DS-2CD2087G3-LI2UY')), 108.8); + // '110 horizontal, 60 vertical' → horizontal figure. + assert.equal(horizontalFovDeg(lookupModelSpec('F4105-LRE')), 110); +}); + +test('horizontalFovDeg returns null when no numeric FOV is stated', () => { + assert.equal(horizontalFovDeg({ fov_deg: '' }), null); + assert.equal(horizontalFovDeg({ fov_deg: 'wide dynamic range' }), null); + assert.equal(horizontalFovDeg({}), null); + assert.equal(horizontalFovDeg(null), null); +}); + +test('specSummary renders the HUD line and degrades by omission', () => { + assert.equal( + specSummary(lookupModelSpec('Q6135-LE')), + 'Axis Q6135-LE · 1080p · IR 250m · PTZ', + ); + // No night vision / no PTZ → those segments are simply absent. + assert.equal( + specSummary({ brand: 'Axis', model: 'X', resolution: { label: '4K' } }), + 'Axis X · 4K', + ); + assert.equal(specSummary(null), ''); +}); diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index f8f83fcd1..9fa4a7cef 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,6 +1,7 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js'; import { headingHudToken, isHeadingEstimated } from './headingConfidence.js'; +import { specSummary } from './modelSpecs.js'; export function createPresentation({ state: layerState, @@ -39,7 +40,8 @@ export function createPresentation({ // A synthetic bearing (headingConfidence 'low', no human calibration) // is tagged so a hashed guess never reads as a surveyed facing (#639). headingHudToken(active.camera), - `FOV ${Math.round(active.camera.fovDeg)}°`, + // Identified hardware labels its FOV as datasheet-sourced (catalog.js). + `FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`, `COVERAGE ${area.toFixed(2)}km²`, overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', `PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`, @@ -49,6 +51,10 @@ export function createPresentation({ ? `SRC ${String(health.sourceKind).toUpperCase()}` : `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`, `${viewBand.toUpperCase()} CONTEXT`, + // Identified hardware: append the model's datasheet specs + attribution. + active.camera.spec + ? `${specSummary(active.camera.spec).toUpperCase()} · SPECS: CCTV-DATABASE.COM` + : null, ] .filter(Boolean) .join(' · '); @@ -86,6 +92,9 @@ export function createPresentation({ headingEstimated: isHeadingEstimated(camera), pitchDeg: camera.pitchDeg, fovDeg: camera.fovDeg, + // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). + fovSource: camera.fovSource || 'estimated', + spec: camera.spec || null, rangeM: camera.rangeM, elevationM: camera.absoluteHeightM, mountHeightM: camera.mountHeightM, From 97118f478100ca4cebdba60b8eaf7f56f4e4de4e Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 11:36:50 +0200 Subject: [PATCH 03/24] feat(cctv): vendor the model specs the integrated catalogs publish Grow the enrichment table from the 4-model starter to the hardware the currently integrated public catalogs actually publish, so identified cameras render datasheet FOV cones and spec cards instead of estimates: - King County (WA) road cameras: Cohu 3950 / 3960 (3965) / 3920 iDome (3925), Bosch AUTODOME IP 5000i. - Sarasota County (FL) + Sioux Falls (SD) open data: Bosch AUTODOME IP starlight 7000i / starlight 7000 HD / dynamic 7000 HD, the VG4 AUTODOME (500i), Bosch AUTODOME 800 (VG5-836), and Axis Q6155-E, Q6054-E, Q6000-E Mk II, and P3707-PE. All records are vendored verbatim from the CCTV Camera Database (CC0), each transcribed from a manufacturer datasheet. Analog Cohu positioners carry no fov_deg (their datasheets publish none), so they enrich the HUD specs without a datasheet cone; every other model carries a real FOV. Adds a test asserting the vendored table covers each integrated catalog's published hardware strings. --- src/layers/cctv/modelSpecs.js | 221 +++++++++++++++++++++++++++- src/layers/cctv/modelSpecs.test.mjs | 13 ++ 2 files changed, 232 insertions(+), 2 deletions(-) diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index e9bb48856..463e750ad 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -13,8 +13,11 @@ * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset * of 12,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV). This module ships a small starter table; city packs that publish - * their hardware models can grow it, or vendor the full extract. + * FOV). This module vendors the records that the currently integrated public + * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis + * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that + * appear in Sarasota County FL and Sioux Falls SD open data; packs that + * publish other hardware models can grow it, or vendor the full extract. * * Scope guardrail: records describe camera PRODUCTS (optics, resolution, * night vision) — never endpoints, stream paths, or access/config data. @@ -78,6 +81,220 @@ const RECORDS = [ environment: ['outdoor'], ndaa_compliant: true, }, + { + id: 'axis-p3707-pe', + brand: 'Axis', + model: 'P3707-PE', + type: 'panoramic', + url: 'https://www.cctv-database.com/camera/axis-p3707-pe/', + aliases: ['AXIS P3707-PE', 'P3707-PE'], + fov_deg: '108-54 horizontal per sensor (4x 1080p)', + resolution: { + mp: 8, + w: 1920, + h: 1080, + label: '4x 2MP (8MP total; 4x 1080p)', + }, + lens_count: 4, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'axis-q6000-e-mk-ii', + brand: 'Axis', + model: 'Q6000-E Mk II', + type: 'panoramic', + url: 'https://www.cctv-database.com/camera/axis-q6000-e-mk-ii/', + aliases: [ + 'AXIS Q6000-E Mk II', + 'Q6000-E Mk II 50 Hz', + 'Q6000-E Mk II 60 Hz', + ], + fov_deg: 'up to 360 combined; 113-152 horizontal per sensor', + resolution: { + mp: 8, + w: 1920, + h: 1080, + label: '4x 2MP (8MP total; 4x 1080p)', + }, + lens_count: 4, + environment: ['outdoor'], + }, + { + id: 'axis-q6054-e', + brand: 'Axis', + model: 'Q6054-E', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6054-e/', + aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'], + fov_deg: '62.9-2.2 horizontal', + resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'axis-q6155-e', + brand: 'Axis', + model: 'Q6155-E', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6155-e/', + aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'], + fov_deg: '66.7-2.36 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-500i', + brand: 'Bosch', + model: 'AutoDome 500i Series (VG4)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-500i/', + aliases: [ + 'VG4 AUTODOME', + 'VG4 AUTODOME H.264', + 'AutoDome 500i', + 'AutoDome 500i Series', + 'VG4-500i', + 'VG4-500', + ], + fov_deg: '57.8-1.7 horizontal', + resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-ip-dynamic-7000-hd', + brand: 'Bosch', + model: 'AUTODOME IP dynamic 7000 HD', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/', + aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'], + fov_deg: '65-2.3 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-ip-starlight-7000-hd', + brand: 'Bosch', + model: 'AUTODOME IP starlight 7000 HD', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-starlight-7000-hd/', + aliases: [ + 'AUTODOME IP starlight 7000 HD', + 'Starlight 7000HD', + 'VG5-7130-CPT4', + 'VG5-7130', + ], + fov_deg: '59-2.1 horizontal', + resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-ndp-5502-30', + brand: 'Bosch', + model: 'AUTODOME IP 5000i (NDP-5502-Z30)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-ndp-5502-30/', + aliases: [ + 'AUTODOME IP 5000i', + 'NDP-5502-Z30', + 'NDP-5502-Z30C', + 'NDP-5502-30', + ], + fov_deg: '60.9-2.4 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-ndp-7512-z30', + brand: 'Bosch', + model: 'AUTODOME IP starlight 7000i (NDP-7512-Z30)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-ndp-7512-z30/', + aliases: [ + 'AUTODOME IP starlight 7000i', + 'NDP-7512-Z30', + 'NDP-7512-Z30C', + 'NDP-7512-Z30K', + ], + fov_deg: '64.7-2.3 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-vg5-836-ecev', + brand: 'Bosch', + model: 'AUTODOME 800 Series HD PTZ (VG5-836-ECEV)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-vg5-836-ecev/', + aliases: ['VG5-836-ECEV', 'VG5-836', 'AUTODOME 800 Series'], + resolution: { mp: 2, label: '1080p' }, + ptz: true, + autotracking: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3920-idome', + brand: 'Cohu', + model: '3920 iDome', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3920-idome/', + aliases: [ + '3920', + '3920 Series', + '3923', + '3925', + '3925 Dome', + 'iDome 3925', + 'Cohu iDome 3920 Series', + ], + resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3950-iview', + brand: 'Cohu', + model: '3950 iVIEW', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3950-iview/', + aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'], + resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3960', + brand: 'Cohu', + model: '3960', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3960/', + aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'], + resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, ]; const BY_MODEL = new Map(); diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 4d6f80370..b31f2d714 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -23,6 +23,19 @@ test('lookupModelSpec matches aliases', () => { assert.equal(byAlias.model, 'Q6135-LE'); }); +test('vendored table covers the integrated catalogs published hardware', () => { + // Models the live packs publish: King County (Cohu, AUTODOME IP 5000i), + // Sarasota (Bosch AUTODOME 7000/VG4), Sioux Falls (Axis). + const cohu = lookupModelSpec('3950'); // King County publishes "3950" + assert.equal(cohu?.brand, 'Cohu'); + const autodome = lookupModelSpec('AUTODOME IP 5000i'); + assert.ok(autodome && horizontalFovDeg(autodome) > 0); + const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string + assert.ok(vg4); + const axis = lookupModelSpec('Q6155-E'); // Sioux Falls + assert.equal(horizontalFovDeg(axis), 66.7); +}); + test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); assert.equal(lookupModelSpec(''), null); From 0533f78be433f28fe9085d856caf000630691670 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 11:56:15 +0200 Subject: [PATCH 04/24] feat(cctv): resolve verbose registry model strings via token fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some catalogs publish a full product title rather than the bare model, e.g. "AXIS Q6155-E PTZ Dome Network Camera" — which the exact model/alias match misses. Add a fallback: after the exact match, scan for a record whose DISTINCTIVE token (a >=4-char model/alias word containing a digit or hyphen, so "dome"/"network"/"ptz"/"hd" never qualify) appears as a whole word in the query. The most specific (longest) matched token wins; if two records tie on the longest matched token the result is ambiguous and returns null, so the lookup never guesses. Exact matches are unchanged and still take precedence. --- scripts/package-boundaries.json | 3 ++ src/layers/cctv/modelSpecs.js | 69 +++++++++++++++++++++++++++-- src/layers/cctv/modelSpecs.test.mjs | 37 ++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..a987623a4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1079,6 +1079,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -1496,6 +1497,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -2136,6 +2138,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 463e750ad..1252ce013 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -297,21 +297,82 @@ const RECORDS = [ }, ]; +/** Lowercase + collapse whitespace, the shared normal form for matching. */ +function normalize(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' '); +} + const BY_MODEL = new Map(); for (const record of RECORDS) { - BY_MODEL.set(record.model.toLowerCase(), record); + BY_MODEL.set(normalize(record.model), record); for (const alias of record.aliases || []) - BY_MODEL.set(alias.toLowerCase(), record); + BY_MODEL.set(normalize(alias), record); } /** - * Looks up a spec record by model string (case-insensitive; aliases match too). + * DISTINCTIVE tokens of a record — the whitespace words of its model and + * aliases that identify hardware rather than describe it. A token qualifies + * only when it is >= 4 chars AND contains a digit or hyphen, so generic words + * ("dome", "network", "camera", "series", "ptz") are never distinctive and a + * verbose registry string cannot match on them alone. Surrounding punctuation + * is trimmed so "(NDP-7512-Z30)" indexes as "ndp-7512-z30". + * @param {Object} record + * @returns {string[]} Unique distinctive tokens, lowercased. + */ +function distinctiveTokens(record) { + const out = new Set(); + for (const name of [record.model, ...(record.aliases || [])]) { + for (const raw of normalize(name).split(' ')) { + const token = raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''); + if (token.length >= 4 && /[0-9-]/.test(token)) out.add(token); + } + } + return [...out]; +} + +/** + * Token index for the substring fallback: one precompiled whole-word matcher + * per distinctive token. Word boundaries are any non-alphanumeric char (or the + * string ends), so hyphenated/dotted tokens like "q6155-e" or "h.264" match as + * whole words while "700" never matches inside "7000". + */ +const TOKEN_INDEX = RECORDS.flatMap((record) => + distinctiveTokens(record).map((token) => ({ + token, + record, + re: new RegExp( + `(?:^|[^a-z0-9])${token.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}(?:[^a-z0-9]|$)`, + ), + })), +); + +/** + * Looks up a spec record for a hardware-model string. + * + * Exact match first (case-insensitive, whitespace-collapsed) on the model and + * every alias. When that misses, a token fallback lets a VERBOSE registry + * string resolve — e.g. "AXIS Q6155-E PTZ Dome Network Camera" finds the + * Q6155-E via its distinctive "q6155-e" token. The most specific (longest) + * matched token wins; if two different records tie on the longest matched + * token the result is ambiguous and returns null (never guesses). * @param {string} model - Hardware model as named by a source pack or seed. * @returns {Object|null} The verbatim spec record, or null when unknown. */ export function lookupModelSpec(model) { if (!model) return null; - return BY_MODEL.get(String(model).trim().toLowerCase()) || null; + const query = normalize(model); + if (!query) return null; + const exact = BY_MODEL.get(query); + if (exact) return exact; + const matches = TOKEN_INDEX.filter((entry) => entry.re.test(query)); + if (!matches.length) return null; + const maxLen = Math.max(...matches.map((m) => m.token.length)); + const top = matches.filter((m) => m.token.length === maxLen); + const records = new Set(top.map((m) => m.record)); + return records.size === 1 ? top[0].record : null; } /** diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index b31f2d714..c504e14ad 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -36,6 +36,43 @@ test('vendored table covers the integrated catalogs published hardware', () => { assert.equal(horizontalFovDeg(axis), 66.7); }); +test('verbose registry strings resolve via distinctive-token fallback', () => { + // Sioux Falls publishes the full product title, not the bare model. + assert.equal( + lookupModelSpec('AXIS Q6155-E PTZ Dome Network Camera')?.model, + 'Q6155-E', + ); + assert.equal(lookupModelSpec('AXIS Q6000-E Mk II')?.model, 'Q6000-E Mk II'); + assert.equal( + lookupModelSpec('AUTODOME IP starlight 7000i camera')?.id, + 'bosch-ndp-7512-z30', + ); + // Punctuation around the model token doesn't defeat the match. + assert.equal( + lookupModelSpec('camera (P3707-PE), outdoor')?.model, + 'P3707-PE', + ); +}); + +test('token fallback never matches on generic words or wrong models', () => { + // Only generic descriptors — no distinctive hardware token — stays null. + assert.equal(lookupModelSpec('PTZ Dome Camera'), null); + assert.equal(lookupModelSpec('HD Network Camera'), null); + assert.equal(lookupModelSpec('Outdoor Dome Camera'), null); + // A real-looking but unknown model resolves to nothing, not a near neighbour. + assert.equal(lookupModelSpec('AXIS FooBar-9 Network Camera'), null); + // A generation token alone must not collide with a similar one: "7000" + // belongs to two records (dynamic / starlight 7000 HD), so a bare + // "AUTODOME 7000" is ambiguous → null rather than an arbitrary pick. + assert.equal(lookupModelSpec('AUTODOME 7000'), null); +}); + +test('exact match still wins over the token fallback', () => { + // "3965" is an alias of the 3960; exact alias match, not a token scan. + assert.equal(lookupModelSpec('3965')?.id, 'cohu-3960'); + assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30'); +}); + test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); assert.equal(lookupModelSpec(''), null); From ffa1891b01b6d39def2d2854560d6e927d4c9cfb Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:09:27 +0000 Subject: [PATCH 05/24] fix(cctv): keep estimated FOV for PTZ/varifocal hardware; carry datasheet range as capability A PTZ/varifocal datasheet FOV is a capability range, not the lens's current zoom, so replacing the frustum's estimate with its wide end presented unknown optical state as known. Parse the full horizontal range (ignoring vertical, "combined" panoramic and lens-note figures), carry it as specFovMinDeg/specFovMaxDeg, and substitute the datasheet FOV only for fixed single-lens hardware. The HUD labels the range as capability beside the estimate. This also stops the Q6000-E Mk II's "360 combined" figure from being read as a 360-degree lens. --- src/layers/cctv/catalog.js | 25 ++++-- src/layers/cctv/modelSpecs.js | 64 +++++++++++--- src/layers/cctv/modelSpecs.test.mjs | 124 ++++++++++++++++++++++++---- src/layers/cctv/presentation.js | 7 +- 4 files changed, 186 insertions(+), 34 deletions(-) diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index be6dc7e90..499e715a0 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,11 +1,17 @@ import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; -import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js'; +import { + lookupModelSpec, + horizontalFovRangeDeg, + fixedHorizontalFovDeg, +} from './modelSpecs.js'; /** * Applies datasheet enrichment to a camera in place: when it names a known - * hardware `model`, the manufacturer's horizontal FOV replaces the pose - * estimate and the spec record is attached for the HUD. Unknown models — the - * common case — change nothing. + * hardware `model`, the spec record is attached for the HUD and the + * datasheet's horizontal FOV range rides along as `specFovMinDeg`/ + * `specFovMaxDeg`. Only fixed-lens hardware replaces the pose estimate: a + * PTZ/varifocal range says what the lens CAN do, not where it is zoomed now. + * Unknown models — the common case — change nothing. * @param {Object} camera - Camera being built (mutated). * @param {string} [modelName] - Hardware model from a source or seed. * @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper. @@ -13,9 +19,14 @@ import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js'; function applyModelEnrichment(camera, modelName, clamp) { const spec = modelName ? lookupModelSpec(modelName) : null; if (!spec) return; - const fov = horizontalFovDeg(spec); - if (fov) { - camera.fovDeg = clamp(fov, 20, 125); + const range = horizontalFovRangeDeg(spec); + if (range) { + camera.specFovMinDeg = range.minDeg; + camera.specFovMaxDeg = range.maxDeg; + } + const fixedFov = fixedHorizontalFovDeg(spec); + if (fixedFov) { + camera.fovDeg = clamp(fixedFov, 20, 125); camera.fovSource = 'datasheet'; } camera.spec = spec; diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 1252ce013..a9d12e23d 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -5,8 +5,10 @@ * * When a catalog camera names its hardware model (source packs may set * `model`; hand-authored seeds may too), the model resolves to a datasheet - * record: the camera's frustum then uses the manufacturer's horizontal FOV - * instead of an estimate, and the HUD meta line gains the model's real specs. + * record and the HUD meta line gains the model's real specs. A fixed-lens + * model's horizontal FOV replaces the frustum's estimate; a PTZ/varifocal or + * multi-sensor range is carried as capability metadata beside the estimate, + * because the lens's current zoom is unknown. * Cameras without an identified model are untouched — unknown never implies * anything. * @@ -376,16 +378,58 @@ export function lookupModelSpec(model) { } /** - * Datasheet horizontal FOV in degrees: the FIRST number in the `fov_deg` - * string, which for varifocal/PTZ ranges ('58.3-2.4 horizontal') is the wide - * end — the honest default for a coverage cone. + * Datasheet horizontal FOV as the range the hardware can cover. Only + * segments stating a horizontal figure count — vertical and "combined" + * panoramic figures never do — and parenthesized lens/sensor notes are + * ignored. A zoom ('58.3-2.4 horizontal') and a lens-option list + * ('108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)') both yield a range. * @param {Object} spec - A record from this module. - * @returns {number|null} Wide-end horizontal FOV, or null when unstated. + * @returns {{minDeg:number, maxDeg:number}|null} Null when unstated. */ -export function horizontalFovDeg(spec) { - const match = String(spec?.fov_deg || '').match(/\d+(?:\.\d+)?/); - const value = match ? Number(match[0]) : NaN; - return Number.isFinite(value) && value > 0 ? value : null; +export function horizontalFovRangeDeg(spec) { + const values = []; + for (const segment of String(spec?.fov_deg || '').split(/[/;,]/)) { + if (!/horizontal/i.test(segment)) continue; + const figures = segment.replace(/\([^)]*\)/g, '').split(/horizontal/i)[0]; + for (const match of figures.matchAll(/\d+(?:\.\d+)?/g)) { + const value = Number(match[0]); + if (Number.isFinite(value) && value > 0) values.push(value); + } + } + if (!values.length) return null; + return { minDeg: Math.min(...values), maxDeg: Math.max(...values) }; +} + +/** + * The datasheet horizontal FOV as the camera's CURRENT optical state, which + * holds only for fixed single-lens hardware stating one figure. PTZ, + * varifocal and multi-sensor models publish a capability range whose in-use + * value is unknown — known hardware capability is not known pose state — so + * they return null and keep their estimated FOV. + * @param {Object} spec - A record from this module. + * @returns {number|null} Fixed horizontal FOV in degrees, or null. + */ +export function fixedHorizontalFovDeg(spec) { + if (!spec || spec.ptz || spec.varifocal || (spec.lens_count || 1) > 1) + return null; + const range = horizontalFovRangeDeg(spec); + return range && range.minDeg === range.maxDeg ? range.minDeg : null; +} + +/** + * HUD token for a datasheet FOV that describes capability rather than the + * camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'. + * @param {Object} camera - Catalog camera (see applyModelEnrichment). + * @returns {string|null} Null when the datasheet FOV already IS the camera's + * FOV (fixed lens) or no datasheet range is known. + */ +export function specFovCapabilityToken(camera) { + if (!camera || camera.fovSource === 'datasheet') return null; + const { specFovMinDeg: min, specFovMaxDeg: max } = camera; + if (!Number.isFinite(min) || !Number.isFinite(max)) return null; + const fmt = (deg) => (deg < 10 ? deg.toFixed(1) : String(Math.round(deg))); + const span = min === max ? fmt(max) : `${fmt(min)}–${fmt(max)}`; + return `SPEC FOV ${span}° (CAPABILITY)`; } /** diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index c504e14ad..06ffe3a64 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -6,9 +6,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { lookupModelSpec, - horizontalFovDeg, + horizontalFovRangeDeg, + fixedHorizontalFovDeg, + specFovCapabilityToken, specSummary, } from './modelSpecs.js'; +import { createCatalog } from './catalog.js'; test('lookupModelSpec matches case-insensitively and trims', () => { const spec = lookupModelSpec(' q6135-le '); @@ -29,11 +32,11 @@ test('vendored table covers the integrated catalogs published hardware', () => { const cohu = lookupModelSpec('3950'); // King County publishes "3950" assert.equal(cohu?.brand, 'Cohu'); const autodome = lookupModelSpec('AUTODOME IP 5000i'); - assert.ok(autodome && horizontalFovDeg(autodome) > 0); + assert.ok(autodome && horizontalFovRangeDeg(autodome).maxDeg > 0); const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string assert.ok(vg4); const axis = lookupModelSpec('Q6155-E'); // Sioux Falls - assert.equal(horizontalFovDeg(axis), 66.7); + assert.deepEqual(horizontalFovRangeDeg(axis), { minDeg: 2.36, maxDeg: 66.7 }); }); test('verbose registry strings resolve via distinctive-token fallback', () => { @@ -80,23 +83,112 @@ test('lookupModelSpec returns null for unknown or empty input — never guesses' assert.equal(lookupModelSpec(undefined), null); }); -test('horizontalFovDeg takes the wide end of a varifocal/PTZ range', () => { - // '58.3-2.4 horizontal' → 58.3 (wide), not 2.4 (tele). - assert.equal(horizontalFovDeg(lookupModelSpec('Q6135-LE')), 58.3); +test('horizontalFovRangeDeg reads the full range across real fov_deg shapes', () => { + const range = (model) => horizontalFovRangeDeg(lookupModelSpec(model)); + // PTZ zoom: both ends, not just the wide one. + assert.deepEqual(range('Q6135-LE'), { minDeg: 2.4, maxDeg: 58.3 }); + // Vertical figures never count. + assert.deepEqual(range('XNP-6400RW'), { minDeg: 1.88, maxDeg: 65.66 }); + // Lens options: parenthesized focal lengths are not FOV figures. + assert.deepEqual(range('DS-2CD2087G3-LI2UY'), { + minDeg: 93.3, + maxDeg: 108.8, + }); + assert.deepEqual(range('F4105-LRE'), { minDeg: 110, maxDeg: 110 }); + // The "360 combined" panoramic figure is not a horizontal lens FOV. + assert.deepEqual(range('Q6000-E Mk II'), { minDeg: 113, maxDeg: 152 }); + assert.deepEqual(range('P3707-PE'), { minDeg: 54, maxDeg: 108 }); }); -test('horizontalFovDeg reads multi-variant and plain fov strings', () => { - // '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)' → first variant. - assert.equal(horizontalFovDeg(lookupModelSpec('DS-2CD2087G3-LI2UY')), 108.8); - // '110 horizontal, 60 vertical' → horizontal figure. - assert.equal(horizontalFovDeg(lookupModelSpec('F4105-LRE')), 110); +test('horizontalFovRangeDeg returns null when no numeric FOV is stated', () => { + assert.equal(horizontalFovRangeDeg({ fov_deg: '' }), null); + assert.equal(horizontalFovRangeDeg({ fov_deg: 'wide dynamic range' }), null); + assert.equal(horizontalFovRangeDeg({ fov_deg: '60 vertical' }), null); + assert.equal(horizontalFovRangeDeg({}), null); + assert.equal(horizontalFovRangeDeg(null), null); }); -test('horizontalFovDeg returns null when no numeric FOV is stated', () => { - assert.equal(horizontalFovDeg({ fov_deg: '' }), null); - assert.equal(horizontalFovDeg({ fov_deg: 'wide dynamic range' }), null); - assert.equal(horizontalFovDeg({}), null); - assert.equal(horizontalFovDeg(null), null); +test('fixedHorizontalFovDeg is the current FOV only for fixed single-lens hardware', () => { + // Fixed dome, one horizontal figure: the datasheet IS the optical state. + assert.equal(fixedHorizontalFovDeg(lookupModelSpec('F4105-LRE')), 110); + // Known hardware capability != known pose state: PTZ, multi-sensor, and a + // model sold in several lens options all stay estimated. + for (const model of [ + 'Q6135-LE', + 'XNP-6400RW', + 'AUTODOME IP 5000i', + 'P3707-PE', + 'Q6000-E Mk II', + 'DS-2CD2087G3-LI2UY', + ]) { + assert.equal(fixedHorizontalFovDeg(lookupModelSpec(model)), null, model); + } + assert.equal(fixedHorizontalFovDeg(null), null); +}); + +test('specFovCapabilityToken labels a datasheet range as capability, never as current FOV', () => { + assert.equal( + specFovCapabilityToken({ specFovMinDeg: 2.4, specFovMaxDeg: 58.3 }), + 'SPEC FOV 2.4–58° (CAPABILITY)', + ); + assert.equal( + specFovCapabilityToken({ specFovMinDeg: 113, specFovMaxDeg: 113 }), + 'SPEC FOV 113° (CAPABILITY)', + ); + // Already the camera's FOV (fixed lens) or nothing known: no token. + assert.equal( + specFovCapabilityToken({ + fovSource: 'datasheet', + specFovMinDeg: 110, + specFovMaxDeg: 110, + }), + null, + ); + assert.equal(specFovCapabilityToken({}), null); + assert.equal(specFovCapabilityToken(null), null); +}); + +/** Browser catalog with the pose math stubbed: only enrichment is under test. */ +function browserCatalog() { + const model = { + safeNumber: (value, fallback = NaN) => + Number.isFinite(Number(value)) ? Number(value) : fallback, + normalizeHeading: (deg) => ((deg % 360) + 360) % 360, + clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)), + headingFromId: () => 0, + normalizeFeedType: (type) => type, + ensureCameraPose: () => {}, + }; + return createCatalog({ + state: {}, + services: { locations: { CITY_POIS: {} } }, + parts: { model }, + source: {}, + }); +} + +test('catalog keeps the estimated FOV for PTZ hardware and substitutes only fixed-lens datasheets', () => { + const [ptz, fixed, unknown] = browserCatalog().buildCatalogFromSources([ + { id: 'ptz', lat: 47.7, lon: -122, fovDeg: 44, model: 'Q6135-LE' }, + { id: 'fixed', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' }, + { id: 'unknown', lat: 47.7, lon: -122, fovDeg: 44, model: 'NOPE-1' }, + ]); + + // PTZ: the frustum keeps its estimate; the datasheet range is metadata. + assert.equal(ptz.fovDeg, 44); + assert.equal(ptz.fovSource, undefined); + assert.equal(ptz.specFovMinDeg, 2.4); + assert.equal(ptz.specFovMaxDeg, 58.3); + assert.equal(ptz.spec.model, 'Q6135-LE'); + + // Fixed lens: the datasheet figure is the optical state. + assert.equal(fixed.fovDeg, 110); + assert.equal(fixed.fovSource, 'datasheet'); + + // Unknown hardware changes nothing. + assert.equal(unknown.fovDeg, 44); + assert.equal(unknown.spec, undefined); + assert.equal(unknown.specFovMaxDeg, undefined); }); test('specSummary renders the HUD line and degrades by omission', () => { diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index 9fa4a7cef..efa18e462 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,7 +1,7 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js'; import { headingHudToken, isHeadingEstimated } from './headingConfidence.js'; -import { specSummary } from './modelSpecs.js'; +import { specSummary, specFovCapabilityToken } from './modelSpecs.js'; export function createPresentation({ state: layerState, @@ -42,6 +42,9 @@ export function createPresentation({ headingHudToken(active.camera), // Identified hardware labels its FOV as datasheet-sourced (catalog.js). `FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`, + // A PTZ/varifocal datasheet range is what the lens can do, not its + // current zoom, so it is shown beside the estimate, never as it. + specFovCapabilityToken(active.camera), `COVERAGE ${area.toFixed(2)}km²`, overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', `PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`, @@ -94,6 +97,8 @@ export function createPresentation({ fovDeg: camera.fovDeg, // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). fovSource: camera.fovSource || 'estimated', + specFovMinDeg: camera.specFovMinDeg ?? null, + specFovMaxDeg: camera.specFovMaxDeg ?? null, spec: camera.spec || null, rangeM: camera.rangeM, elevationM: camera.absoluteHeightM, From cce159c93d6c7023e65589c1024bf83de45a1499 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:24:13 +0000 Subject: [PATCH 06/24] docs(cctv): document identified-hardware specs and datasheet FOV in CHANGELOG and CURRENT-STATE CONTRIBUTING requires runtime-behavior changes to update docs/CURRENT-STATE.md and CHANGELOG.md in the same PR. --- CHANGELOG.md | 9 +++++++++ docs/CURRENT-STATE.md | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..21a8cb743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- CCTV cameras with identified hardware show the model's datasheet specs. When a + source names a camera model found in the vendored CCTV Camera Database records + (CC0), the HUD meta line adds its resolution, night vision and PTZ capability. + A fixed single-lens model's horizontal FOV replaces the estimate + (`FOV n° (DATASHEET)`); a PTZ or varifocal range is what the lens can do, not + its current zoom, so those cameras keep the estimate and show + `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra, #452). + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD @@ -235,6 +243,7 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). + - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..5e1ccd447 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3390,6 +3390,13 @@ silently demoting every later lookup for the session. (`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as estimated, matching the CAL badge. Public camera state carries `headingConfidence` and `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). +- **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that + resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera + Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens + hardware replaces the estimated FOV, shown as `FOV n° (DATASHEET)`. PTZ, varifocal and + multi-sensor datasheets publish a capability range, not the lens's current zoom, so those + cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public + camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). - **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. - **CCTV v2 foundation:** a pitched frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's From 03929f1a009e1a5a590dd790bcb2e38c4725adae Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 1 Oct 2026 12:34:07 +0200 Subject: [PATCH 07/24] docs(cctv): refresh the CCTV Camera Database size in the model-specs module The dataset has grown from 12,000+ to 28,000+ camera models (v2.26.0, 75% with a field of view). The 17 vendored records are unchanged in v2.26.0. --- src/layers/cctv/modelSpecs.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index a9d12e23d..cee3e5044 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -14,8 +14,8 @@ * * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset - * of 12,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV). This module vendors the records that the currently integrated public + * of 28,000+ camera models transcribed from manufacturer datasheets (75% with + * FOV, as of v2.26.0). This module vendors the records that the currently integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that * appear in Sarasota County FL and Sioux Falls SD open data; packs that From bdd64660e7d9d0de31ce58dc8450a09e7a632214 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 18:17:09 -0400 Subject: [PATCH 08/24] feat: ship a web app manifest and home-screen icons Adding the app to a phone home screen produced a blank tile: index.html linked only an SVG favicon, and Safari ignores SVG for apple-touch-icon, substituting a screenshot of the page. There was no manifest at all, so Android had nothing to install against either. Generate the rasters from public/logo.svg with sharp, which the repo already carries. The output is committed so a plain `vite build` needs no image toolchain; re-run scripts/generate-icons.mjs after changing the logo. The logo's viewBox is wider than it is tall, so each icon centres the mark on a square #0a0a0f canvas rather than stretching it, inset to 78% normally and 54% for the maskable variant to survive Android's circular crop. The SVG favicon stays as the browser tab icon. --- CHANGELOG.md | 6 +++ docs/CURRENT-STATE.md | 16 +++++++ index.html | 10 +++++ public/icons/icon-180.png | Bin 0 -> 12055 bytes public/icons/icon-192.png | Bin 0 -> 12993 bytes public/icons/icon-32.png | Bin 0 -> 1160 bytes public/icons/icon-512-maskable.png | Bin 0 -> 28226 bytes public/icons/icon-512.png | Bin 0 -> 40995 bytes public/manifest.webmanifest | 21 +++++++++ scripts/format-scope.json | 4 +- scripts/generate-icons.mjs | 70 +++++++++++++++++++++++++++++ src/tooling/webAppIcons.test.mjs | 70 +++++++++++++++++++++++++++++ 12 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 public/icons/icon-180.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-32.png create mode 100644 public/icons/icon-512-maskable.png create mode 100644 public/icons/icon-512.png create mode 100644 public/manifest.webmanifest create mode 100644 scripts/generate-icons.mjs create mode 100644 src/tooling/webAppIcons.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 032c6fe2a..eb61ad1db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +- Ship a web app manifest and raster home-screen icons, so adding the app to a + phone home screen or installing it on desktop uses the God's Eye View mark + instead of a blank tile. The icons are generated from `public/logo.svg` by + `node scripts/generate-icons.mjs` and committed, so a plain `vite build` needs + no image toolchain. The existing SVG favicon still serves the browser tab. + - GBFS v3 station names use English translations when available, then the first usable translation or short name. Empty or malformed names no longer appear as `[object Object]` or interrupt station loading; legacy string names remain diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0d3ef31e0..f8e6bff3a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1131,6 +1131,22 @@ services, local voice and standalone key setup. Importing them starts no network acquisition. Browser layer lifecycle, rendering and voice execution stay in their existing modules. +## Web app install icons + +`public/manifest.webmanifest` declares the installable app: standalone display, +`#0a0a0f` background and theme, and 192/512 icons plus a 512 maskable variant. +`index.html` links the manifest, a 180px `apple-touch-icon` and a 32px PNG +favicon; the existing SVG favicon still serves the browser tab. Safari ignores an +SVG apple-touch-icon and substitutes a page screenshot, so the raster is what +makes an installed icon appear at all. + +`node scripts/generate-icons.mjs` renders every raster from `public/logo.svg` and +writes `public/icons/`. Output is committed, so building needs no image +toolchain; re-run the script after changing the logo. Ordinary icons inset the +mark to 78% of the canvas and the maskable variant to 54%, keeping it inside the +circle Android may crop to. `src/tooling/webAppIcons.test.mjs` checks the links, +the manifest fields and that each committed PNG is square at its declared size. + ## Local build preview After `npm run build`, `npm run preview` serves the built app with the same data diff --git a/index.html b/index.html index 2e630c4a8..31db11b06 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,16 @@ God's Eye View + + + + + + + + diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..5655a9e705643b9a36e8d27666014bc940e3d94f GIT binary patch literal 12055 zcmcJVRZtyF)8`NF8rsDPN z%t5j;ND)?ST@sYFPFzy!8!5!v@E~ZsWRKRR##QatBtA~iy(lEjHzaiCE(*>{Pw7qnR)+3aUh{nldt6vBXg4g- zZr|dHh*@h@6{UU5mD+2H*evtwZlJ*&ZC4s5eznP*3HuwyQqTdT*C8fw=?8m=D38w> zhUsl2$7T?!Hhh;e-=!|x7&nQqN`Z-3A|dS<$xI}T&{h^xK`i8=2WKw`k~Cm#LNTg=C?5wmf{N|oJ943+ zUzC&L)IR7o3)US*70UJlP-!B0GL5^sTlaHujUP{-z#vn20)XzSa|e) z+N>?)pob1DguGj^W&YBxdKkeNLx@Jo71E3F$py9*sh$3A*vf;)NP)Z^W3t^KPhS+D zqBX}A`zBoGjNMb+JWoU6P z-$|f~(^1NMYtKLgTgH(GjCH(_co=h;2E$^CTtG816o#wGFkzM#tvB5kaAD=>(LfH1 zoB(?*BLDOdaC<>Mt3Uy!L zFMj0`0MG$0y3gzEe|9YKcX8 z^B^(PGM)M=j-nshiL!pYvy z{B7L$Z8Qv_#cf!d_+l`)ezp6fef=2?VXN8w-NG+Ut5}ZC?MzF01D7?QJd?rtrpfZ? zAkJf^=+duXEZ0AO>@&fl`z@8h|1JGO@QqKSOc*mjX>|*TX8@!HJ!f&2Q-?ses$Er z-pd=VQS(yu@r3)qt0Eq?Hpy*LoS^>Rxh)yOCZH+O{n6hqZ;-W9Ttc%l@7LeW z@5Qm5BC+53A%*c3{+gV}FV3;m2)L0BR)s%!;4paweNO$RxGe=3Y^)=7>F8)}6+^i6 zQeycZ^IFQGRE(WvQULm!jOhqC9UXn5Gu$NTY_$1Me7DJU-v@cR2?vDFn?Lg-wYET; zQ4A$gk}{+UikOA9Mog_Y#+tTolkhJ4negjAi6c{p;Dz23PAjGCw9&sHsM!fpqkf$|u872eYulGx*>ddjbwz_i}c~W=&sM@RNVn z52Pl6Wm4`>!E-Q1+f@|l$UP}LlU*EP!?=iuSQYE1>D$HM-L09k{^95?M>A5NzYhj~ zjjBzFg`|CPSn}eBChehyi9TLol1Kd5t%i)%E}hRp_fB2OgO1`;h_OJ*jUhjAl%+pP z^Q%1u=Hw+#bX*!|-iy?k2}$eR&5+&n{BbMaKWJH2L(rFxq$t9Y%8K{8qH1GJ+YrI@J#++80_A#Tx`gPKb z%wx4Rck1)S(q>$A!&p~tPGs{G#)38=bpN{PM&J~0JrCIhXn1>-nlwCI59#y8zxeJY zv&2tUomI!Sxc8Pz;}m<|Uj^_@sPLO}8r!zZmet^(4CHkE z%EMSRj*jyvIB2{{Gwu5lr z@a&&VuPcl`y@!q5zx&5*Ej^zhoizR=-RM;aABK9ktDk3JnYnc|mKZY#$Y@+4<}Jly zDiL!&n3~Z~!#cRAePI;Mwe#l_%qdLFn9dabH~{G*r{fHjWA&o_dR}~AvYHKYxme%Z z??&DF8GphCngA#)yY)N?Xi5CLAveKsNI{kO1Q!L3qyCvDFVXt1W|Xdum&@*=tI(1Y(=|kV!sHI06C$g1XEnAKft&7iJKiZ5=8rAyEV*BnsqmH0G*zqg36L^S>LCjkUud_;CIQh_7P`tGfbZw1|9Hc&5j}r{NNMszBWuBG zKDv&9$ERWfN#rw#z}IDhM0V>#eonFL=hg~v&=R~t}AWpS4jZ|$PiM+l0g)X`QD5a6#+_e-I%5W5*H2w zSp2s(XmZ4YV4PrizG5jJlXue2g5iLePl-G%_7z%XUx$>^}UO?aMuFTS07tcFHZeno7eg5bjuu5Ows?1zHB@?=K0coT&&hO%(D>{D;Pupn=AkZ zYIhTEy&|H_zd3PMh6=kiLUBT(Q7NdPK$6F7z@#4QN?u_I-}PFXRy`i`ER8W1=n%Q$ zSKGk4GaBN*2NsdMj-4tZ#6NUtDnb8dPc!u(Kg&id#YCIZ+5)?vZtBB{c=+RTlEDQp z`bu89LwWD>Y|T;Qqzq263$O3*@ZTFY3@|=J)^};h=8?YL_-iv1#R-ZJ*#eQ7hP^B{@ySBjpmtV0Nn1lx6U$9GTUpw|`}+Dy>gv>%Z}!JW_g#LMvlu(REc&-#8lG)yd2O99}AyIZ>M$)TE z9^?VAltHHq^|4PYS|GZ4KmA_;2YiU&MPxGDSo2wENOvPfSxeu2sls|>_k=MEFK775^!kt`(6C=MrXv=3N9qE^2=wg zNO}F1Y;Lh# zkj(T}4ejPvU(KRU{gTf1)usJqwLB&eC+3!}O3f>Vyv z)CqW2gx%(tu5)~=Y$t%}d4Mqszy?WSBQ-TA<~eMMC9{46Gmx4Z0zbuGFQKi=(TIz{ zveU8OtTxCrHLG;l@Q{TJV*eOHy;bZ6gd;Ur7sUBd9jOPQsw_A0!)GW7fHI{EBM`sn z^UmKqM|I7JBjHx5W@^_mul|f6dn3Nib8A7AWSiy|dKoaRZnQSX#E2G`RtH+*`k80XX2}rgVZyyXceL^H zYX~)!jRWkQ&3N2N!$va^;02vyl#>@P!JTJaA$hu+O!L#c8&@5@{iLUO zzCckR&Nhf9@$b&zU-)4BStzlec@la>D(0oBM#-Dz>J4Psm($00#mbDJe_EYc&E?$aIQnY9^ncUZA_07tdw zQnkJu?u3D{iVI|x9Q~&8g;5b3E-<>LQdw=k^YNd@!NB|(J-0`j6?{XXbW;HgV>Hb1 zF>ImF6P~?x*NiF^uD7Z2>}@n`jMzT9)xM(Ep}?ArB=fmLx%jJZ=&BNrM8Ks&$SGI+ z=Vm_(ij$Lo0)A%`Ln-NJx_2p7o#C?Z$?P>j@t9vDK+xl89w0LHsvT8*yKP1JR z5=R9W#EJS4TCJ~GRf4*w+Yqhq@fo6TjlXFud?{GV?n9-i1fp-_W_#|z-A5@v zIPM_3KP(X9pVRdU+Pw|M&YV4pGV;x~S$bgV2q6-Enq(?ZMph+(3yCL8<0#K|d-nph zNjF46_&|g|tFlvT8QtXvx0Am+sJK0~28J9}W6b`N zH}oBEsnv28AH3gij1^-esu)4?FCLS~Rb{yx)@X0y39ocNC2%=6Nj=zoCjhx+fUS$b zb{h{zy*swm>MQSP))$fe_7V@I?w06Yq{QtwUSmkhjp&ZDUqVv*+#>qUw^`J0e2CkT z*%*Q$$532T=!vULu=(Y(e+OKYw4?ngnTtr_Gm09U+0+z9*OrQ>#~Q^!wUZ2Fs{U*E z-Jsg9;n%w|!V!y4R(F$u(T8Mxu2(F>mZ!SjN*FQ{vc>WsZsO_N?Wo(WyyiKvYtGBZ zfn{|VbL_O%?5{+|6dSZi(%ysx+^`-wl5W>Q23AD0I(_J%18%rFPDQ_JzIG24fj|B`PZQfSw>*iasC78I}2+Nu>Hxg(gZ z=5EC{=B`S0#1oFQkO)mY-ES6@Z+Np&c_4MRt$6OWON{^P9P<@YVt`_uP2SM4pdDh< zJ7x_pqIxR7u>zERGs7KzSv<&@-2$<%gLDLL0meXPxXY+yShV1Tk5G%O>J%S>wItsB zHlf7xjSHh#B4aJ3`#Z&x&rtd{jLE2zQNJb-?i%_g>gO?ML3p1*!)6`ugH%8+kA^#8 ztlg<@B+Po&pp+1Z>29JceVT90fz#A4QXhV*aw-nPu0`j2TqD_>gSRpGIiMN)Ga5WU zPLHpHmdt6HDO*wdnmrj4#!b66sU59o;Q-YGlqm+$rFkz?42?rH)G}oFYSAOXy{@lm zaFJhi{Hl);G<}F~|IGHf&N(+Xk^5be50}^5Zb*+A)lUIt158lgEwvT0iV8S1t(e>C zfJZA!R$JDyN&43jtDm{AshLYnND7K5*6ip|uE+9K05z@phTt=-kjVgf!CV{2uJ+FI(jxwI_e9P?bkH zqOq)~en9-ZdiZ?D4pA3=ieO-SLAF#!_)cr7km7KYUo7}gu;eCQZa`H@uZM`l`WN7iX9lo>UJ9{^7RxTEk~;dAcHF+Je(iv&F>1benmQ3|T1lgKH;6 zdvRB~QvdY8MCax>=@q-&Rx`GV{g1=t2OJ$U^CUhB+hSvmJByy3b3h( z_SeU1_21IF8v!_Jhxh$;+C&Rugq9Nb|mY z))_d2PI+oSPZ6vewI0f}uSassA7np~*8~a^@`dVGcPMfg`Ax?AH#uyqihy#BQ+J*o z4PR~hH>8CC@5d26hXHpj$A=G}n%_#Vcc&Rk08jvz~DyM}6{wO4~IsHY7&p5X?rF2gM?EqNk zgZNhkLbjp$$qW*!OfKR&%t66QZij7s=9T=lW1tpRk(0T#8*R&^VQ%o zM6uT+Y&yJQG8UUnQK9V?8E7KS(WAjh-5J1_4eakyhVQ_V-`i+1JAH44DHb8>7tr&C%gk=R$6T?dy_wgN`WjQ${=DYvSHbne=n1 z?qm*+5Q{Rn-|Q5-sHKA%`gx45sS>tgAwA-Wf;N+r_0_YQR@EmCIh+eVuMWF3sJoF? zv>`=1`Vb_B!Wn%=eu+3P8j;XyE-rN*&gCmEw+;5puiw00p?J(6V4CrE-JxQqphDWw z>j=st()C35B*8pS-O76=3=6(dF-Bk(8&RgMjC5P;n zNC<6uPiJ1Z^oc2FG0o1Ydy>UNt4T^q8l!sOd9pfa*ATrh5Oe^Oj=SR?)a>jkoJmrR z{90cb_=0$dOP@%DpjG+zZ zO53KOz`+fu8I@|0caYO!x-o0!iFXVLie*;zo{+07j%&9=iXF4`XecLkgZ6v6-4QJQ z`)*LOfaLrE#3KPJ*}DQcJWVUqD`-$0EP|CFQE;etgE$>4s|Ts=rHRaIY3CzHz6NVr z!eVViQ`?c5`YU{m8lC8a)%W~VE10tPhA9cSeu#92x(-ViG-e;{d?Q_O1a@}6WMLg& zEyW{svU?h$xsbydapt=3DJbPS3!$}JZt?{*rx+=LJ; zJ|$b>L@o5<>~jTHzXYr8iGizK#1fKUEMfi`hFF)oo$VyA9{*w}-|mnYg^sJp2MUcL zSu727JiiT+G#H!%DjkLD+!5ryw<+V|)b1N~uk(Qv4`%p|w~MC$G2fq>x)V=>kOqV! zBO{}I@6J}#*F36)fjLtoU`h2N=L3#upGNr8WxYhSf#b?0fOa{VLU^Bc&-a?E>ZxKo zw)zkrjsa!O)T?M*+EERDZ6ksh{E%ruZCf+UKT&4rAxP$UwA#Q0%tbuz$i*}Qc4(CZz@hFnfB5;d8Z}1PLX)^ z4Fl(9D6DR5#@AF!uMwHKy5lk5i_DdUQXp2q<_&M5roQDoRP5jP7U5;{iVO`%PVY76 z_Nstk%Umrx5$gaWQrZijzhPTl%O5L6c`+cB@H zcRO(qbj)+7f~bopUwZCaoz4l?kU~dCcnA45Y5u>aMde#M}F;L z3^pw;fqSIH#NNt8#;%QE-~dkGB3$g5 zSs(5fxdh(Qn{q1|A>iE3>ije8`uYd%m%8XTo2ss=rFiIWrtay*(~T3I*{z^v!}d+U z-TC@?S8Q(Rfu=|k$r7al@Y0tYS$BACv8PGo1hQypYfkc|7xLxaqtW-tN1&s79Q|sk zvnhz%VV$nYsM}9yL2}_)>Cji|ucB02^Tq1v0#6&Ck%(=I3XR6ZNlVG!Yr{xEe;u)P zw2HT4Z}!SlS7ZRDgcUFKg=+#fER6NMLG7@7?y`R<+_W{%Y70)WmsZB?Z1y9xR%oNu zBthB0GMwRtZlk@dTr^?I{Fj?-jcT_v{P;W6wvD6YF)@Xuf zbCWyHczGXde^-{^NnGM2@YNV0Fe~hI-%eTCdGb@0K8~!>m zv?C9Xry{i4lLHU(r7gpGKVjFPn-(=`2azma>!pZG;#?6}j||d-q`XZj^lZ0BX8B88 z;%s%Bi2^oUtAM;Qj~=IyL6|QsQ|ZziZmpe3cAUX%gGxgWL7&iQHqh+r6GVZ?x@Ub? z{y@#S15dW*t>dCg?fey$SA80K!HQ;%v(|R-!gs!hotCJN@+&SM&8N;R$o@VBBNYEt1^__q z{Feow;F?-^SQOVaicUJ(>h1kJCRp@Z)eUT%=W1z1)eAH~?P{LK8Mw_>(IrxaOh3S5q$$l!)Vby*19}8fK z?8yU|*HJTntYl>2j~^~5Zmx4x zusUql*tFqTl=p;Lgsg2S3(CcCJ2M7#cmH7{*3ikj=4(p!`x=0f2~1vU%FD}JZCZ!5 z<7!96tw-9y0!JKJH%|7f6q=nL!U)i;K>{YcH1CYm?g z`+gzlCb~XsIquOLC{1atQp{OJ-k^B+n8U^JN>=Caq_#_?W0K!z{EFBlh(Crcd!0z@ zia4a;=4&R*39gYDr;EP|G>iD-jPSFlj-Oiha*DE;zc86~K{vFP^>E+)s^T|Ek%^eb z#b(^9aHmt#nEU7Z_vbtAb0edzz>D-X+P6)X55i}AEE5hSeYIQ{{^}z^{!70d^(DuH z3u+V+9V<&9XPTHMM$9a^SzqUON_w)`(Uu9&jTsM>UbAVq^j65Rx!@5_oR-CfU8Oap zVRnd`hn~Q|YYf;Dppm9owFP4be?|(tdW2X@t+fqibE3^RHvalLwYY%qmO~dHl_-th zZA?;-yaCP_+q&3E@qC(`hz|HO7c}x=GZeWI8K}!`Bt{|KhOG5W?*4?c-~0Zp)A?!Wd?Sba?Z?m*|L$Yjq>4;3X1cjku2+ZvHYlqzg&c>Zv@_qFHV68G;h&l)56Mlm`g#^a(v`8f+Kq@S z$AKLt{5E~IJa`e{*E1W$HCU&E#88r{YuNzI_YitU@+0yGB1?6pKnGG0zwX#J=UxwC`pH}HG*2`D#Gs@)Z{2J#bIQrpma6Pt(*8Hru z1u(q6@xXL7>sZD_A|5&U-qVJj&RE~;dSJs58eZc*kM~`d@eMZE;JohE)sCGYpsz`D z0Irrqo+t1g*g8>$$m{V%n`iENFmyi#d4{-ooG?qgUg>h{pnl8leCip%>$orchLS4% z-2@w~*7D27?`VCY!Vxai7>!uEa#A_^BF)bG`CXtTSJqBd z`LdZ*Jw4q!q=VH24+oNt(0K!q_coHv{-BNz;eg0OLR?o`)onnj5xN23c50~xY`YDd+F9@>Hp_&gs{r18RUL24sf4@>|U zWWHP9(lrr@lye*~u?X!{?S(qDziM!F(D7G#7_cVx)q-c>g{Vq@ZDv)sRtT0}7*{<6 z;D8Qn9G-0JzKW`Ow}6!*xe9%PmMW{ucD+}gU7y=YJp%{=e;^TJs58U9#L)P2IUar_ z!X7zfC37C+U5)8Msi_f48U7viv2L_hJEO^QUE3{77v6GpA_jLd@HfwU9TTG2^fjhH z1%QnT+k&5?0S$P%{ecEU8Xow!J%#f#&h~3~_QJ9ZXrXsp`wi|c{3wTc{1z(J`&ynP z!H0t1&J6=~28TWz!gK#X6bCrCUX{Sfa@dh1Wvojz(DDfKOzW@r5XALGB-&|NK-;tT zq^x`&hN+UxI1;1aBs~b_Km^l9%qDKI?&!%_-Xr}<1a?`;Sb%-IJm*TZPdd%Z;f$#+ z*ViSpA+!%9zu}t|3a7!#1XwuFIwf9qY>zm<`;~vz**(9$(J78k~`p8;$}nh z-*9CV!= z?fK?TZ$)gwK`(G+GI__gYd6)8;A*HTHn30<+1!+dlpS{U(v zsdbiy)ICCOF{Rmyr0}+k=eNHm7_>Iz8=(o&V(mt0X$`g>>Kk;W^bovJtGzA*-fsqz zqLMn7)vcQ+0ftN8>j{>L-6lBbAD|J^#R5d zYv#KNq>>FK`Fskcbk?uPv<@_9H)Sv5&-7nnR51HDWT-h=;=2v^m z{B8tKF^u5f*!&h$841J>lRaDsXn+sibOOCHR z-7=a$u?pGPTdqh3Q||?-9k-6FRF<}IN3;I49*4QF>szI7RWVHWS@QM*)ZQqm&AuSs zw3stC>5`oSLp~J=uX&^&hA}`-j`ZtVMmo)as!&*BPJ+tAV+Z&oj}p7syZ+Fg++S$1 z?(nN6`x-;~si4s4o3hmsd!NV?Lxl*aup1jsn0@73sb|5*ubE%Stoz64^OWhw7NPMn=t-8J(V|G!+;yE{j%*yPFvCNGt(uqt zJ=NyX5x315Vy&r_wslf!LC@D*(O>T6U;@tGB$o2F z7epX`wN(;fH+%*ypRW>fUh9bJ@TFCy_X4QmOgwFEgG`c)Mp)*l=6MjECZk>sksbB; zunyfo-5$EyuaM{i5wPcU$_5>wokz)ZY}&D?n(-@H?}TI`v;m0v&2Z>u2kJJf1(CV|Zg`i5cCJr0=|u(T7wxHGG4hg(aPT}K zZ|T__sS>rmyV~`AXTkacLJ(-c&^Fmw>*ESf7*cfY5#ILD-oG}mOZ?9L?F_0C&&4y> zyHAso(7*mszRIkk@c9JZYO*@$X+z%%yZC3yQ;^cn~ti@Nw762du zNQ()pd97dSd*)y)JqD%$qf*?1OX&QK*S1%CCo$7XBGIkkLWtxmYo%Zk3s=c8m6TH0 zanWO;FxaPuFxhR;OGvF2SBoU%3(4|!Ubi!OuV=Eoucxd3T)2F55xVVkXL_?9Z~9I= z`zh`&s+vkgraY}XLT$bfOja#yT2;TgAb4< zzrRNPHe_F@73wnr&jz1oaHwqm8b1fSiCTI47u5t zp$iaqous4VtW&i784hy;draNybY5W}tJj2Hk86uB`dO)4j-6zt=*k>U%>{GK8o|=y z^XW96LT;Os6Rl0sL_|s){SzSTWnr-;MDC{7pDqrSNC~-S8C^QDF z=m2wtIcWuqVbQ~YZiUU#e(QV0WSG`}Q7k_LTEN=jHzQ_-MX764*m2Ok9d`4<0G44gW%`4wxwI#-JrYsdA(k1&i!1^rXXyh@PsZ z`++#|OzaHxSdfld)}X?eNlBxDw;|9G2Li!vAvI-UNq8xhi2zdPKkq<{HWw zRN7!qZ9a#fEX2aX9k2YweUW^u=4(J*pr#=azE$k8wlu$$J;J6iCt;^^CDXkIimH z7H?R1_0)Zij8Sp<0$fM*HOPH+Ox7Tv=4sybStIt2E$1WV>nji%;B#iLrBHGqc4TRO zd~JUmUTmib8mFd?Q|GoxLsp=$O4DyBovq%onG8zkOic7r=M=uDYzTgY*%0_;%xHDS zpt86Q;6aSS=dK}7Oiv-5L2!3xy;sPSVId&@2->!=gD8rcEuVu{+F{`xD;!%w)=oB? zsd&GQS9VXOB?ecjZrt0<;xJEL{C>kbP2%*##|)1r^9*=I)NemrQ4_GHY58M&{`Icg ziWO|{$VwZ&9;7*#9+Cmu0{4o5Lb%&u1Hm;>qB_o0Y4mMCb$$1nDq1xvz<_TTW&v>w z7!WF@8ln2}61C0d_m|Q7bLBg(ycrMl&M3d>nE-vwFICcoq=YFue9mh(S`2!@#klfK zuhf>fSXcB6_y(1aI6dMyD(`sb^jl+G`^1kCfZ0)+5Nu<73_-Izjqk0NB{j*D)J)D| zSnK>(C5!5I`0^TO_03Dzf(L^Q&p*vIRZs?D%p@Q?UR3c$JIj=0C#+ZbpBLU7zb>tp z25QTzd) zUw|p6|DJYVijOV}fs#F$Koanv+A9?moC}BflIQ{Y&zF|FZhrg@!xy}n}UV-Q}+ zr|ZLxux_S z)@B_oEyuA22fpf|cj=5DsRhV-Qc;oTCv1(S(-rS2IBsh&GwPlkbuT}7cQ9psgim`< zUbU(XJUS}})IkGq>Ro};s|%hNO#-}M(cel8cmosh0)`MA|?wm@avJ zzjf7hkK#sM$FhyD&mQ@X+C%l$gw)Tlz>f`;5mkY=B#nTV3il}!4^@S?1EAnK0N4Mf zL%tuY=eb}NOk|6E2&e7$5UeJsBXptiojU-lvWFiFd&%zQ$vxbOv>e(2?pT--?;oN) zK(L`toSGS<&2OGRMbAwlm+6rlYtuyC4Mm7I1tbdo*!2w7a8@b9etGFxamY)^gWi&( zY2sd@pssO7XbLc~Tx*#1uoKR3^)%lmf+)!Q;g>*aY%3Zp)%fu~m|wpmo5{rsjV+t+;*U%fvy+-T}9T*k8ZS!9RFP zbXgEhs6r}*Y`HV0*LpV+CsI^8&h4`c+$8jng8y4mfR2;eNDb~0V4T2s0b+>#2U4mw6To&%}YQMb{#IF~Z;b&m`@nv~cp#Fb<4J0T${pB{f zkM(tV-HJU#yz>zs_0<`AwM&xoG2;`VX7(UBc-w{cF2{uKd5gKzMUxe@xQh*|ni(fC z8_E$xn7cpQ;4*vC>_Tf~{(y5$eL`<&X6BcKeQ>Ds_4gl@=U{Ft9G%Pc)@of`aR&Fq zSv&@3|KOP+7DP~S*adh+SD{|CXZ61o&vlOwcDsCi7kGS-pl4J;`-$^S<;Pm6#hrs+ z4XJ1`ZvVUiEmtKX07-EG_m0wFuk$X{xxnVoRQdMf)q))z2RH}E=(qoLpFSrqWRn%u z7@U|n53XJP!1@e>;SK%Wmc9h4g?9Q?#(zxR@qMp^1-qYC!7Kde!7E`sf1Ovliu??aSVR=qV+^UOEiqRp8pQzsx#(;-XrzIkh;bf{?$F3JQ{KJq5zSDW z4?J{5K+^7k*(S!rbi1jnSt6}l{&TIS=ofMIx_*!TO4@6=U{7UF(Sa(LmWE37 z@5ju60rW52wDfV#yo#BP`$YU3emgsbcs~vCUPzW;C2D#nP|zXxX;V2+^?vb>9>Ryn z3v1nOZO3l@2$JN{(PfO6fdI@DuU2!A)WaR$TO#JzoTm>z;rq~i5y~IFbn_PRh}OBg zh5=5%DL3qfsdD#&uEO?4_smkGXR2zZAeu)R=5sLF9SlBny5;F&wq` z!!3h8?)r^X7Y~gMnZvt!9PnaX z>3?cod6--9bkA7vl;>*)cTMxWCE3E*E7sz>s_&g`8n&ee@J-b(dA}}OuZdT=^gV(L zIQc#3F&mmzB@fVU=8OVCEgk^|$B<&e~2+rIRm01#c1=)Pxuq}ZT z9Tyn(MOS~nu+G3?tQJt{;YhA)L$+@|+wr?nTn^krW=HsRySb7;%@SRKH(IU_JDu#LR)IEsZZr3@24_kS8R{**qZ}u6FVgrX zsvgSvqns|^yGWs z%c(h-jW}iJ);$Xr7?q*s(*$je0)z|SbpRbmP(vv++9*=<$%>lG=@~W<7|KlR#kvn& zEI!QK&H5*O0L;k>!ncURujl;&s&0bQLk0YO+<@$GEj?!QjaZaB`go=+><1e4-gHk& z-&Z@=Rj?!#F?S{^D(Z#H43~^ndzg%@Y{gS;m)FuakW2EPD`WPtAoAoOB`FU$iWB*UtvK=8fZMN607saz8eCP;)vi~g3 zhGi_Er%&mw^z+}Jxv@of2mos~WScmgPbku@j1|K(s#1(0L-*W$ z?qC9PwuS`O4-?b$qgSZ22Jxf&5gJ10=c9bB(pnVTU-!@e8KHI-A}eq;VjHL=Rom1_ z`RCE0>rlMWax6V-Nd0cOJ-;!Fuz{S0rLyhL`PR4eOP`JOyZkmy?7n-(x#}!jI5{4{ z7jBijnhma05RRZ76hZDxm-yxNw-MyHosC6CF zM-})c{1$@%`bMU9;XvWC7YZ3OLn(0eS9~Po9bTmx{e8xQCOU^EFvlLmwVAK#6Mk4c zHhB2_oVkFjV2W(4_=}J4NnSb$@om@(v8XF%H{4EK^=FxVV+`5Z?x07_cB|ZB)(I zeqhn3LMg{#$F6A9Kh0sP!8SH!pnm3t;6vv(ryV-I>Mp$KGrz@vv{|IRNP%&TE)m7%Cm^A`Rp1ErFh(L&_tnfmZGD1PxtC zVq(_upY0NrKeXv`jVB+cnI9zKT%UEE8%0Ltq4Q-jTUIYYxLd0zV zF11YdbUlpVG#2Uyv@kzzN;91s>`YU{6)I#EGsE|AK`j}xyH~IR=3%E1;xLn2YgKp zU!4(-fLm>K3HAhLo&aiYp<&`a$L^(xvxXZ*oGe<^VyaD|R;MDCDbP0x-a$vnPG3|0 zpHk3Qsq;_h)W`Fc*{(a$(%Dwa;Gdt!Zov*4=zKrdiudEogdBPc$FAV`wUoE-=VDt= zHPPFR;-0g>Wv>Xx3HJJWMP)i|e*rKNCj&XR@X?8ustJzJ{{T8{V(L653@qx}hOpVS zAnG*&^D0leGPlC^WUNBnN{VNHY(@p0vIwU^yet>8BYKhkDb$;(MaGrmqy3J9APLf^ zfVW}I_t&_W4WlIZ8pMPQ@PzCCd6F7&)B?ZyzJTg@kd%W8^s*TIQ<+s?&& zQ*&N=o?Y0&Sm7uA{BKk>l~^k=(~E_Ls~$p^=-2+Du+iXcVfWh`E?~Q6Q#qe77Pk_t zxk=+ckbZW4$$ji`>%V3T*D2WG9@n!78}zQ|R-+OH;3|TNRn?gM@&3^+xTCzgj(76S z&=cbzJom$mau7QS=WJK{wIS>K3`{p~+Ze$6wBhGV`_t zgQHPcl=@~OJ1=nOzY$=S;7=E%$4;x-{Z}vrhPfOH5v*&)K0<7qN{fw!4tbSsG=j6^ z^vcqK3n|_tqmI5r{ZIKtqIjH2SykSw*4s}Dig?BKdR$$)dDE0VS&wVMH5>p?fDD_d zF{fj;?0t|aqEwOJ-s#J6gTe?Qm)A3{_Je+(>gECdr{T7*>;V$WLeXz3ZSL8ci?rM! z_o%)lTPmkR_g~F{$FI*7vlgo@wmGpZ6w5N?#L4YM?$s(Qjfomqz@2B>eixdNs(x$$ zmwy;&#x&w=;tWuY)cQ|?xMC5Z2|NZ2yCCxiHDw%*;!40>$77PP=+z`ZMWy0UVP>=Y zJum#gZll%S4Z`cmdDwe@Mk|S-jZI|Xg^V-_IS-{-Y;w#$)jg(hlR3rxp`63< zKvvhSP?V-8vhU%%ey2DKd$`kVjTVc|93l~mi(#N)C|&f_ZV0oYiD*Q|%IVchC_ZBW zP6swMI5X6q&+K@~EWjI>Rd+w7`WDXUwVEj|sxMAw#HTe!W0Enpr=K7;y&=FAab1oefqa+tJpus6XcD~R2!fd$;&g$`SxW5H$*`Okv2Tl z?!0Omm>iRylDiE8u0C0FPwll%$Ynk!;sAhbP*h(lXDL-UX-y~d2igM#9>)Y1Ib}%W z)A-D5-_*OR<*?|*O^h`l`})xV@G@h3vE%L*`MIL^+Gh8|;NBIYh-{td-0LbF^qM;e z;5h(ZS!qqMZebuCsU9bfYt{W1DG9&5qw#ST&|ax!!O~dbonv*?3E^Z!ix3n`&Bjh? z`8nZSiXCy8uLg8aq+jVtY4ban<{zgGEXis)w<1nRKAB`swV%o1RvY{T7@KeNRjxH` zJm$z^q>IWW-TE+I@I(d%-uqTo^h6&qXxp%MzItxSvm-p$YBwyrUz>KQ5I@(fbxyC< z*$V_>bcH)Nflq5Yx5gSx=4)^2aZb25^_6yZJ_f+_p<{uFDx8=BDFSRYxNgA+#Z+4H z_^G=UtahwDcOPcK0M^>d(kIvu!Zn-YyT~-Ce+?DCqj6?}Vg5Z>onJ|FK>RVK!EoMs z!vA>0q`JWVOXxLsI&w!-x2%VNb>i|D3fGL34UM_)qjMO*632^rHH*RuAmR*0aW0y_ zN62D{w#vVTKWvg?_ryHqZFQzD19CZ#)F|UF`@W8Y7f=m{sXH?P>RB=-mOI^T9mAOz z3b`W=f-5fP;!j}uUJblrc|0zxwvfQ@Ev#mot@|P=(9j_=+*bJGj#p})xnLI8=+7gz zG|4G%cx?eZ;5<|RZM*O^aM`3A5vYP_K&IYg9-AW6Sughp4ndj)sF&@GtHuCPi8*kn zM=mqaERT>4&oI-N-QU_R307X(Un#S!=>I-4(G%w2E!-7A{>lBg#*H#B8~|+d0Ag|q z3LWTr<3VVi*MsEP^IXnhEIOao0cW3O`SYCX*#kJ0W7w1oX;`Hdm<_7?DR29>mw?g6 zxMUZ|j!YhX&e>0h`>1T^QH9w2L%44w-G@PiaP)u+K091^U4CD*35G#myPCC4kvEx0*wWb9?** zY(WRltW|X$S=WYP%k1|_e}5+4ug-(p-Br@=n+zsWIen<1Htr5K*){RXU{KxF`#$1s z8wl+xwJa3~^DmOZ5BapTc)l+fqUK&MlVzAIP^FR4lg6~JCu@_N^1nVfF~x1v82p1d z^I>HRwnYf|&-)R^mvd%#xXyxpgVT=7?fFZUui=mQzn5#1qugLPb%wUSzOg^dT*p)|k?YOiJj#ezC)laS;u0W1xzpCw?c%3EpjWo@&t{XEOhwbt zxR#mVv!)lj*TY_H66gy=+{cy;Po-pv24d-$WpH5V&t4+V^An=}RM{$9=)c8Y{&nO{ zD_LymXm|ml2p`XlEjhbP>2Y)e!6xh*av@nd6Ul7M1$!Cq|SRHPs0NJvcTUFRxZWzxP$sM z^GGQve}gA~GK&sf1Ok_7|DO0RO}R@4$GP_rnGFax-jDAcYWYEm$HELT&H-J9Y_LkQ zHgB|v+j;cN{&@%`99Xuq2dSh^68ZFiV2JG$^7dQ?6hELrvZD z2(VIK)#_x@1eHT&?6k>S2`l;EzjfmDu`)_3`W6;ddo-cIvhp-v#;movHlL4dnZXF& zL4`7CyhL(*z9W+#@CJur+=7GE zM=Xgbk+957xKc=Q^mxuNbq>ho=i2p2aaPSh>7l!JIk-M$b4rz(r}YX%HsIbzb=*pq zlKLISA!9Kn^#%P~czFDJmH=cE%6IHiH-@c|fcjPGPb*(L9Z%)6a zcAkpwGo5+KEPfQN=-kzY`2P5g&x+x36Vv$UF$+@hItK8xV}Sx6ei0^5IZ2n{;XoWD z_i&5@q6-}|8y^^sbRxO(RqXn`>gDx!n<*cu4wOs#)g6CyG559yb6NHR7Nd3_j(=94 zDXFZ-RCnSCfxr^$XhRJ*X*FKfu+~_Fn(EqNMnH2FD3H-R&nED9Y8{{fdMF-XZD;f|pqXN$ zp&aHJi9L|zbe6f__x8KhT-AoZdH_^Wm`Gf-@H)Oq?qH31gL1gg*q&#QR2BO3$&m;T zE3UXt?b@&Kh_|^0_0eCvY%<45__)>m2GFd!8;p*#G*cvw4<@GJxJK1VMAPo<-L4dK zHsp9}p+>7PqG7uSViZ=#4xM|w^ll5mAUt63XGbkB00AI}>`*Z~S<8ZZo_ft`kv04u z?!hZc7kG_n++IJHeKpNcS2&c)b(5@t0FOPUW!7_n%^PcW7ALS)b{`YBjS%(f2Y{i( zJi|O|#uCPzz5-k*FqjSuGX9wXC|-rlZ^*rEccIlyhL9A)U|=eW_#3@x&mK3*E=9|{ zD}6{c;20P3By)5iShiOcB1L~I7l}`nD@S8ii^cLq!WjoO_jzV>lt}3HW2|uB1m9k} zbKcatSMI+L1m%CkPrW zq{cDB6sCJdOq4w1P5I}5ub`QvR4e6iKNJp`FII#~PvyvE%;;9ASFrtN7=vqq)Ci}o zWy@v>T{cpv%juX5zU5JH^uefV3yL-RJm0X!hnQck(LL54I;+7BdR%)aT<8qFUf?N} z)ARg>Fb09ZcJ@;V>k&4ifxf@X=YD5qx3vot5m0Pu+lQqmK&c1sX{0f9k3M~ny89fu z(3a$(90f_c)u(WInjLr%br=P$rtP`P5UM~gCXMJK81I_SiR}!T3tUp$^Q!EW0r~y5 zv7Pu^Hqb}_g19Wwc7!Zp^henmLcu#pep?g)b-|a_#zNSoO#As! zFl(aexqPQkB6t_GsS|HVV;4`*l&gjszcqha3$eD|4z%i=b}ne!lHSd75^zVPeSRhn zuGGOVY#!%*pP~~o%s#c*r9=s8jBH)}N+?^_R12Zy&L(!9Qdv=!@iw-a7uJe^f=}Ua zfReEX*gR)MGFRw4mSfl8U}7?_nh~jjZ{_@`vR}k+qyP}5BV>sFJ~Vee-C0JO1am&i z_JvfSN-DmStE)=o7--1BmETV(Pq5EP#Pww;DJcn0-R|+-Na1B=W&OC-)=trIWiDv| z0$W`L<;5^%lGYl*qHQHQ45cUnzb(RXk0@%*n?1)rf=*;+^aiEKa6U{tSo17xp}ynQ zSW!`_lqv@w7RQsMWPfYKwt$J@rr9Sk5ksK-R3(8`XY|)<=U%OiHC`)OPO$&cK*f%C zx74eyt4~xGG^0vU7p*Mpcy*g1&8;d;%Aje9IOg|ZxXlLZ+<$vY4v(HiFLU95zRYEX zQK3}(l{aOj)DrmY79ZZd-yXoMQ4vVwQBmuByq#DDg7fctp*D@U=a}Chj!zAjYV*)E zi!(!#oKWT<&hKv8l5~<7K9XWv&1qzdfj}r7!?RJ_#o$-%kn9baVy#2FN zx4z2jy!*=-GFZe-bk)l0W1oLM({_2XFju(BNij;9f~9s`)vm>UX9s6?sO01PV`JaWWTJC*VN7v#2H!pVslP^0_%3Sapi(X?De?(k2wx6kN z2FA%Y>UTpJ#L2BCjoTi0DeJ?+V)8g|77ywpZk`O`4*xX96H$eQ57WQp8t71{@r?QU z)k2w59FY*H!}LW8Y>U-Z@*f3=eX@Aw(Ox4@j?K`g9ZRreG|EGW-7Xz_a}7!TO!bW| zKL!N@U4BEJJ+CHnlypB7&6XeXCc@07Uc+612Vpd8^gE*zGgKqvd$nlBvh9dM@g|x^ zw2Wbo7p(H7v1hLp>F+Z(6_nEr%!$x3ls90b1rRt35vm=+$oG)3EJG`;JW!QQMac;g zFiJq1FGC#b3qflqdP$Oh^KnHPX*sO$kh4ci@V^(zGi%nPGz;7adpT|T0v8@L9%;49 zJTE@25s1uc8EMx3z>xS;Mj{?a2(_iASmHdR{)V88vgSQmW*wCmAGje^wM3Zh8f8g) zEB$yJ0L0|x=X&KQIa0WN{5%T5aJ5t^2FDdh&Xwerd&GBu72W^GlJ#`NknK`yW`17J z=@)-VB+eXM6MsKvbGiapzdgqD_KD`}RV4F_jwMf~9IsRLN=V?~_n%i;Nugxnb|G*g z=!(o6!Oir_lXHEPjLq+*4(wqD7(;#t!A}!a=V&^w0Zio>uVuHUHv`37=ml!;P)*Sl z+bSFOB-=HR^0kR20Go1ey1x1iPxL>(w$cpvTvxsZZ2^AMO`cGyz?JWPAUay<`MK8! zn>zD+?HSF|T_Di=lt!|uDAftuQt4M3(?LHi4c2JC)(vBOo>{1$zsi@7IUva#m%m)1 z{vEc2)wIF!jDM>1Fozw^;Wl%RGLemzx=fl8WTUD=wFHQ$?9c{!GEM05$Ow710lldpJX63N_mTYIFCpju?z_p89zxHkaKQL zv|R1oE{@HFaJ||PO}M92jPX{(wIrUD>Y$QN`YQ3b9i-4SmTg_(dA?+i}^m`lpBJ4kg5LPqA-Q|*6yhu%|Kr4^1 zmc9I64H4^e453?VdIwS0Y3K&8+y-YYcgm#1Om=aO&~14X!Zwp6( zf*Sx%z=cL&70xy8@ZZ=Ba7aa(kzsk0j$D_Mt?Wy%QjClMaZxT-)tfHCHI4m-9AciC z5-p5r0P>WA#tV=0G((_wXi^7ijN8ejjKmn+;&YEY1}mdm%}r&@FRfR^i!T$yiTIfK zhv)h5m4C3)w+qn7v9^C`V^1w~6gb9w$KkYj!Bp9!GJ-tO+M!V>x9%P0+&|1ZPnI}0 z_l9HO5ZM#_u6)`_t&a1ngn^93nl;cK!g30JXh& z!{BR~z;CPCD*{(&BYEb%OY9VCk<5;JSSG#MYS*KBm?4Z4C5Rbv6D9&IMegqP8UNRo z@`QauC#RTh|DU8Ryp4sX3Hs}kbsKTkuT#+`yZWeI9N`zoRZAOAE-*kWF^x!ab7Kho z%;WS`8pxUekMSw6+P<#?F{O)K9xK=sJMGMyy2|yz}5%L%czHpY=uUh}+R-2sNxO zRDnlOY?zb)Wc5MevK1yCh!~1F6SUV>G8N^89G5)Hr?}G_l#vzO z3%kthsxIW>U%AOctckNQZY<6yaAE6!vkId^?i0R)=m^Ic8w;`(*flcr=}Nf=QkNa- zshAjT7|F?N5k!b6qyX|YG_VI-F(;xf9<5tpB|0EXo;+V!L_B#eS1E!}t~V`jX4rs} z6RbeClrDi)TN5}wM z#wK-TmS$oL9!&SW47MO6@n#*nXBCtLu#hB6rYRCTQK?%ZWy^#$l7?g_cnT|RruzM| z5#fFi3_Jv!XByaj6q2AI9XWB`nUM!{84}M)^=d?*uU|^%Z;l{Sq5O8h>Ud0~91dm- zXcM>pOiP-(2t1^g1x=C>YLz5m$Yh3o=A5H^5a|u$7QT@i`v zs%7L3pt$U@>xI+e&N22pN&0Qe7Q!jPHCVK~FGt)0YFchhtB=ka7IVudnRy>Bh!0yd z6(>k1YU1jL)?Wa|h#B4my3)zalmlWLgzHRSbr6VnOu7tOoA!v)_@f|kmJ~NaM;$lv(c&CLU=md(LVjaAua0mwq<4sx^<7Ya=fxcDa z5Bp9qe=e&{;dg@$zbeth9vP@>k=?^EL!>r27JW$1f&dc&=9Yx0XtYts0tOida>W=8b;`U)nrg0OY$v)yTB2C`lL6Q_DSs) z25mzHiXKrUXV%UqXO~~FIknwzVq<2k%e?69%DG@8?BH4LH!O^@bfgA&{?{_VNp`es za@EHEGEk}_d9afKE3%1{YIkSnZM69OV*y+JsUdfyXb>g8gO>GiA>+VAFm{B+br4%c zp%A~6ysGcO$90hKRGPuq!d8Q$O>f6)nh1g1KGcV-K;>B|P&3q$J4Ox!;`x{c@qb&z z^}Cj4#yZ6OfIjAoScY0#BnGBb)!&<6i;)n9PG%m50TxuOkl(Q&VyY2lB_}`1U&*=u scTEIzK4HlJ{gwZB`iwN7$`@g}Tk5l-_%Gi7G${aSaRsqj5u=d*0e}Voj{pDw literal 0 HcmV?d00001 diff --git a/public/icons/icon-32.png b/public/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..a19279f0690e87da20ab7989e3ef4dd401336f59 GIT binary patch literal 1160 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

jis$0avX_KC%q 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 kDTR-v5{3c)59}m9;{X5v literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..644a5c8eae4afb4859ea929be2c517a19e834b39 GIT binary patch literal 40995 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 literal 0 HcmV?d00001 diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 000000000..574b90faf --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "God's Eye View", + "short_name": "God's Eye", + "description": "Live spatial intelligence in the browser.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#0a0a0f", + "theme_color": "#0a0a0f", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { + "src": "/icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 662d33a6b..d910536a2 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -981,5 +981,7 @@ "src/tools/globePanel.test.mjs", "src/costEndpointThrottle.test.mjs", "src/sources/gbfsStations.test.mjs", - "scripts/qa-gbfs-names.mjs" + "scripts/qa-gbfs-names.mjs", + "scripts/generate-icons.mjs", + "src/tooling/webAppIcons.test.mjs" ] diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 000000000..3c16eb46e --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// Renders the home-screen and favicon rasters from `public/logo.svg`. +// +// Re-run after changing the logo: `node scripts/generate-icons.mjs`. The output +// is committed, because the install icons have to exist in a plain `vite build` +// and contributors should not need an image toolchain to ship the app. +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const REPO_ROOT = path.resolve( + fileURLToPath(new URL('.', import.meta.url)), + '..', +); +const LOGO = path.join(REPO_ROOT, 'public', 'logo.svg'); +const OUT_DIR = path.join(REPO_ROOT, 'public', 'icons'); + +/** `--bg-dark` from src/ui/styles/foundation.css, so the tile matches the app. */ +const BACKGROUND = '#0a0a0f'; + +/** + * Fraction of the canvas the mark is allowed to occupy. + * + * A maskable icon may be cropped to a circle inscribed in the middle 80% of the + * square, so its mark stays well inside that circle; the other icons are shown + * as drawn and only need optical breathing room. + */ +const INSET = { normal: 0.78, maskable: 0.54 }; + +const TARGETS = [ + { file: 'icon-32.png', size: 32, inset: INSET.normal }, + { file: 'icon-180.png', size: 180, inset: INSET.normal }, + { file: 'icon-192.png', size: 192, inset: INSET.normal }, + { file: 'icon-512.png', size: 512, inset: INSET.normal }, + { file: 'icon-512-maskable.png', size: 512, inset: INSET.maskable }, +]; + +/** Fit the logo inside `size * inset` and centre it on an opaque square. */ +async function render(svg, { size, inset }) { + const box = Math.round(size * inset); + // The logo's viewBox is wider than it is tall, so `contain` bounds it by + // width and leaves the vertical padding uneven — centring is done by the + // composite below rather than by the resize. + const mark = await sharp(svg, { density: 384 }) + .resize(box, box, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toBuffer(); + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: BACKGROUND, + }, + }) + .composite([{ input: mark, gravity: 'centre' }]) + .png({ compressionLevel: 9 }) + .toBuffer(); +} + +const svg = await readFile(LOGO); +await mkdir(OUT_DIR, { recursive: true }); +for (const target of TARGETS) { + await writeFile(path.join(OUT_DIR, target.file), await render(svg, target)); + console.log(`icons: wrote ${target.file} (${target.size}px)`); +} diff --git a/src/tooling/webAppIcons.test.mjs b/src/tooling/webAppIcons.test.mjs new file mode 100644 index 000000000..2267872fe --- /dev/null +++ b/src/tooling/webAppIcons.test.mjs @@ -0,0 +1,70 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const REPO_ROOT = path.resolve( + fileURLToPath(new URL('.', import.meta.url)), + '../..', +); +const PUBLIC_ROOT = path.join(REPO_ROOT, 'public'); +const INDEX_HTML = readFileSync(path.join(REPO_ROOT, 'index.html'), 'utf8'); + +/** `public/` is served at the site root, so a manifest href maps to a file by dropping the leading slash. */ +function publicFile(href) { + assert.ok(href.startsWith('/'), `${href} must be a root-relative URL`); + return path.join(PUBLIC_ROOT, href.slice(1)); +} + +const manifestHref = INDEX_HTML.match( + /]+rel="manifest"[^>]+href="([^"]+)"/, +)?.[1]; + +test('index.html links a web app manifest', () => { + assert.ok(manifestHref, 'no in index.html'); +}); + +test('iOS gets a raster home-screen icon', () => { + // Safari ignores SVG for apple-touch-icon and falls back to a screenshot of + // the page, which is the blank tile this whole file exists to prevent. The + // existing `rel="icon"` SVG covers the browser tab and nothing else. + const href = INDEX_HTML.match( + /]+rel="apple-touch-icon"[^>]+href="([^"]+)"/, + )?.[1]; + assert.ok(href, 'no in index.html'); + assert.match(href, /\.png$/, 'apple-touch-icon must be a PNG'); + assert.doesNotThrow(() => readFileSync(publicFile(href))); +}); + +test('the manifest declares a standalone installable app', () => { + const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8')); + assert.equal(manifest.display, 'standalone'); + assert.equal(manifest.start_url, '/'); + for (const field of ['name', 'short_name', 'background_color', 'theme_color']) + assert.ok(manifest[field], `manifest is missing ${field}`); + // Android's install prompt needs both, and uses the 512 for the splash screen. + const sizes = new Set(manifest.icons.map((icon) => icon.sizes)); + for (const required of ['192x192', '512x512']) + assert.ok(sizes.has(required), `manifest declares no ${required} icon`); + // Without a maskable icon Android pads the square into a circle and clips + // the mark; with one it crops to the safe zone instead. + assert.ok( + manifest.icons.some((icon) => icon.purpose?.includes('maskable')), + 'manifest declares no maskable icon', + ); +}); + +test('every declared icon is a square PNG at its declared size', async () => { + const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8')); + for (const icon of manifest.icons) { + const meta = await sharp(publicFile(icon.src)).metadata(); + const [width, height] = icon.sizes.split('x').map(Number); + assert.equal(meta.format, 'png', `${icon.src} is not a PNG`); + // A size the manifest overstates is worse than a missing one: the platform + // trusts the declaration and scales whatever it finds. + assert.equal(meta.width, width, `${icon.src} width`); + assert.equal(meta.height, height, `${icon.src} height`); + } +}); From fc2c372ffcc7cbb67ecda42bd8e27a08be719ca5 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Thu, 1 Oct 2026 15:01:32 -0400 Subject: [PATCH 09/24] fix: keep install assets inside a non-root base Manifest start_url, scope and icon src resolve against the manifest URL, so root-relative values sent an app served under a path prefix to the domain root. Make them relative, and add a build test under base: '/example/' that asserts every emitted link and manifest URL stays inside the base. index.html is unchanged: Vite already rewrites its links under base. --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 6 +++ public/manifest.webmanifest | 10 ++-- src/tooling/webAppIcons.test.mjs | 85 ++++++++++++++++++++++++++++++-- 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb61ad1db..0c07f3c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ instead of a blank tile. The icons are generated from `public/logo.svg` by `node scripts/generate-icons.mjs` and committed, so a plain `vite build` needs no image toolchain. The existing SVG favicon still serves the browser tab. + Manifest paths are relative to the manifest, so a build served under a path + prefix installs and launches inside that prefix. - GBFS v3 station names use English translations when available, then the first usable translation or short name. Empty or malformed names no longer appear as diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f8e6bff3a..3d51bad2a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1140,12 +1140,18 @@ favicon; the existing SVG favicon still serves the browser tab. Safari ignores a SVG apple-touch-icon and substitutes a page screenshot, so the raster is what makes an installed icon appear at all. +Manifest `start_url`, `scope` and icon paths are relative to the manifest, and +Vite rewrites the `index.html` links under `base`, so a build served from a path +prefix installs and launches inside that prefix. + `node scripts/generate-icons.mjs` renders every raster from `public/logo.svg` and writes `public/icons/`. Output is committed, so building needs no image toolchain; re-run the script after changing the logo. Ordinary icons inset the mark to 78% of the canvas and the maskable variant to 54%, keeping it inside the circle Android may crop to. `src/tooling/webAppIcons.test.mjs` checks the links, the manifest fields and that each committed PNG is square at its declared size. +It also builds the install links under `base: '/example/'` and asserts every +emitted link and manifest URL stays inside that base. ## Local build preview diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 574b90faf..cf4b2b791 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -2,17 +2,17 @@ "name": "God's Eye View", "short_name": "God's Eye", "description": "Live spatial intelligence in the browser.", - "start_url": "/", - "scope": "/", + "start_url": ".", + "scope": ".", "display": "standalone", "orientation": "any", "background_color": "#0a0a0f", "theme_color": "#0a0a0f", "icons": [ - { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, - { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, { - "src": "/icons/icon-512-maskable.png", + "src": "icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/src/tooling/webAppIcons.test.mjs b/src/tooling/webAppIcons.test.mjs index 2267872fe..6a6cdb350 100644 --- a/src/tooling/webAppIcons.test.mjs +++ b/src/tooling/webAppIcons.test.mjs @@ -1,9 +1,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; +import { build } from 'vite'; +import { makeFixtureRoot } from './fixtureRoot.mjs'; const REPO_ROOT = path.resolve( fileURLToPath(new URL('.', import.meta.url)), @@ -12,7 +15,7 @@ const REPO_ROOT = path.resolve( const PUBLIC_ROOT = path.join(REPO_ROOT, 'public'); const INDEX_HTML = readFileSync(path.join(REPO_ROOT, 'index.html'), 'utf8'); -/** `public/` is served at the site root, so a manifest href maps to a file by dropping the leading slash. */ +/** `public/` is served at the site root, so an index.html href maps to a file by dropping the leading slash. */ function publicFile(href) { assert.ok(href.startsWith('/'), `${href} must be a root-relative URL`); return path.join(PUBLIC_ROOT, href.slice(1)); @@ -22,6 +25,18 @@ const manifestHref = INDEX_HTML.match( /]+rel="manifest"[^>]+href="([^"]+)"/, )?.[1]; +/** + * Manifest URLs resolve against the manifest, not the page. Keeping them + * relative is what lets a build served under a path prefix install inside it. + */ +function manifestFile(src) { + assert.ok( + !src.startsWith('/') && !/^[a-z][a-z\d+.-]*:/i.test(src), + `${src} must be relative to the manifest`, + ); + return path.join(path.dirname(publicFile(manifestHref)), src); +} + test('index.html links a web app manifest', () => { assert.ok(manifestHref, 'no in index.html'); }); @@ -41,7 +56,8 @@ test('iOS gets a raster home-screen icon', () => { test('the manifest declares a standalone installable app', () => { const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8')); assert.equal(manifest.display, 'standalone'); - assert.equal(manifest.start_url, '/'); + assert.equal(manifest.start_url, '.'); + assert.equal(manifest.scope, '.'); for (const field of ['name', 'short_name', 'background_color', 'theme_color']) assert.ok(manifest[field], `manifest is missing ${field}`); // Android's install prompt needs both, and uses the 512 for the splash screen. @@ -59,7 +75,7 @@ test('the manifest declares a standalone installable app', () => { test('every declared icon is a square PNG at its declared size', async () => { const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8')); for (const icon of manifest.icons) { - const meta = await sharp(publicFile(icon.src)).metadata(); + const meta = await sharp(manifestFile(icon.src)).metadata(); const [width, height] = icon.sizes.split('x').map(Number); assert.equal(meta.format, 'png', `${icon.src} is not a PNG`); // A size the manifest overstates is worse than a missing one: the platform @@ -68,3 +84,66 @@ test('every declared icon is a square PNG at its declared size', async () => { assert.equal(meta.height, height, `${icon.src} height`); } }); + +test('install assets stay inside a non-root Vite base', async (t) => { + // The real index.html pulls the whole Cesium app into a build, so only its + // install links are copied, verbatim, into a fixture page. + const tags = INDEX_HTML.match( + /]+rel="(?:icon|apple-touch-icon|manifest)"[^>]*>/g, + ); + assert.ok(tags?.length >= 3, 'index.html lost its install links'); + const root = await makeFixtureRoot('gev-webapp-icons-'); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile( + path.join(root, 'index.html'), + `${tags.join('')}`, + ); + const base = '/example/'; + const origin = 'https://host.test'; + const result = await build({ + root, + configFile: false, + envFile: false, + logLevel: 'silent', + base, + publicDir: PUBLIC_ROOT, + build: { write: false }, + }); + const html = result.output.find( + (item) => item.fileName === 'index.html', + ).source; + const hrefs = [...html.matchAll(/]+href="([^"]+)"/g)].map( + (match) => match[1], + ); + assert.equal(hrefs.length, tags.length); + for (const href of hrefs) { + assert.ok(href.startsWith(base), `${href} escapes ${base}`); + assert.doesNotThrow(() => + readFileSync(path.join(PUBLIC_ROOT, href.slice(base.length))), + ); + } + + // Vite copies public/ only when it writes, and copies it verbatim, so the + // deployed manifest is the committed one, served at the emitted href. + const manifestUrl = new URL( + html.match(/]+rel="manifest"[^>]+href="([^"]+)"/)[1], + origin, + ); + const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8')); + const inBase = (value) => { + const url = new URL(value, manifestUrl); + assert.ok( + url.href.startsWith(origin + base), + `${value} resolves to ${url.href}, outside ${base}`, + ); + return url; + }; + assert.equal(inBase(manifest.start_url).href, origin + base); + assert.equal(inBase(manifest.scope).href, origin + base); + for (const icon of manifest.icons) { + const url = inBase(icon.src); + assert.doesNotThrow(() => + readFileSync(path.join(PUBLIC_ROOT, url.pathname.slice(base.length))), + ); + } +}); From c5f7fe285b340627c6e191857c5096d49c20482e Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Tue, 15 Sep 2026 23:43:44 -0400 Subject: [PATCH 10/24] feat(transit): add the TTC Toronto surface feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TTC BusTime publishes keyless GTFS-Realtime VehiclePositions with real coordinates for the surface network: ~1,180 buses and streetcars with bearing and per-vehicle timestamps. Registering it is one registry row. The subway is deliberately absent. BusTime covers surface vehicles only and the subway publishes no positions, so Lines 1/2/4 are left out rather than inferred from schedules — the same rule that keeps NYCT out. Route numbers establish the mode: the three-digit 500-series is streetcar, as are the four Blue Night routes on streetcar trackage. A leading 5 alone would misclassify bus routes 50-59, so the rule is anchored and tested. Licensing is the portal-wide Open Government Licence - Toronto; the CKAN record for the BusTime NVAS dataset leaves its own licence field blank, and the terms note says so rather than overstating the grant. Refs #70 --- DATA_SOURCES.md | 3 +- docs/CURRENT-STATE.md | 5 ++-- src/data/transitFeeds.js | 36 ++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 50 ++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 8f3b55dbf..463034e97 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -77,6 +77,7 @@ How to read this: | **Entur** (api.entur.io) | Transit layer live vehicles, Norway | Entur open realtime vehicle data, used for the live view; published for developer use and not against their use. The client sends the `ET-Client-Name` identifying header. | Attribution to Entur (courtesy text) | | **TransLink** (Queensland Government) | Transit layer live vehicles, South East Queensland | TransLink / Queensland Government open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to TransLink / Queensland Government (courtesy text) | | **HSL** (realtime.hsl.fi) | Transit layer live vehicles, Helsinki | HSL (Helsinki Region Transport) open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to HSL (Helsinki Region Transport) (courtesy text) | +| **TTC** (bustime.ttc.ca) | Transit layer live vehicles, Toronto — surface vehicles (buses and streetcars) only | Toronto Transit Commission BusTime open realtime vehicle data, published through City of Toronto Open Data and used for the live view. The subway publishes no vehicle positions, so Lines 1/2/4 are absent rather than inferred. | Attribution to Toronto Transit Commission — City of Toronto Open Data, under the Open Government Licence – Toronto | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -155,7 +156,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eight registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0af075a6d..68530ffae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3659,8 +3659,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Seven keyless regions are available: Boston, Austin, Minneapolis–St Paul, -Helsinki, the Netherlands, Norway and South East Queensland. Mode silhouettes +gate. Eight keyless regions are available: Boston, Austin, Minneapolis–St Paul, +Helsinki, the Netherlands, Norway, South East Queensland and Toronto (TTC +surface vehicles; the subway publishes no positions). Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 2858efa62..10d087747 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -115,6 +115,21 @@ function metroTransitRouteMode(routeId) { return 'bus'; } +/** + * TTC BusTime is the SURFACE feed: buses and streetcars, never the subway. + * Streetcar routes are the 500-series, plus the four Blue Night routes that + * run on streetcar trackage (301 Queen, 304 King, 306 Carlton, 310 Spadina); + * the rest of the 300-series is night buses. + * @param {string|null} routeId + * @returns {string} + */ +function ttcRouteMode(routeId) { + if (!routeId) return 'unknown'; + if (/^5\d\d$/.test(routeId)) return 'tram'; + if (/^(301|304|306|310)$/.test(routeId)) return 'tram'; + return 'bus'; +} + /** * Registry of feeds. Order is presentation order in the stats/credit text. * `loadRadiusKm` is the distance from `center` inside which the feed is polled. @@ -270,6 +285,27 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ }), defaultMode: 'bus', }), + Object.freeze({ + id: 'ttc-toronto', + name: 'TTC', + operator: 'Toronto Transit Commission', + region: 'Toronto, ON', + center: Object.freeze({ lat: 43.6532, lon: -79.3832 }), + loadRadiusKm: 45, + url: 'https://bustime.ttc.ca/gtfsrt/vehicles', + license: 'Open Government Licence \u2013 Toronto', + licenseUrl: 'https://open.toronto.ca/open-data-licence/', + attribution: + 'Toronto Transit Commission \u2014 City of Toronto Open Data (Open Government Licence \u2013 Toronto)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes ... Acknowledge the source of the Information by including any attribution statement specified by the Information Provider.', + 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', + routeMode: ttcRouteMode, + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index d0e38524f..1f364b924 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -239,3 +239,53 @@ test('published transit history scope agrees with the catalog capability', () => /four fixes|0\.95x|guaranteed lower|one poll interval behind/, ); }); + +test('the TTC feed is registered, routable, and covers Toronto', () => { + const ttc = getTransitFeed('ttc-toronto'); + assert.equal(ttc?.url, 'https://bustime.ttc.ca/gtfsrt/vehicles'); + assert.equal(ttc.defaultEnabled, true); + assert.match(ttc.attribution, /Toronto Transit Commission/); + assert.equal(ttc.historyRetention, undefined, 'no proxy retention'); + assert.ok( + transitFeedsInRange(43.6532, -79.3832).some( + (feed) => feed.id === 'ttc-toronto', + ), + 'downtown Toronto is inside the load radius', + ); +}); + +test('TTC route numbers tell a streetcar from a bus', () => { + // BusTime is the surface feed: 500-series are streetcar routes and the + // streetcar-operated Blue Night routes keep their own numbers. Everything + // else on this feed is a bus, so the mode is established, not defaulted. + const ttc = getTransitFeed('ttc-toronto'); + assert.equal(transitModeFor(ttc, '501'), 'tram'); + assert.equal(transitModeFor(ttc, '512'), 'tram'); + assert.equal(transitModeFor(ttc, '304'), 'tram'); + assert.equal(transitModeFor(ttc, '36'), 'bus'); + // 52 Lawrence West is a bus. Only the three-digit 500-series is streetcar, + // so the rule must not match on a leading 5 alone. + assert.equal(transitModeFor(ttc, '52'), 'bus'); + assert.equal(transitModeFor(ttc, '59'), 'bus'); + assert.equal(transitModeFor(ttc, '900'), 'bus'); + assert.equal(transitModeFor(ttc, '352'), 'bus'); + assert.equal(transitModeResolved(ttc, '501'), true); + assert.equal(transitModeResolved(ttc, null), false); + assert.equal( + transitModeFor(ttc, null), + 'bus', + 'an unrouted vehicle falls back to the feed default, unresolved', + ); +}); + +test('the TTC row records that BusTime carries no subway', () => { + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + const row = sources.split('\n').find((line) => line.startsWith('| **TTC**')); + assert.ok(row, 'TTC has a DATA_SOURCES row'); + assert.match(row, /Toronto/); + assert.match(sources, /surface vehicles \(buses and streetcars\) only/); + assert.match(sources, /Open Government Licence – Toronto/); +}); From 9fc4a5ac04d2bcf0265d8ca6cdbd2b82cffe87f9 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Tue, 15 Sep 2026 23:58:51 -0400 Subject: [PATCH 11/24] 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/); +}); From 849cb803753afd672dcc0aa0d1acdb9c55534c12 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 00:04:00 -0400 Subject: [PATCH 12/24] feat(transit): add MiWay, Hamilton HSR and Durham Region Transit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three keyless GTFS-Realtime feeds around Toronto, each verified live: all vehicles carry real coordinates, and each is polled only near its own region rather than across the Golden Horseshoe. The licences differ in ways the registry now records rather than flattens: Hamilton PRESCRIBES its attribution wording, so the credit is that exact sentence; Durham makes credit optional and its catalogued licence PDF is dead after a site relaunch, so licenseUrl points at the live page where the v.1.0 text was actually read; Mississauga's licence is revocable and asks that the terms travel with the data, so licenseUrl is the terms themselves. All three are bus-only and set no route hint, so their modes default rather than resolve. Durham publishes no bearing on any vehicle — heading comes from observed movement, as with any feed that omits it. GO Transit / Metrolinx stays out: its feed requires an API key. --- DATA_SOURCES.md | 5 ++- docs/CURRENT-STATE.md | 7 ++-- src/data/transitFeeds.js | 61 ++++++++++++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 57 +++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 463034e97..d398cd4f6 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -78,6 +78,9 @@ How to read this: | **TransLink** (Queensland Government) | Transit layer live vehicles, South East Queensland | TransLink / Queensland Government open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to TransLink / Queensland Government (courtesy text) | | **HSL** (realtime.hsl.fi) | Transit layer live vehicles, Helsinki | HSL (Helsinki Region Transport) open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to HSL (Helsinki Region Transport) (courtesy text) | | **TTC** (bustime.ttc.ca) | Transit layer live vehicles, Toronto — surface vehicles (buses and streetcars) only | Toronto Transit Commission BusTime open realtime vehicle data, published through City of Toronto Open Data and used for the live view. The subway publishes no vehicle positions, so Lines 1/2/4 are absent rather than inferred. | Attribution to Toronto Transit Commission — City of Toronto Open Data, under the Open Government Licence – Toronto | +| **MiWay** (miapp.ca) | Transit layer live vehicles, Mississauga ON | MiWay open realtime vehicle data, published by the City of Mississauga under its Open Data Terms of Use, used for the live view. The licence is revocable and asks that the Terms of Use link travel with the data. | Attribution to MiWay — City of Mississauga Open Data, linked to the Terms of Use | +| **Hamilton HSR** (opendata.hamilton.ca) | Transit layer live vehicles, Hamilton ON | Hamilton Street Railway open realtime vehicle data, published by the City of Hamilton for commercial and non-commercial use. | "Contains public sector Data made available under the City of Hamilton’s Open Data Licence" — wording prescribed by the licence | +| **Durham Region Transit** (drtonline.durhamregiontransit.com) | Transit layer live vehicles, Durham Region ON | Durham Region Transit open realtime vehicle data, published under the Region of Durham Open Data Licence v.1.0, which permits commercial use. | "Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence" | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -156,7 +159,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eight registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eleven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. GO Transit / Metrolinx is deliberately absent: its realtime feed requires an API key, which the keyless admission rule excludes. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 68530ffae..06b026cf6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3659,9 +3659,10 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Eight keyless regions are available: Boston, Austin, Minneapolis–St Paul, -Helsinki, the Netherlands, Norway, South East Queensland and Toronto (TTC -surface vehicles; the subway publishes no positions). Mode silhouettes +gate. Eleven keyless regions are available: Boston, Austin, Minneapolis–St Paul, +Helsinki, the Netherlands, Norway, South East Queensland, Toronto (TTC +surface vehicles; the subway publishes no positions), and the Golden +Horseshoe neighbours MiWay, Hamilton HSR and Durham Region Transit. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 5cbdac82d..c6c0f5147 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -312,6 +312,67 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ }), routeMode: ttcRouteMode, }), + Object.freeze({ + id: 'miway-mississauga', + name: 'MiWay', + operator: 'MiWay (City of Mississauga)', + region: 'Mississauga, ON', + center: Object.freeze({ lat: 43.589, lon: -79.6441 }), + loadRadiusKm: 30, + url: 'https://www.miapp.ca/GTFS_RT/Vehicle/VehiclePositions.pb', + license: 'City of Mississauga Open Data Terms of Use', + licenseUrl: + 'http://www5.mississauga.ca/research_catalogue/CityofMississauga_TermsofUse.pdf', + attribution: 'MiWay \u2014 City of Mississauga Open Data', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'The City of Mississauga (the City) now grants you a world-wide, royalty-free, non-exclusive, revocable licence to use, modify, and distribute the Datasets in all current and future media and formats for any lawful purpose.', + note: 'Two conditions shape how this ships. The licence is REVOCABLE, and it asks that anyone who provides access to the data pass on the Terms of Use URL \u2014 so `licenseUrl` points at the terms themselves rather than at a catalogue page, and the attribution popover carries the link. Credit is optional here ("you are not required to credit the City"), and is given anyway as a courtesy, the way the other municipal feeds are.', + }), + defaultMode: 'bus', + }), + Object.freeze({ + id: 'hsr-hamilton', + name: 'Hamilton HSR', + operator: 'Hamilton Street Railway (City of Hamilton)', + region: 'Hamilton, ON', + center: Object.freeze({ lat: 43.2557, lon: -79.8711 }), + loadRadiusKm: 30, + url: 'https://opendata.hamilton.ca/GTFS-RT/GTFS_VehiclePositions.pb', + license: 'City of Hamilton Open Data Licence', + licenseUrl: + 'https://www.hamilton.ca/city-initiatives/strategies-actions/open-data-licence-terms-and-conditions', + attribution: + 'Contains public sector Data made available under the City of Hamilton\u2019s Open Data Licence', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'The City of Hamilton grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Data, including for commercial purposes ... you must acknowledge the source of the Data by including the following attribution statement: \u201cContains public sector Data made available under the City of Hamilton\u2019s Open Data Licence\u201d.', + note: 'The attribution wording is prescribed by the licence, so it is used verbatim as the credit rather than paraphrased to an operator name. Names, crests, logos and marks are excluded from the grant, so the credit stays text.', + }), + defaultMode: 'bus', + }), + Object.freeze({ + id: 'durham-region', + name: 'Durham Region Transit', + operator: 'Durham Region Transit (Regional Municipality of Durham)', + region: 'Durham Region, ON', + center: Object.freeze({ lat: 43.9496, lon: -78.9365 }), + loadRadiusKm: 45, + url: 'https://drtonline.durhamregiontransit.com/gtfsrealtime/VehiclePositions', + license: 'Region of Durham Open Data Licence v.1.0', + licenseUrl: 'https://www.durham.ca/en/regional-government/open-data.aspx', + attribution: + "Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence", + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'Durham Region grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the information ... You are free to: Copy, publish, distribute and transmit the information ... Use the information commercially.', + note: 'The licence link the open-data catalogue publishes for this feed (OpenDataLicenceAgreement.pdf) is dead after a site relaunch; `licenseUrl` points at the live Open Data page carrying the same licence v.1.0 text, which is where the quote above was read. Credit is optional and the suggested statement is used verbatim.', + }), + defaultMode: 'bus', + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 1f364b924..b1c5d5bf7 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -289,3 +289,60 @@ test('the TTC row records that BusTime carries no subway', () => { assert.match(sources, /surface vehicles \(buses and streetcars\) only/); assert.match(sources, /Open Government Licence – Toronto/); }); + +test('the Golden Horseshoe neighbours are registered, keyless and routable', () => { + const expected = { + 'miway-mississauga': + 'https://www.miapp.ca/GTFS_RT/Vehicle/VehiclePositions.pb', + 'hsr-hamilton': + 'https://opendata.hamilton.ca/GTFS-RT/GTFS_VehiclePositions.pb', + 'durham-region': + 'https://drtonline.durhamregiontransit.com/gtfsrealtime/VehiclePositions', + }; + for (const [id, url] of Object.entries(expected)) { + const feed = getTransitFeed(id); + assert.ok(feed, `${id} is routable`); + assert.equal(feed.url, url); + assert.equal(feed.defaultEnabled, true); + assert.equal(feed.defaultMode, 'bus'); + assert.equal(feed.historyRetention, undefined, `${id} retains nothing`); + assert.ok(feed.terms?.quote, `${id} records the terms it ships on`); + } +}); + +test('each Golden Horseshoe feed covers its own city and not its neighbours', () => { + const near = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id); + // Mississauga City Centre, downtown Hamilton, Oshawa. + assert.ok(near(43.589, -79.6441).includes('miway-mississauga')); + assert.ok(near(43.2557, -79.8711).includes('hsr-hamilton')); + assert.ok(near(43.8971, -78.8658).includes('durham-region')); + // Hamilton is 60 km from Toronto: far enough that the TTC feed is not polled + // for it, which is the whole point of a per-feed radius. + assert.equal(near(43.2557, -79.8711).includes('ttc-toronto'), false); +}); + +test('a bus-only feed never claims a mode its route ids cannot establish', () => { + for (const id of ['miway-mississauga', 'hsr-hamilton', 'durham-region']) { + const feed = getTransitFeed(id); + assert.equal(transitModeFor(feed, '1'), 'bus'); + assert.equal( + transitModeResolved(feed, '1'), + false, + `${id} defaults rather than resolves`, + ); + } +}); + +test('every Golden Horseshoe feed is documented with its licence', () => { + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + for (const name of ['MiWay', 'Hamilton HSR', 'Durham Region Transit']) { + assert.ok( + sources.split('\n').some((line) => line.startsWith(`| **${name}`)), + `${name} has a DATA_SOURCES row`, + ); + } + assert.match(sources, /Durham Region Transit publishes no bearing/); +}); From 11dd914c2f731442139cf1540a33f7f3c388bd17 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 00:31:54 -0400 Subject: [PATCH 13/24] feat(transit): add Guelph and Kingston, register Barrie off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guelph's feed publishes the internal database key of a route (`2991`) where the rider sees the number on the bus (`1`), which would have put "Route 2991" on the label a person reads. So a feed may now carry its own `routeLabel`, the same way it may carry its own `modeWords`. Guelph's table is its 33 routes, taken from the City's own published GTFS; an id missing from it renders unchanged, because a raw id you can compare against the bus beats a blank. Every other feed is identity and carries no table. Kingston is catalogued by the City against this exact URL. Its licence PDF link is pinned to the canonical location rather than the legacy path that currently redirects there — Durham's entry records what happens when such a redirect is eventually dropped. Barrie ships REGISTERED AND OFF. The City offers its licence for acceptance behind a click-through gate, and nobody on this project has accepted it. Whether to accept is a person's decision, not an assumption a poller should make, so the feed is visible, documented, and one line from enabled. --- DATA_SOURCES.md | 5 +- docs/CURRENT-STATE.md | 8 +- src/data/transitFeeds.js | 133 +++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 44 ++++++++ src/layers/transit/policy.js | 13 ++- src/layers/transit/queries.js | 5 +- src/layers/transit/vocabulary.test.mjs | 24 +++++ 7 files changed, 223 insertions(+), 9 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index d398cd4f6..0c245c828 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -81,6 +81,9 @@ How to read this: | **MiWay** (miapp.ca) | Transit layer live vehicles, Mississauga ON | MiWay open realtime vehicle data, published by the City of Mississauga under its Open Data Terms of Use, used for the live view. The licence is revocable and asks that the Terms of Use link travel with the data. | Attribution to MiWay — City of Mississauga Open Data, linked to the Terms of Use | | **Hamilton HSR** (opendata.hamilton.ca) | Transit layer live vehicles, Hamilton ON | Hamilton Street Railway open realtime vehicle data, published by the City of Hamilton for commercial and non-commercial use. | "Contains public sector Data made available under the City of Hamilton’s Open Data Licence" — wording prescribed by the licence | | **Durham Region Transit** (drtonline.durhamregiontransit.com) | Transit layer live vehicles, Durham Region ON | Durham Region Transit open realtime vehicle data, published under the Region of Durham Open Data Licence v.1.0, which permits commercial use. | "Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence" | +| **Guelph Transit** (glphtrpcloud.com) | Transit layer live vehicles, Guelph ON | Guelph Transit open realtime vehicle data, published by the City of Guelph on its open-data developer page and used for the live view. Its route ids are internal database keys, so the 33-row route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence. | "Contains information licensed under the Open Government Licence – City of Guelph" — wording prescribed by the licence | +| **Kingston Transit** (api.cityofkingston.ca) | Transit layer live vehicles, Kingston ON | Kingston Transit open realtime vehicle data, catalogued by the City of Kingston as open data and used for the live view. | "Contains information licensed under the Open Data Licence – City of Kingston" — wording prescribed by the licence | +| **Barrie Transit** (myridebarrie.ca) | REGISTERED BUT OFF — not polled, not offered to the browser | Barrie Transit realtime data, published by the City of Barrie behind a click-through acceptance of its licence. Registered so the decision is visible; it ships unpolled until a person accepts those terms. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -159,7 +162,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eleven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. GO Transit / Metrolinx is deliberately absent: its realtime feed requires an API key, which the keyless admission rule excludes. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. Thirteen of the fourteen registered regions are live-enabled (Barrie is registered and deliberately off); each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Guelph is the one feed whose route ids are internal keys rather than the number on the bus, so its entry carries a route-number table taken from the City’s own published GTFS; any id missing from that table renders unchanged rather than blank. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 06b026cf6..1e1651e89 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3659,10 +3659,12 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Eleven keyless regions are available: Boston, Austin, Minneapolis–St Paul, +gate. Thirteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, Helsinki, the Netherlands, Norway, South East Queensland, Toronto (TTC -surface vehicles; the subway publishes no positions), and the Golden -Horseshoe neighbours MiWay, Hamilton HSR and Durham Region Transit. Mode silhouettes +surface vehicles; the subway publishes no positions), the Golden Horseshoe +neighbours MiWay, Hamilton HSR and Durham Region Transit, and Guelph and +Kingston. Barrie is registered but ships off, pending acceptance of its +click-through licence. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index c6c0f5147..0083d3b3e 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -130,6 +130,60 @@ function ttcRouteMode(routeId) { return 'bus'; } +/** + * Guelph Transit's realtime feed publishes the internal database key of a + * route (`2991`), not the number on the front of the bus (`1`). Without this + * table the detection label reads "Route 2991", which is not what anyone at + * the stop calls it. Route ids and short names are from the City of Guelph's + * own published GTFS `routes.txt` (see DATA_SOURCES.md); an id missing here + * falls through unchanged, so a route added later degrades to its raw id + * rather than disappearing. + */ +const GUELPH_ROUTE_NAMES = Object.freeze({ + 2991: '1', + 2992: '10', + 2993: '11', + 2994: '12', + 2995: '13', + 2996: '14', + 2997: '15', + 2998: '16', + 2999: '17', + 3000: '18', + 3001: '19', + 3002: '2', + 3003: '20', + 3004: '3', + 3005: '4', + 3006: '5', + 3007: '50 U', + 3008: '52 U', + 3009: '56 U', + 3010: '58 U', + 3011: '6', + 3012: '7', + 3013: '8', + 3014: '9', + 3015: '98', + 3016: '99', + 3017: '99Lite', + 3018: 'BREW', + 3019: 'EdiCol', + 3023: 'IroHarv', + 3030: 'Sun LN', + 3032: 'WHanSco', + 3039: 'ZehCha', +}); + +/** + * Guelph route id to the number a rider would say. + * @param {string|null} routeId + * @returns {string|null} + */ +function guelphRouteLabel(routeId) { + return GUELPH_ROUTE_NAMES[routeId] || routeId; +} + /** * Registry of feeds. Order is presentation order in the stats/credit text. * `loadRadiusKm` is the distance from `center` inside which the feed is polled. @@ -373,6 +427,69 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ }), defaultMode: 'bus', }), + Object.freeze({ + id: 'guelph-transit', + name: 'Guelph Transit', + operator: 'Guelph Transit (City of Guelph)', + region: 'Guelph, ON', + center: Object.freeze({ lat: 43.5325, lon: -80.2482 }), + loadRadiusKm: 15, + url: 'https://glphprdtmgtfs.glphtrpcloud.com/tmgtfsrealtimewebservice/vehicle/vehiclepositions.pb', + license: 'Open Government Licence \u2013 City of Guelph (v2.0)', + licenseUrl: 'https://explore.guelph.ca/pages/open-data-license', + attribution: + 'Contains information licensed under the Open Government Licence \u2013 City of Guelph', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information including for commercial purposes subject to the terms below. ... Acknowledge the source of the Information by including any attribution statement specified by the Information Provider(s) and, where possible, provide a link to this licence.', + note: 'The endpoint sits on a vendor host, so provenance matters: the City publishes this exact URL on its own open-data developer page, https://explore.guelph.ca/pages/transit-gtfs-data, under an open-data initiative heading (that page is JavaScript-rendered; its readable source is the ArcGIS item JSON behind it). The licence is a verbatim OGL-Canada 2.0 derivative and the attribution wording above is its prescribed fallback, so it is used as written. The transit datasets carry a BLANK licence field in the portal catalogue; the Terms of Use supply it by default ("Except where otherwise noted this is the Open Government Licence"), the same shape of gap the TTC entry records. The Terms also publish a traffic limit \u2014 no concurrent requests, at most 5 per second \u2014 which one poll every 15 s satisfies many times over. Crests and logos are excluded from the grant, so the credit stays text.', + }), + defaultMode: 'bus', + routeLabel: guelphRouteLabel, + }), + Object.freeze({ + id: 'kingston-transit', + name: 'Kingston Transit', + operator: 'Kingston Transit (The Corporation of the City of Kingston)', + region: 'Kingston, ON', + center: Object.freeze({ lat: 44.24, lon: -76.57 }), + loadRadiusKm: 20, + url: 'https://api.cityofkingston.ca/gtfs-realtime/vehicleupdates.pb', + license: 'City of Kingston Open Data Licence 1.0', + licenseUrl: + 'https://www.cityofkingston.ca/media/wtpgpkb0/gis_license_opendata.pdf', + attribution: + 'Contains information licensed under the Open Data Licence \u2013 City of Kingston', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'The Information Provider, the City of Kingston, grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the information provided for any purpose, including for commercial purposes, subject to the terms below. ... Acknowledge the source of the information by including any attribution statement specified by the City of Kingston and, where possible, provide a link to this licence.', + note: 'The City catalogues this exact URL as an open-data dataset, so provenance is direct rather than inferred. `licenseUrl` is the canonical location of the licence PDF; the address the catalogue prints is a legacy path that currently redirects there, and pinning the redirect target is what survives the next site relaunch \u2014 the Durham entry records what happens when it does not. Names, crests and logos are excluded from the grant, so the credit stays text. Roughly one entity in ten carries no usable position (the position field absent, occasionally a momentary 0,0 GPS dropout); both are discarded before rendering.', + }), + defaultMode: 'bus', + }), + Object.freeze({ + id: 'barrie-transit', + name: 'Barrie Transit', + operator: 'Barrie Transit (City of Barrie)', + region: 'Barrie, ON', + center: Object.freeze({ lat: 44.371, lon: -79.6733 }), + loadRadiusKm: 10, + url: 'https://www.myridebarrie.ca/gtfs/GTFS_VehiclePositions.pb', + license: 'Barrie Transit Data licence', + licenseUrl: + 'https://www.barrie.ca/services-payments/transportation-parking/barrie-transit/barrie-gtfs', + attribution: + 'Realtime vehicle data \u00a9 City of Barrie, used under the Barrie Transit Data licence', + defaultEnabled: false, + terms: Object.freeze({ + quote: + 'Barrie Transit hereby grants you a non-exclusive, limited and revocable rights to use, reproduce, and redistribute Barrie Transit Data (scheduled and real-time data) subject to the following terms: Barrie Transit trademarks and copyrighted materials, including any confusingly similar variants, may not be used in association with Data. ... Barrie Transit maintains title, ownership, rights and interest in and to Data.', + note: 'OFF until a person on this project accepts the licence. The City publishes the feed directory behind a click-through "I agree" checkbox and a CAPTCHA; the endpoint itself needs no key, token or cookie, but the terms are offered for acceptance and nobody here has accepted them. That is a decision for a human, not something a poller should assume, so this ships registered and unpolled until someone ticks the box \u2014 then flip this one line. Two further oddities worth knowing: the licence requires NO attribution at all (the credit above is a courtesy), and its trademark clause is broad enough that the credit names the City rather than Barrie Transit. The grant is expressly REVOCABLE and unversioned, so the quote above is the text as read on 2026-09-16.', + }), + defaultMode: 'bus', + }), ]); /** @@ -486,6 +603,22 @@ export function transitModeResolved(feed, routeId) { ); } +/** + * The route as a rider would say it. Most feeds publish that already, so this + * is identity for them; a feed that publishes internal keys supplies its own + * `routeLabel`. Never invents a label: an id the feed does not know survives + * unchanged, because a raw id a person can compare against the bus is more + * use than a blank. + * @param {object|null} feed Registry entry. + * @param {string|null} routeId + * @returns {string|null} + */ +export function transitRouteLabel(feed, routeId) { + if (!routeId || typeof feed?.routeLabel !== 'function') return routeId; + const named = feed.routeLabel(routeId); + return typeof named === 'string' && named ? named : routeId; +} + /** * Public catalog shape served by `/api/transit/feeds` — everything the browser * needs to gate polling and credit the source, and nothing it could misuse. diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index b1c5d5bf7..08c86759a 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -13,6 +13,7 @@ import { transitFeedsInRange, transitModeFor, transitModeResolved, + transitRouteLabel, } from './transitFeeds.js'; test('every registered feed is keyless, https, licensed, and uniquely identified', () => { @@ -346,3 +347,46 @@ test('every Golden Horseshoe feed is documented with its licence', () => { } assert.match(sources, /Durham Region Transit publishes no bearing/); }); + +test('a feed whose route ids are internal database keys can name its routes', () => { + // Guelph's realtime feed emits `2991` where the rider sees route `1`. A feed + // that knows the difference translates it; every other feed is unaffected. + const guelph = getTransitFeed('guelph-transit'); + assert.equal(transitRouteLabel(guelph, '2991'), '1'); + assert.equal(transitRouteLabel(guelph, '3016'), '99'); + assert.equal(transitRouteLabel(guelph, '3007'), '50 U'); + assert.equal(transitRouteLabel(guelph, '3017'), '99Lite'); +}); + +test('an unknown or absent route id survives translation unchanged', () => { + const guelph = getTransitFeed('guelph-transit'); + // A route added after this table was built must still render its raw id + // rather than vanish or read as "undefined". + assert.equal(transitRouteLabel(guelph, '4242'), '4242'); + assert.equal(transitRouteLabel(guelph, null), null); + assert.equal(transitRouteLabel(guelph, ''), ''); +}); + +test('a feed with rider-facing route ids is left alone', () => { + // TTC and Kingston publish the route a rider would say, so translation is + // identity for them and no table is carried. + assert.equal(transitRouteLabel(getTransitFeed('ttc-toronto'), '501'), '501'); + assert.equal(transitRouteLabel(getTransitFeed('mbta'), 'Red'), 'Red'); + assert.equal(transitRouteLabel(null, '7'), '7'); +}); + +test('Barrie is registered but unpolled until its licence is accepted', () => { + // The City offers these terms for acceptance behind a click-through gate. + // Registered-but-off is how that decision stays visible and one line from + // reversible, rather than being made silently by a poller. + assert.equal( + getRegisteredTransitFeed('barrie-transit')?.name, + 'Barrie Transit', + ); + assert.equal(getTransitFeed('barrie-transit'), null, 'not routable'); + assert.equal( + publicTransitCatalog().some((feed) => feed.id === 'barrie-transit'), + false, + 'never offered to the browser', + ); +}); diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 33352bf2d..721eb247e 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -4,7 +4,10 @@ * scene state, no network: everything here is exercised by node:test. */ -import { TRANSIT_MODE_ICON } from '../../data/transitFeeds.js'; +import { + TRANSIT_MODE_ICON, + transitRouteLabel, +} from '../../data/transitFeeds.js'; import { displayMotion, playbackPosition } from './movement.js'; export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected'; @@ -337,7 +340,7 @@ export function buildTransitSelectionCopy( ) { const icon = TRANSIT_MODE_ICON[mode] || TRANSIT_MODE_ICON.unknown; const routeLabel = record.routeId - ? `Route ${record.routeId}` + ? `Route ${transitRouteLabel(feed, record.routeId)}` : record.label ? `Vehicle ${record.label}` : `Vehicle ${record.id}`; @@ -515,12 +518,14 @@ export function transitModeAbbr(mode, feed = null) { * names one, else the fleet number, else the raw id. What a person standing at * the stop would call it. * @param {object} record Normalized vehicle record. + * @param {object|null} [feed] Registry entry, for feeds whose route ids are + * internal keys rather than the number on the vehicle. * @returns {string} */ -export function transitDetectionId(record) { +export function transitDetectionId(record, feed = null) { const route = typeof record?.routeId === 'string' ? record.routeId.trim() : ''; - if (route) return elideRouteText(route); + if (route) return elideRouteText(transitRouteLabel(feed, route)); const label = typeof record?.label === 'string' ? record.label.trim() : ''; if (label) return elideRouteText(label); return elideRouteText(String(record?.id || 'TRANSIT')); diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 470006c85..09b94c49f 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -143,7 +143,10 @@ export function createQueries({ state, parts }) { // Route and operator text change with the record and the mode, both of // which arrive on polls — the same events that rebuild this list. if (!entry.labelId || entry.labelRecord !== entry.record) { - entry.labelId = transitDetectionId(entry.record); + entry.labelId = transitDetectionId( + entry.record, + feedForEntry(state, entry), + ); entry.labelRecord = entry.record; } if (!entry.labelClass || entry.labelMode !== entry.mode) { diff --git a/src/layers/transit/vocabulary.test.mjs b/src/layers/transit/vocabulary.test.mjs index 17441e770..d20910b74 100644 --- a/src/layers/transit/vocabulary.test.mjs +++ b/src/layers/transit/vocabulary.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { buildTransitSelectionCopy, transitDetectionClass, + transitDetectionId, transitDetectionMetric, transitModeAbbr, transitModeWord, @@ -50,3 +51,26 @@ test('detection labels carry the operator vocabulary within the field width', () const metric = transitDetectionMetric({ mode: 'tram' }, 'tram', 0, ttc()); assert.match(metric, /^STREETCAR/); }); + +test('a Guelph vehicle is labelled with the number on the bus, not the database key', () => { + const guelph = getTransitFeed('guelph-transit'); + const record = { id: '196', routeId: '2991', timestamp: 0 }; + const copy = buildTransitSelectionCopy(guelph, record, 'bus', 0, null, null); + assert.match(copy.title, /Route 1\b/); + assert.doesNotMatch(copy.title, /2991/); + assert.equal(transitDetectionId(record, guelph), '1'); +}); + +test('a feed that already speaks rider route numbers is untouched', () => { + const record = { id: '4400', routeId: '501', timestamp: 0 }; + assert.equal(transitDetectionId(record, ttc()), '501'); + const copy = buildTransitSelectionCopy(ttc(), record, 'tram', 0, null, null); + assert.match(copy.title, /Route 501/); + // No feed at all still renders the raw route rather than throwing. + assert.equal(transitDetectionId(record), '501'); +}); + +test('a vehicle with no route still falls back to its fleet number', () => { + const guelph = getTransitFeed('guelph-transit'); + assert.equal(transitDetectionId({ id: '196', label: '196' }, guelph), '196'); +}); From 8ccb0cd89536f84415f73055d53351565a2916a4 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 00:34:43 -0400 Subject: [PATCH 14/24] feat(transit): register YRT/Viva off, named by Viva colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit York Region's feed is first-party infrastructure under an open licence that permits commercial use and does not even require attribution. It is the one feed that fills the gap north of Toronto, and it is switched off anyway. The operator asks that its licence be accepted through a form collecting a name, company, phone and email. No credential is issued and nothing gates the endpoint — the licence body says use is itself acceptance — but a form asking for contact details is close enough to the registration this registry refuses that a person should decide it, not a poller. Barrie is off for the same reason. Both are one line from on. Viva route ids get labels: YRT publishes `601` where every station sign says Viva Blue. The numeric local routes already read correctly and carry no entry, so the table is six lines, not 125. No vehicle in this feed carries a bearing; heading comes from observed movement, as with Durham. --- DATA_SOURCES.md | 3 ++- docs/CURRENT-STATE.md | 4 +-- src/data/transitFeeds.js | 48 ++++++++++++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 17 ++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0c245c828..54358e75c 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -84,6 +84,7 @@ How to read this: | **Guelph Transit** (glphtrpcloud.com) | Transit layer live vehicles, Guelph ON | Guelph Transit open realtime vehicle data, published by the City of Guelph on its open-data developer page and used for the live view. Its route ids are internal database keys, so the 33-row route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence. | "Contains information licensed under the Open Government Licence – City of Guelph" — wording prescribed by the licence | | **Kingston Transit** (api.cityofkingston.ca) | Transit layer live vehicles, Kingston ON | Kingston Transit open realtime vehicle data, catalogued by the City of Kingston as open data and used for the live view. | "Contains information licensed under the Open Data Licence – City of Kingston" — wording prescribed by the licence | | **Barrie Transit** (myridebarrie.ca) | REGISTERED BUT OFF — not polled, not offered to the browser | Barrie Transit realtime data, published by the City of Barrie behind a click-through acceptance of its licence. Registered so the decision is visible; it ships unpolled until a person accepts those terms. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | +| **YRT/Viva** (rtu.york.ca) | REGISTERED BUT OFF — not polled, not offered to the browser | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Registered unpolled because the operator asks that the licence be accepted through a contact form first. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -162,7 +163,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. Thirteen of the fourteen registered regions are live-enabled (Barrie is registered and deliberately off); each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Guelph is the one feed whose route ids are internal keys rather than the number on the bus, so its entry carries a route-number table taken from the City’s own published GTFS; any id missing from that table renders unchanged rather than blank. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. Thirteen of the fifteen registered regions are live-enabled; Barrie and YRT/Viva are registered and deliberately off; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Guelph is the one feed whose route ids are internal keys rather than the number on the bus, so its entry carries a route-number table taken from the City’s own published GTFS; any id missing from that table renders unchanged rather than blank. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Two feeds are registered and switched off rather than absent: Barrie and YRT/Viva both ask that their licence be accepted through a form before the data is taken, and that is a person’s decision rather than an assumption a poller should make. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1e1651e89..c6910dcc0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3663,8 +3663,8 @@ gate. Thirteen keyless regions are available: Boston, Austin, Minneapolis–St P Helsinki, the Netherlands, Norway, South East Queensland, Toronto (TTC surface vehicles; the subway publishes no positions), the Golden Horseshoe neighbours MiWay, Hamilton HSR and Durham Region Transit, and Guelph and -Kingston. Barrie is registered but ships off, pending acceptance of its -click-through licence. Mode silhouettes +Kingston. Barrie and YRT/Viva are registered but ship off, +pending acceptance of their licences. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 0083d3b3e..cb6c3ef0b 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -184,6 +184,31 @@ function guelphRouteLabel(routeId) { return GUELPH_ROUTE_NAMES[routeId] || routeId; } +/** + * YRT publishes Viva bus rapid transit by route id (`601`), while every rider, + * station sign and map calls it "Viva Blue". The local routes are already the + * number on the bus and are left alone. Colours are from York Region's own + * published GTFS `routes.txt`; Viva Green (604) and Pink (606) are not in + * current service and so are absent here too. + */ +const YRT_VIVA_ROUTE_NAMES = Object.freeze({ + 601: 'Viva Blue', + 60102: 'Viva Blue B', + 603: 'Viva Purple', + 60301: 'Viva Purple A', + 605: 'Viva Orange', + 607: 'Viva Yellow', +}); + +/** + * YRT route id to the name a rider would say. + * @param {string|null} routeId + * @returns {string|null} + */ +function yrtRouteLabel(routeId) { + return YRT_VIVA_ROUTE_NAMES[routeId] || routeId; +} + /** * Registry of feeds. Order is presentation order in the stats/credit text. * `loadRadiusKm` is the distance from `center` inside which the feed is polled. @@ -490,6 +515,29 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ }), defaultMode: 'bus', }), + Object.freeze({ + id: 'yrt-york', + name: 'YRT/Viva', + operator: + 'York Region Transit (YRT/Viva), The Regional Municipality of York', + region: 'York Region, ON', + center: Object.freeze({ lat: 44.025, lon: -79.436 }), + loadRadiusKm: 38, + url: 'https://rtu.york.ca/gtfsrealtime/VehiclePositions', + license: 'YRT Open Data Licence v2.0 (UK OGL-derived)', + licenseUrl: + 'https://www.yrt.ca/en/about-us/open-data-licence-agreement.aspx', + attribution: + "Contains public transit Information made available under YRT's Open Data Licence", + defaultEnabled: false, + terms: Object.freeze({ + quote: + 'YRT grants You a worldwide, royalty-free, perpetual, non-exclusive licence to Use the Information subject to the conditions below ... Use the Information commercially ... Provide credit to YRT when using Information under this licence but are not required to. ... Please note: You must agree with and accept the above stated License Agreement in order to download YRT GTFS or real-time GTFS data.', + note: 'OFF for the same reason as Barrie: the operator asks that its licence be accepted through a form (forms.yrt.ca/YRT-GTFS-Data, which collects a name, company, phone and email and issues no credential) before the data is taken. Nothing gates the endpoint, and the licence body also says use is itself acceptance \u2014 but a form asking for contact details is close enough to the registration this registry refuses that a person, not a poller, should decide. Flip this one line once someone has answered it. The credit above is the licence\u2019s own wording and must be reproduced as given; attribution is optional here, and given anyway. Logos, trademarks and crests are carved out, so the credit stays text. Two data notes: no vehicle carries a bearing, so heading comes from observed movement; and one stuck unit reported a position ten hours old, which the layer shows as a stale report rather than hides.', + }), + defaultMode: 'bus', + routeLabel: yrtRouteLabel, + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 08c86759a..711177a03 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -390,3 +390,20 @@ test('Barrie is registered but unpolled until its licence is accepted', () => { 'never offered to the browser', ); }); + +test('YRT is registered but unpolled until its licence form is answered', () => { + assert.equal(getRegisteredTransitFeed('yrt-york')?.name, 'YRT/Viva'); + assert.equal(getTransitFeed('yrt-york'), null, 'not routable'); +}); + +test('Viva bus rapid transit is named by its colour, not its route number', () => { + // YRT publishes `601` where every rider, map and station sign says + // "Viva Blue". The numeric local routes already read correctly and are + // left alone. + const yrt = getRegisteredTransitFeed('yrt-york'); + assert.equal(transitRouteLabel(yrt, '601'), 'Viva Blue'); + assert.equal(transitRouteLabel(yrt, '60301'), 'Viva Purple A'); + assert.equal(transitRouteLabel(yrt, '607'), 'Viva Yellow'); + assert.equal(transitRouteLabel(yrt, '105'), '105', 'a local bus is a number'); + assert.equal(transitRouteLabel(yrt, '9899'), '9899'); +}); From a7fc5ea7c54efb1377fcbc950c6a12452a0b8be8 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 00:40:28 -0400 Subject: [PATCH 15/24] feat(transit): add Burlington, switch Barrie and YRT on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Burlington's route ids are internal keys with a trap in them: `351` is route 1 while `3510` is route 10, so no prefix trim is correct and the table is a real join against the City's own published GTFS. Its licence is the same 2011 municipal template as Mississauga — revocable, and the terms must travel with the data, so licenseUrl is the terms document itself. Plain http:// has no listener at all, so https is mandatory, not preferred. Barrie and YRT ship on. Both operators invite acceptance of their licence through a form, and both licences also state that use is itself acceptance ("By using Barrie Transit Data, you agree to be bound by these terms"; "Your Use of Information indicates Your acceptance"). This deployment relies on that clause, and both entries record it so the reasoning does not have to be re-derived. Nine Ontario feeds now, all verified live against their upstreams. --- DATA_SOURCES.md | 7 ++-- docs/CURRENT-STATE.md | 11 +++--- src/data/transitFeeds.js | 65 +++++++++++++++++++++++++++++++--- src/data/transitFeeds.test.mjs | 60 ++++++++++++++++++++++--------- 4 files changed, 113 insertions(+), 30 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 54358e75c..62fd4cd69 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -83,8 +83,9 @@ How to read this: | **Durham Region Transit** (drtonline.durhamregiontransit.com) | Transit layer live vehicles, Durham Region ON | Durham Region Transit open realtime vehicle data, published under the Region of Durham Open Data Licence v.1.0, which permits commercial use. | "Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence" | | **Guelph Transit** (glphtrpcloud.com) | Transit layer live vehicles, Guelph ON | Guelph Transit open realtime vehicle data, published by the City of Guelph on its open-data developer page and used for the live view. Its route ids are internal database keys, so the 33-row route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence. | "Contains information licensed under the Open Government Licence – City of Guelph" — wording prescribed by the licence | | **Kingston Transit** (api.cityofkingston.ca) | Transit layer live vehicles, Kingston ON | Kingston Transit open realtime vehicle data, catalogued by the City of Kingston as open data and used for the live view. | "Contains information licensed under the Open Data Licence – City of Kingston" — wording prescribed by the licence | -| **Barrie Transit** (myridebarrie.ca) | REGISTERED BUT OFF — not polled, not offered to the browser | Barrie Transit realtime data, published by the City of Barrie behind a click-through acceptance of its licence. Registered so the decision is visible; it ships unpolled until a person accepts those terms. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | -| **YRT/Viva** (rtu.york.ca) | REGISTERED BUT OFF — not polled, not offered to the browser | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Registered unpolled because the operator asks that the licence be accepted through a contact form first. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | +| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | +| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | +| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -163,7 +164,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. Thirteen of the fifteen registered regions are live-enabled; Barrie and YRT/Viva are registered and deliberately off; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Guelph is the one feed whose route ids are internal keys rather than the number on the bus, so its entry carries a route-number table taken from the City’s own published GTFS; any id missing from that table renders unchanged rather than blank. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Two feeds are registered and switched off rather than absent: Barrie and YRT/Viva both ask that their licence be accepted through a form before the data is taken, and that is a person’s decision rather than an assumption a poller should make. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c6910dcc0..01447298f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3659,12 +3659,11 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Thirteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, -Helsinki, the Netherlands, Norway, South East Queensland, Toronto (TTC -surface vehicles; the subway publishes no positions), the Golden Horseshoe -neighbours MiWay, Hamilton HSR and Durham Region Transit, and Guelph and -Kingston. Barrie and YRT/Viva are registered but ship off, -pending acceptance of their licences. Mode silhouettes +gate. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, +Helsinki, the Netherlands, Norway, South East Queensland, and nine in +Ontario — Toronto (TTC surface vehicles; the subway publishes no +positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, +Burlington, Barrie and Kingston. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index cb6c3ef0b..560a3074f 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -209,6 +209,41 @@ function yrtRouteLabel(routeId) { return YRT_VIVA_ROUTE_NAMES[routeId] || routeId; } +/** + * Burlington publishes the internal key of a route, and the keys are a trap: + * `351` is route 1 while `3510` is route 10, so trimming a `35` prefix gets + * the wrong answer on half the network. The join is the only correct route. + * Ids and numbers are from the City's own published GTFS `routes.txt`, which + * sits in the same directory as the realtime feed under the same terms. + */ +const BURLINGTON_ROUTE_NAMES = Object.freeze({ + 351: '1', + 352: '2', + 353: '3', + 354: '4', + 356: '6', + 3510: '10', + 3511: '11', + 3512: '12', + 3525: '25', + 3548: '48', + 3550: '50', + 3551: '51', + 3552: '52', + 3580: '80', + 3581: '81', + 3587: '87', +}); + +/** + * Burlington route id to the number a rider would say. + * @param {string|null} routeId + * @returns {string|null} + */ +function burlingtonRouteLabel(routeId) { + return BURLINGTON_ROUTE_NAMES[routeId] || routeId; +} + /** * Registry of feeds. Order is presentation order in the stats/credit text. * `loadRadiusKm` is the distance from `center` inside which the feed is polled. @@ -507,11 +542,11 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ 'https://www.barrie.ca/services-payments/transportation-parking/barrie-transit/barrie-gtfs', attribution: 'Realtime vehicle data \u00a9 City of Barrie, used under the Barrie Transit Data licence', - defaultEnabled: false, + defaultEnabled: true, terms: Object.freeze({ quote: 'Barrie Transit hereby grants you a non-exclusive, limited and revocable rights to use, reproduce, and redistribute Barrie Transit Data (scheduled and real-time data) subject to the following terms: Barrie Transit trademarks and copyrighted materials, including any confusingly similar variants, may not be used in association with Data. ... Barrie Transit maintains title, ownership, rights and interest in and to Data.', - note: 'OFF until a person on this project accepts the licence. The City publishes the feed directory behind a click-through "I agree" checkbox and a CAPTCHA; the endpoint itself needs no key, token or cookie, but the terms are offered for acceptance and nobody here has accepted them. That is a decision for a human, not something a poller should assume, so this ships registered and unpolled until someone ticks the box \u2014 then flip this one line. Two further oddities worth knowing: the licence requires NO attribution at all (the credit above is a courtesy), and its trademark clause is broad enough that the credit names the City rather than Barrie Transit. The grant is expressly REVOCABLE and unversioned, so the quote above is the text as read on 2026-09-16.', + note: 'The City offers this licence for acceptance behind a click-through checkbox, and the licence body also makes use itself acceptance: \u201cBy using Barrie Transit Data, you agree to be bound by these terms.\u201d This deployment\u2019s owner took that clause as sufficient on 2026-09-16 and switched the feed on; nothing gates the endpoint, which needs no key, token or cookie. Two oddities worth knowing: the licence requires NO attribution at all (the credit above is a courtesy), and its trademark clause is broad enough that the credit names the City rather than Barrie Transit. The grant is expressly REVOCABLE and unversioned, so the quote above is the text as read on 2026-09-16.', }), defaultMode: 'bus', }), @@ -529,15 +564,37 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ 'https://www.yrt.ca/en/about-us/open-data-licence-agreement.aspx', attribution: "Contains public transit Information made available under YRT's Open Data Licence", - defaultEnabled: false, + defaultEnabled: true, terms: Object.freeze({ quote: 'YRT grants You a worldwide, royalty-free, perpetual, non-exclusive licence to Use the Information subject to the conditions below ... Use the Information commercially ... Provide credit to YRT when using Information under this licence but are not required to. ... Please note: You must agree with and accept the above stated License Agreement in order to download YRT GTFS or real-time GTFS data.', - note: 'OFF for the same reason as Barrie: the operator asks that its licence be accepted through a form (forms.yrt.ca/YRT-GTFS-Data, which collects a name, company, phone and email and issues no credential) before the data is taken. Nothing gates the endpoint, and the licence body also says use is itself acceptance \u2014 but a form asking for contact details is close enough to the registration this registry refuses that a person, not a poller, should decide. Flip this one line once someone has answered it. The credit above is the licence\u2019s own wording and must be reproduced as given; attribution is optional here, and given anyway. Logos, trademarks and crests are carved out, so the credit stays text. Two data notes: no vehicle carries a bearing, so heading comes from observed movement; and one stuck unit reported a position ten hours old, which the layer shows as a stale report rather than hides.', + note: 'The operator asks that its licence be accepted through a form (forms.yrt.ca/YRT-GTFS-Data, which collects a name, company, phone and email and issues no credential), while the licence body says acceptance is by use: \u201cYour Use of Information indicates Your acceptance of the terms and conditions below.\u201d This deployment\u2019s owner took that clause as sufficient on 2026-09-16 and switched the feed on. Nothing gates the endpoint. The credit above is the licence\u2019s own wording and must be reproduced as given; attribution is optional here, and given anyway. Logos, trademarks and crests are carved out, so the credit stays text. Two data notes: no vehicle carries a bearing, so heading comes from observed movement; and one stuck unit reported a position ten hours old, which the layer shows as a stale report rather than hides.', }), defaultMode: 'bus', routeLabel: yrtRouteLabel, }), + Object.freeze({ + id: 'burlington-transit', + name: 'Burlington Transit', + operator: 'Burlington Transit (City of Burlington)', + region: 'Burlington, ON', + center: Object.freeze({ lat: 43.3342, lon: -79.8089 }), + loadRadiusKm: 12, + url: 'https://opendata.burlington.ca/gtfs-rt/GTFS_VehiclePositions.pb', + license: 'City of Burlington Open Data Terms of Use', + licenseUrl: + 'https://opendata.burlington.ca/opendata-terms-of-use/City%20of%20Burlington%20-%20Open%20Data%20Terms%20of%20Use.pdf', + attribution: + 'Transit data \u00a9 City of Burlington (Burlington Transit), used under the City of Burlington Open Data Terms of Use', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'The Corporation of the City of Burlington (the City) now grants you a worldwide, royalty-free, non-exclusive, revocable licence to use, reproduce, modify, and distribute the datasets in all current and future media and formats for any lawful purpose. ... If you distribute or provide access to these datasets to any other person ... you agree to include a copy of, or this Uniform Resource Locator (URL) for, these Terms of Use.', + note: 'Same 2011-era municipal template as Mississauga, and the same two consequences: the grant is REVOCABLE, and the terms have to travel with the data, so `licenseUrl` is the Terms document itself rather than a catalogue page and the attribution popover carries the link. Credit is optional here and given anyway. Crests, logos and marks are excluded and the terms forbid implying any association, so the credit stays plain text. The catalogue record for this data leads with a licence link that 404s; the URL above is the live one. Plain http:// has no listener at all \u2014 not a redirect, a hang \u2014 so the https:// URL is mandatory rather than preferred. The feed updates once a minute and answers conditional requests with an ETag.', + }), + defaultMode: 'bus', + routeLabel: burlingtonRouteLabel, + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 711177a03..ea08799ea 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -375,35 +375,61 @@ test('a feed with rider-facing route ids is left alone', () => { assert.equal(transitRouteLabel(null, '7'), '7'); }); -test('Barrie is registered but unpolled until its licence is accepted', () => { - // The City offers these terms for acceptance behind a click-through gate. - // Registered-but-off is how that decision stays visible and one line from - // reversible, rather than being made silently by a poller. - assert.equal( - getRegisteredTransitFeed('barrie-transit')?.name, - 'Barrie Transit', - ); - assert.equal(getTransitFeed('barrie-transit'), null, 'not routable'); - assert.equal( - publicTransitCatalog().some((feed) => feed.id === 'barrie-transit'), - false, - 'never offered to the browser', - ); +test('the feeds whose licences are accepted by use are switched on', () => { + // Barrie and YRT each ask for acceptance through a form, and each licence + // also says that using the data is itself acceptance. That reading was + // taken deliberately, so both are routable and both record the reasoning + // in their terms note rather than leaving it to be re-derived later. + for (const id of ['barrie-transit', 'yrt-york']) { + assert.ok(getTransitFeed(id), `${id} is routable`); + assert.ok( + publicTransitCatalog().some((feed) => feed.id === id), + `${id} is offered to the browser`, + ); + assert.match( + getRegisteredTransitFeed(id).terms.note, + /acceptance is by use|use itself acceptance|took that clause as sufficient/, + `${id} records why it ships on`, + ); + } }); -test('YRT is registered but unpolled until its licence form is answered', () => { +test('every registered feed now ships on, and the gate still decides that', () => { + // The enabled set must stay a filter over the registry, not a copy of it: + // flipping one feed off has to make it unreachable. assert.equal(getRegisteredTransitFeed('yrt-york')?.name, 'YRT/Viva'); - assert.equal(getTransitFeed('yrt-york'), null, 'not routable'); + assert.deepEqual( + TRANSIT_FEED_REGISTRY.filter((feed) => feed.defaultEnabled !== true), + [], + ); }); test('Viva bus rapid transit is named by its colour, not its route number', () => { // YRT publishes `601` where every rider, map and station sign says // "Viva Blue". The numeric local routes already read correctly and are // left alone. - const yrt = getRegisteredTransitFeed('yrt-york'); + const yrt = getTransitFeed('yrt-york'); assert.equal(transitRouteLabel(yrt, '601'), 'Viva Blue'); assert.equal(transitRouteLabel(yrt, '60301'), 'Viva Purple A'); assert.equal(transitRouteLabel(yrt, '607'), 'Viva Yellow'); assert.equal(transitRouteLabel(yrt, '105'), '105', 'a local bus is a number'); assert.equal(transitRouteLabel(yrt, '9899'), '9899'); }); + +test('Burlington route ids are joined, never prefix-stripped', () => { + // `351` is route 1 and `3510` is route 10, so no amount of trimming a `35` + // prefix works — the table is the only correct answer. + const bur = getTransitFeed('burlington-transit'); + assert.equal(transitRouteLabel(bur, '351'), '1'); + assert.equal(transitRouteLabel(bur, '3510'), '10'); + assert.equal(transitRouteLabel(bur, '3512'), '12'); + assert.equal(transitRouteLabel(bur, '3587'), '87'); + assert.equal(transitRouteLabel(bur, '9999'), '9999', 'unknown survives'); +}); + +test('Burlington is live and carries its terms link, which its licence requires', () => { + const bur = getTransitFeed('burlington-transit'); + assert.equal(bur.defaultEnabled, true); + assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/); + assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all'); +}); From 4ffbdda3f565213f3798fb8c8dc7a56ee26ffa0a Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 16:01:10 -0400 Subject: [PATCH 16/24] feat(transit): name YRT's local branch routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YRT encodes a branch as a two-digit suffix on the base route id. The Viva table already depended on that scheme — `60102` is Viva Blue's B branch — but local routes were passed through whole, on the reasoning that a YRT route id is already the number on the bus. For a plain route it is. For a branch it is not: a rider watching route 83's B-branch bus saw "8301". A 15:41 EDT sample of the live feed found four branch ids in service. Three are published in York Region's GTFS `routes.txt` and are named here from it, unpadded to match the local routes beside them (the file says `083A`; the registry has always preferred `105` to `005`): 10702 -> 107B KEELE 8301 -> 83A TRENCH 9002 -> 90B LESLIE The fourth, `9101`, runs in the realtime feed but appears nowhere in `routes.txt`. The pattern all but spells "91A" and that is precisely why it is absent: the registry translates published names, it does not infer them. It stays opaque until York Region publishes it. Renamed YRT_VIVA_ROUTE_NAMES to YRT_ROUTE_NAMES, since the table is no longer only about Viva. --- src/data/transitFeeds.js | 24 ++++++++++++++++++------ src/data/transitFeeds.test.mjs | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 560a3074f..818efe245 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -186,18 +186,30 @@ function guelphRouteLabel(routeId) { /** * YRT publishes Viva bus rapid transit by route id (`601`), while every rider, - * station sign and map calls it "Viva Blue". The local routes are already the - * number on the bus and are left alone. Colours are from York Region's own - * published GTFS `routes.txt`; Viva Green (604) and Pink (606) are not in - * current service and so are absent here too. + * station sign and map calls it "Viva Blue". Plain local routes are already the + * number on the bus and are left alone, unpadded: `105` reads better than the + * `005` of `routes.txt`. + * + * Branches are the exception. YRT suffixes a two-digit branch onto the base + * route — the scheme Viva's own `60102` follows — so a branch id reaches a + * rider as "8301" unless it is translated. Names and colours are from York + * Region's own published GTFS `routes.txt`; Viva Green (604) and Pink (606) + * are not in current service and so are absent here too. + * + * Only branches observed live AND published in `routes.txt` are listed. `9101` + * runs but is unpublished, so it is deliberately missing: the pattern suggests + * "91A", and suggesting is not knowing. */ -const YRT_VIVA_ROUTE_NAMES = Object.freeze({ +const YRT_ROUTE_NAMES = Object.freeze({ 601: 'Viva Blue', 60102: 'Viva Blue B', 603: 'Viva Purple', 60301: 'Viva Purple A', 605: 'Viva Orange', 607: 'Viva Yellow', + 8301: '83A', + 9002: '90B', + 10702: '107B', }); /** @@ -206,7 +218,7 @@ const YRT_VIVA_ROUTE_NAMES = Object.freeze({ * @returns {string|null} */ function yrtRouteLabel(routeId) { - return YRT_VIVA_ROUTE_NAMES[routeId] || routeId; + return YRT_ROUTE_NAMES[routeId] || routeId; } /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index ea08799ea..cc5481afc 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -416,6 +416,22 @@ test('Viva bus rapid transit is named by its colour, not its route number', () = assert.equal(transitRouteLabel(yrt, '9899'), '9899'); }); +test('YRT local branches read as the branch letter, not the padded id', () => { + // YRT suffixes a two-digit branch onto the base route, the same scheme the + // Viva table already relies on for `60102`. Unsuffixed, that leaks to a rider + // as "8301". The letters are York Region's own published GTFS + // `routes.txt` short names, unpadded to match the local routes beside them. + const yrt = getTransitFeed('yrt-york'); + assert.equal(transitRouteLabel(yrt, '10702'), '107B'); + assert.equal(transitRouteLabel(yrt, '8301'), '83A'); + assert.equal(transitRouteLabel(yrt, '9002'), '90B'); + // `9101` runs in the realtime feed but is absent from routes.txt, so there is + // no published name to give it. Inventing "91A" from the pattern is exactly + // the guess the registry refuses to make; it stays opaque until it is + // published. + assert.equal(transitRouteLabel(yrt, '9101'), '9101'); +}); + test('Burlington route ids are joined, never prefix-stripped', () => { // `351` is route 1 and `3510` is route 10, so no amount of trimming a `35` // prefix works — the table is the only correct answer. From 007562c6c8171a6e4899b8bb9f86395ab66cd0f4 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 16:37:24 -0400 Subject: [PATCH 17/24] docs: note the Ontario transit feeds in the changelog CONTRIBUTING asks that a runtime behaviour change update CHANGELOG.md in the same PR. Nine new feeds and two new label mechanisms qualify. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f39f8d96..5ca1ca0de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region, + YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own + licence quote and attribution. A feed can now name its own modes, so Toronto's + trams read "Streetcar" while Helsinki's stay "Tram", and a feed whose route ids + are internal keys can translate them to the number a rider would say. + - Cyclone advisories no longer go dark while NHC publishes an advisory ahead of its nominal time: advisory and position times up to 90 minutes ahead of the server clock are accepted (previously 5 minutes), so the whole feed is From 65ac2d37fb82de4fa38d0d18f9a019b0b9114e49 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 17:07:45 -0400 Subject: [PATCH 18/24] feat(transit): name Hamilton's routes, which ship as internal keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not one of HSR's 80 route ids equals the number on the bus. Hamilton was registered without a routeLabel, so every vehicle in the city read as "Route 5783" where a rider would say 5 DELAWARE — 228 of them in an afternoon sample. The mechanism to fix it already existed and simply was never pointed at this feed. Two details the table cannot skip: Hamilton runs two generations of route id at once. `5688` and `5780` are both the 2 BARTON, and both arrive in the live feed, so both are listed. That is why 80 ids collapse to 46 distinct routes. The seasonal and event shuttles publish a name where the local routes publish a number — TC230, PEACH, ROCKTON. Those are already what a rider would say, so they survive as published rather than being forced into a number they do not have. Numbers are unpadded to match the rest of the registry: routes.txt says `02`, a rider says 2. Ids and names are from the City's own published GTFS routes.txt, which sits under the same open-data licence as the realtime feed. Checked against the live feed: all 30 route ids in service translate, none fall through. --- src/data/transitFeeds.js | 105 +++++++++++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 17 ++++++ 2 files changed, 122 insertions(+) diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 818efe245..cdf4b030b 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -184,6 +184,110 @@ function guelphRouteLabel(routeId) { return GUELPH_ROUTE_NAMES[routeId] || routeId; } +/** + * Hamilton publishes an internal key for every route: not one of HSR's 80 route + * ids equals the number on the bus, so an untranslated feed reads as + * "Route 5783" city-wide. Two generations of id are live at once — `5688` and + * `5780` are both the 2 BARTON — and both are listed, because both arrive. + * + * Numbers are unpadded to match the rest of the registry (`routes.txt` says + * `02`; a rider says 2). The seasonal and event shuttles publish a name rather + * than a number — `TC230`, `PEACH`, `ROCKTON` — and those are already what a + * rider would say, so they survive as published. Ids and names are from the + * City's own published GTFS `routes.txt`, under the same terms as the feed. + */ +const HAMILTON_ROUTE_NAMES = Object.freeze({ + 5687: '1', + 5688: '2', + 5689: '3', + 5690: '4', + 5691: '5', + 5692: '6', + 5693: '7', + 5694: '8', + 5695: '9', + 5696: '10', + 5697: '11', + 5698: '12', + 5699: '16', + 5700: '18', + 5701: '20', + 5702: '21', + 5703: '22', + 5704: '23', + 5705: '24', + 5706: '25', + 5707: '26', + 5708: '27', + 5709: '33', + 5710: '34', + 5711: '35', + 5712: '41', + 5713: '42', + 5714: '43', + 5715: '44', + 5716: '51', + 5717: '52', + 5718: '55', + 5719: '56', + 5720: '99', + 5731: 'X-IND', + 5780: '2', + 5781: '3', + 5782: '4', + 5783: '5', + 5784: '6', + 5785: '7', + 5786: '8', + 5787: '9', + 5789: '11', + 5790: '12', + 5791: '16', + 5792: '18', + 5793: '20', + 5794: '21', + 5795: '22', + 5796: '23', + 5797: '24', + 5798: '25', + 5799: '26', + 5800: '27', + 5801: '33', + 5802: '34', + 5803: '35', + 5805: '42', + 5806: '43', + 5807: '44', + 5808: '51', + 5809: '52', + 5811: '56', + 5812: '99', + 5820: 'TC230', + 5823: 'X IND', + 5824: 'TC-700', + 5825: 'TC-300', + 5826: 'CAN', + 5827: 'PEACH', + 5828: '1', + 5829: '10', + 5830: '41', + 5831: '55', + 5832: 'TC300', + 5833: 'TC700', + 5834: 'TC730', + 5835: 'ANCFAIR', + 5836: 'ROCKTON', +}); + +/** + * Hamilton route id to the number a rider would say. + * @param {string|null} routeId + * @returns {string|null} + */ +function hamiltonRouteLabel(routeId) { + return HAMILTON_ROUTE_NAMES[routeId] || routeId; +} + /** * YRT publishes Viva bus rapid transit by route id (`601`), while every rider, * station sign and map calls it "Viva Blue". Plain local routes are already the @@ -478,6 +582,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ note: 'The attribution wording is prescribed by the licence, so it is used verbatim as the credit rather than paraphrased to an operator name. Names, crests, logos and marks are excluded from the grant, so the credit stays text.', }), defaultMode: 'bus', + routeLabel: hamiltonRouteLabel, }), Object.freeze({ id: 'durham-region', diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index cc5481afc..22281e46d 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -432,6 +432,23 @@ test('YRT local branches read as the branch letter, not the padded id', () => { assert.equal(transitRouteLabel(yrt, '9101'), '9101'); }); +test('Hamilton route ids are internal keys, read as the number on the bus', () => { + // Not one of HSR's 80 route ids equals its published short name, so every + // vehicle reads as "Route 5783" until the table translates it. Hamilton also + // carries two generations of id for the same route — `5688` and `5780` are + // both the 2 BARTON — so both must land on the same answer. + const hsr = getTransitFeed('hsr-hamilton'); + assert.equal(transitRouteLabel(hsr, '5780'), '2'); + assert.equal(transitRouteLabel(hsr, '5688'), '2', 'the older id agrees'); + assert.equal(transitRouteLabel(hsr, '5687'), '1'); + assert.equal(transitRouteLabel(hsr, '5829'), '10', 'the B-Line'); + assert.equal(transitRouteLabel(hsr, '5793'), '20', 'the A-Line'); + // Published names that are not numbers are already what a rider would say, + // so they survive as published rather than being forced into a number. + assert.equal(transitRouteLabel(hsr, '5820'), 'TC230'); + assert.equal(transitRouteLabel(hsr, '9999'), '9999', 'unknown survives'); +}); + test('Burlington route ids are joined, never prefix-stripped', () => { // `351` is route 1 and `3510` is route 10, so no amount of trimming a `35` // prefix works — the table is the only correct answer. From 9ab26bbcb04a6453528f21210e6317e170778da7 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 17:14:19 -0400 Subject: [PATCH 19/24] docs: record Hamilton's route table and the feeds left out Two gaps. The Hamilton entry never said its route ids are internal keys, which the Guelph, Burlington and YRT entries all say of theirs; the route-table sentence in the transit notes named those three and not Hamilton, though it now carries the largest table of the four. Both were stale the moment Hamilton got a table. The exclusions were half-recorded: GO Transit and Grand River had their reasons written down, but Ottawa, London, Thunder Bay and the static-only majority did not, so a later reader would have re-investigated feeds that were already surveyed and rejected. All of them are now named with the rule each one falls to, and the survey is stated as complete against the Mobility Database's Ontario catalogue. Also records that a route label is never invented, which is why YRT's unpublished 9101 renders as the feed sends it. --- DATA_SOURCES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 62fd4cd69..e78c082ad 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -79,7 +79,7 @@ How to read this: | **HSL** (realtime.hsl.fi) | Transit layer live vehicles, Helsinki | HSL (Helsinki Region Transport) open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to HSL (Helsinki Region Transport) (courtesy text) | | **TTC** (bustime.ttc.ca) | Transit layer live vehicles, Toronto — surface vehicles (buses and streetcars) only | Toronto Transit Commission BusTime open realtime vehicle data, published through City of Toronto Open Data and used for the live view. The subway publishes no vehicle positions, so Lines 1/2/4 are absent rather than inferred. | Attribution to Toronto Transit Commission — City of Toronto Open Data, under the Open Government Licence – Toronto | | **MiWay** (miapp.ca) | Transit layer live vehicles, Mississauga ON | MiWay open realtime vehicle data, published by the City of Mississauga under its Open Data Terms of Use, used for the live view. The licence is revocable and asks that the Terms of Use link travel with the data. | Attribution to MiWay — City of Mississauga Open Data, linked to the Terms of Use | -| **Hamilton HSR** (opendata.hamilton.ca) | Transit layer live vehicles, Hamilton ON | Hamilton Street Railway open realtime vehicle data, published by the City of Hamilton for commercial and non-commercial use. | "Contains public sector Data made available under the City of Hamilton’s Open Data Licence" — wording prescribed by the licence | +| **Hamilton HSR** (opendata.hamilton.ca) | Transit layer live vehicles, Hamilton ON | Hamilton Street Railway open realtime vehicle data, published by the City of Hamilton for commercial and non-commercial use. Not one of its 80 route ids is the number on the bus, so the route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence; two generations of id run at once and both are listed. | "Contains public sector Data made available under the City of Hamilton’s Open Data Licence" — wording prescribed by the licence | | **Durham Region Transit** (drtonline.durhamregiontransit.com) | Transit layer live vehicles, Durham Region ON | Durham Region Transit open realtime vehicle data, published under the Region of Durham Open Data Licence v.1.0, which permits commercial use. | "Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence" | | **Guelph Transit** (glphtrpcloud.com) | Transit layer live vehicles, Guelph ON | Guelph Transit open realtime vehicle data, published by the City of Guelph on its open-data developer page and used for the live view. Its route ids are internal database keys, so the 33-row route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence. | "Contains information licensed under the Open Government Licence – City of Guelph" — wording prescribed by the licence | | **Kingston Transit** (api.cityofkingston.ca) | Transit layer live vehicles, Kingston ON | Kingston Transit open realtime vehicle data, catalogued by the City of Kingston as open data and used for the live view. | "Contains information licensed under the Open Data Licence – City of Kingston" — wording prescribed by the licence | @@ -164,7 +164,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. From 68e99d9407956b4fce646b5b68f0cd1fa972b557 Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 17:17:37 -0400 Subject: [PATCH 20/24] docs: list the Ontario regions in the README feature table The Transit row names the regions a reader can expect to see vehicles in, and still stopped at South East Queensland after nine Ontario feeds were added. Someone scanning the table for Toronto would have concluded the layer does not cover it. Toronto carries the subway caveat here too: the row would otherwise promise metros in a city whose metro publishes no positions. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c8b9eafec..c5aeb4710 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | From 3cdb440bc2166fb9bd42104b69f4ddc162c3dd31 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 17:27:55 -0700 Subject: [PATCH 21/24] Use the opaque iOS status bar and keep the SVG tab icon black-translucent needs viewport-fit=cover and safe-area padding, which this change does not add. List the PNG favicon before the SVG so browsers that take the last usable icon keep the SVG, add mobile-web-app-capable, and give the manifest a stable id. --- index.html | 7 +++++-- public/manifest.webmanifest | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index bdc82bd25..8a01415b6 100644 --- a/index.html +++ b/index.html @@ -4,16 +4,19 @@ God's Eye View - + + + - + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index cf4b2b791..478cd9019 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,4 +1,5 @@ { + "id": ".", "name": "God's Eye View", "short_name": "God's Eye", "description": "Live spatial intelligence in the browser.", From 687a94c9df5226ace08c5cbb8ff888641ec919da Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 17:27:55 -0700 Subject: [PATCH 22/24] =?UTF-8?q?Document=20Bike=20Ita=C3=BA=20(Tembici)?= =?UTF-8?q?=20and=20test=20the=20Brazilian=20cities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seven feeds publish no licence, operator or attribution fields, so DATA_SOURCES records that and credits the operator. Adds a registry test per city: allowed host and path, provider, and activation over its outermost live station. --- CHANGELOG.md | 3 +- DATA_SOURCES.md | 3 +- docs/CURRENT-STATE.md | 2 +- src/data/dataCredits.js | 2 +- src/layers/bikeshare/brPbscCities.test.mjs | 73 ++++++++++++++++++++++ 5 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 src/layers/bikeshare/brPbscCities.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f63083839..5d0194a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,8 @@ - Add seven Brazilian Bike Itaú (Tembici) cities to the GBFS bikeshare registry: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and - Curitiba. These are the layer's first stations in South America. + Curitiba (about 1,000 stations in total). These are the layer's first stations + in South America. - Add Bike Share Toronto (1,069 stations) to the bikeshare registry. The system publishes the same PBSC GBFS v2 feeds as the Austin, Honolulu and Chattanooga diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ac1718308..66c92174e 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -65,8 +65,9 @@ How to read this: | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | | **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" | -| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle, Bike Itaú/Tembici) + its `license_url` | +| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | +| **Bike Itaú (Tembici)** (`*.publicbikesystem.net`, seven Brazilian cities) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and Curitiba | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)" | | **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | | **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" | | **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 61f7a0e80..7a211e806 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3232,7 +3232,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto and Bike Itaú (Brazil) + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 40537d4a9..d6c265ddc 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -304,7 +304,7 @@ export const DATA_CREDITS = [ key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds ' + - '(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú/Tembici)', + '(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú, operated by Tembici)', }, { key: 'urbansharing-gbfs', diff --git a/src/layers/bikeshare/brPbscCities.test.mjs b/src/layers/bikeshare/brPbscCities.test.mjs new file mode 100644 index 000000000..60e12c23b --- /dev/null +++ b/src/layers/bikeshare/brPbscCities.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CITY_BY_ID } from './registry.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js'; + +// Each Bike Itaú city's PBSC host, and the station farthest from its +// registered centre in the live feed (checked 2026-10-15). +const CITIES = { + 'sao-paulo-bike-itau': { + host: 'saopaulo.publicbikesystem.net', + stations: [{ name: '260 - P3', lat: -23.568235, lon: -46.740715 }], + }, + 'rio-de-janeiro-bike-itau': { + host: 'riodejaneiro.publicbikesystem.net', + stations: [{ name: '293 - Praia da Macumba', lat: -23.03135, lon: -43.479584 }], + }, + 'salvador-bike-itau': { + host: 'salvador.publicbikesystem.net', + stations: [{ name: '49 - Stella Maris', lat: -12.93645, lon: -38.32701 }], + }, + 'recife-bike-itau': { + host: 'recife.publicbikesystem.net', + stations: [{ name: '69 - Praia de Piedade', lat: -8.186205, lon: -34.918371 }], + }, + 'porto-alegre-bike-itau': { + host: 'portoalegre.publicbikesystem.net', + stations: [{ name: '72 - Avenida Tramandaí', lat: -30.135915, lon: -51.22684 }], + }, + 'brasilia-bike-itau': { + host: 'brasilia.publicbikesystem.net', + stations: [{ name: "60 - Parque Olhos d'Água", lat: -15.7431, lon: -47.88667 }], + }, + 'curitiba-bike-itau': { + host: 'curitiba.publicbikesystem.net', + stations: [{ name: '42 - Terminal Portão', lat: -25.476885, lon: -49.292608 }], + }, +}; + +// The production range check, with no viewer or fetches behind it. +const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) }; +const { computeInRangeCities } = createViewport({ + state: {}, + services: { render: { governorRequestRender() {} } }, + parts, + source: null, +}); + +test('the Bike Itaú systems are registered and readable through the proxy', () => { + for (const [id, expected] of Object.entries(CITIES)) { + const city = CITY_BY_ID.get(id); + assert.ok(city, `${id} is registered`); + assert.equal(city.provider, 'Bike Itaú'); + assert.deepEqual(city.hosts, [expected.host]); + for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) { + const url = new URL(feed); + assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`); + assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`); + } + } +}); + +test('a camera over each outermost station activates its system', () => { + for (const [id, expected] of Object.entries(CITIES)) { + for (const station of expected.stations) { + assert.ok( + computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id), + `${station.name} activates ${id}`, + ); + } + } +}); From 265c8a1cc0ea5b396f0392aa83589d02a06be1fd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 17:27:55 -0700 Subject: [PATCH 23/24] Tighten datasheet model matching and keep given fields of view Codec, product and resolution words never identify a model; generation suffixes such as Mk II no longer fall back to the base model; a mismatched manufacturer never matches, and brand-only numeric names need the brand. The datasheet FOV only fills when the source and seed give none and the pose is not curated, and DATASHEET is shown only while no calibration offset applies. Adds the CC0 extract to DATA_SOURCES. --- CHANGELOG.md | 9 +- DATA_SOURCES.md | 12 ++ docs/CURRENT-STATE.md | 3 +- src/layers/cctv/catalog.js | 37 ++++-- src/layers/cctv/modelSpecs.js | 188 ++++++++++++++++++++-------- src/layers/cctv/modelSpecs.test.mjs | 108 +++++++++++++++- src/layers/cctv/presentation.js | 17 ++- 7 files changed, 295 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 834650d63..81ff88aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ - CCTV cameras with identified hardware show the model's datasheet specs. When a source names a camera model found in the vendored CCTV Camera Database records (CC0), the HUD meta line adds its resolution, night vision and PTZ capability. - A fixed single-lens model's horizontal FOV replaces the estimate - (`FOV n° (DATASHEET)`); a PTZ or varifocal range is what the lens can do, not - its current zoom, so those cameras keep the estimate and show - `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra, #452). + A fixed single-lens model's horizontal FOV fills in when the source states no + FOV and the pose is not curated (`FOV n° (DATASHEET)`); a PTZ or varifocal + range is what the lens can do, not its current zoom, so those cameras keep the + estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra, + #452). - Add WSDOT highway cameras (Washington State) as a keyless CCTV source pack: WSDOT's official Travel Center camera layer, with frames registered only on diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 8f3b55dbf..d1ecd1423 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -185,6 +185,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | +| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | ### ⚠️ TeleGeography is bundled but NonCommercial @@ -210,6 +211,17 @@ commercial use with attribution and applicable database share-alike/access obligations; it does not license the application code. Trimmed test-only tile fixtures retain their provenance in `src/data/fixtures/README.md`. +### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) + +The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the +[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of +camera models transcribed from manufacturer datasheets, dedicated to the public domain under +**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King +County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is +fetched at runtime. The records describe camera products (optics, resolution, night vision) +only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD +credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. + ### Bundled OpenStreetMap data Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d24d9d92d..b2be525c7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4185,7 +4185,8 @@ silently demoting every later lookup for the session. - **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens - hardware replaces the estimated FOV, shown as `FOV n° (DATASHEET)`. PTZ, varifocal and + hardware fills the FOV, and only when the source or seed states none and the pose is not + curated, shown as `FOV n° (DATASHEET)` until a manual calibration moves it. PTZ, varifocal and multi-sensor datasheets publish a capability range, not the lens's current zoom, so those cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 7804327d6..900692939 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -7,17 +7,22 @@ import { /** * Applies datasheet enrichment to a camera in place: when it names a known - * hardware `model`, the spec record is attached for the HUD and the - * datasheet's horizontal FOV range rides along as `specFovMinDeg`/ - * `specFovMaxDeg`. Only fixed-lens hardware replaces the pose estimate: a - * PTZ/varifocal range says what the lens CAN do, not where it is zoomed now. + * hardware `model` (and, when published, a matching `manufacturer`), the spec + * record is attached for the HUD and the datasheet's horizontal FOV range + * rides along as `specFovMinDeg`/`specFovMaxDeg`. Only fixed-lens hardware + * fills the FOV, and only when none was given: an explicit source/seed + * `fovDeg` or a curated pose is never overwritten. A PTZ/varifocal range says + * what the lens CAN do, not where it is zoomed now. * Unknown models — the common case — change nothing. * @param {Object} camera - Camera being built (mutated). - * @param {string} [modelName] - Hardware model from a source or seed. + * @param {{model?:string, manufacturer?:string, explicitFov?:boolean}} hardware + * - Hardware model/manufacturer from a source or seed, and whether that + * source or seed stated its own `fovDeg`. * @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper. */ -function applyModelEnrichment(camera, modelName, clamp) { - const spec = modelName ? lookupModelSpec(modelName) : null; +function applyModelEnrichment(camera, hardware, clamp) { + const { model, manufacturer, explicitFov } = hardware; + const spec = model ? lookupModelSpec(model, manufacturer) : null; if (!spec) return; const range = horizontalFovRangeDeg(spec); if (range) { @@ -25,7 +30,7 @@ function applyModelEnrichment(camera, modelName, clamp) { camera.specFovMaxDeg = range.maxDeg; } const fixedFov = fixedHorizontalFovDeg(spec); - if (fixedFov) { + if (fixedFov && !explicitFov && camera.poseSource !== 'curated') { camera.fovDeg = clamp(fixedFov, 20, 125); camera.fovSource = 'datasheet'; } @@ -77,7 +82,11 @@ export function createCatalog({ state: layerState, services, parts, source }) { parts.model.clamp(seed.elevationM ?? 22, 8, 80), pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4), }; - applyModelEnrichment(camera, seed.model, parts.model.clamp); + applyModelEnrichment( + camera, + { model: seed.model, explicitFov: seed.fovDeg != null }, + parts.model.clamp, + ); parts.model.ensureCameraPose(camera); catalog.push(camera); } @@ -242,7 +251,15 @@ export function createCatalog({ state: layerState, services, parts, source }) { }; applyModelEnrichment( camera, - source.model || seed?.model, + { + model: source.model || seed?.model, + manufacturer: source.model ? source.manufacturer : '', + explicitFov: + (source.fovDeg != null && Number.isFinite(Number(source.fovDeg))) || + CAMERA_SEEDS.some( + (entry) => entry.id === id && entry.fovDeg != null, + ), + }, parts.model.clamp, ); parts.model.ensureCameraPose(camera); diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index cee3e5044..1e5bc7cf1 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -14,7 +14,7 @@ * * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset - * of 28,000+ camera models transcribed from manufacturer datasheets (75% with + * of 28,000+ camera models transcribed from manufacturer datasheets (84% with * FOV, as of v2.26.0). This module vendors the records that the currently integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that @@ -25,7 +25,7 @@ * night vision) — never endpoints, stream paths, or access/config data. */ -/** Verbatim records from the dataset's model-enrichment extract (v2.19.0). */ +/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */ const RECORDS = [ { id: 'axis-q6135-le', @@ -307,74 +307,144 @@ function normalize(value) { .replace(/\s+/g, ' '); } -const BY_MODEL = new Map(); -for (const record of RECORDS) { - BY_MODEL.set(normalize(record.model), record); - for (const alias of record.aliases || []) - BY_MODEL.set(normalize(alias), record); +/** + * Whitespace words of a name, lowercased, with surrounding punctuation trimmed + * so "(NDP-7512-Z30)" reads as "ndp-7512-z30". Inner hyphens and dots stay: + * "f4105-lre-unknown" is one word, never "f4105-lre" plus noise. + * @param {string} value + * @returns {string[]} + */ +function words(value) { + return normalize(value) + .split(' ') + .map((raw) => raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '')) + .filter(Boolean); } /** - * DISTINCTIVE tokens of a record — the whitespace words of its model and - * aliases that identify hardware rather than describe it. A token qualifies - * only when it is >= 4 chars AND contains a digit or hyphen, so generic words - * ("dome", "network", "camera", "series", "ptz") are never distinctive and a - * verbose registry string cannot match on them alone. Surrounding punctuation - * is trimmed so "(NDP-7512-Z30)" indexes as "ndp-7512-z30". - * @param {Object} record - * @returns {string[]} Unique distinctive tokens, lowercased. + * Descriptor words that never identify hardware: product-type nouns, codec + * and resolution/zoom/frequency tokens ("h.264", "2mp", "40x", "1080p"). */ -function distinctiveTokens(record) { - const out = new Set(); - for (const name of [record.model, ...(record.aliases || [])]) { - for (const raw of normalize(name).split(' ')) { - const token = raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''); - if (token.length >= 4 && /[0-9-]/.test(token)) out.add(token); - } +const GENERIC_WORDS = new Set([ + 'network', + 'camera', + 'cam', + 'ip', + 'ptz', + 'dome', + 'bullet', + 'box', + 'hd', + 'ir', + 'outdoor', + 'indoor', + 'series', +]); + +function isGeneric(word) { + return ( + GENERIC_WORDS.has(word) || + /^h\.?\d+$/.test(word) || + /^\d+(?:\.\d+)?(?:mp|p|k|x|mm|hz|tvl)$/.test(word) + ); +} + +/** A model code: letters AND digits, and not a generic descriptor. */ +function isModelCode(word) { + return /[a-z]/.test(word) && /\d/.test(word) && !isGeneric(word); +} + +/** Hyphenated variant suffixes that do not change the hardware. */ +const VARIANT_SUFFIXES = new Set(['50hz', '60hz', 'ntsc', 'pal']); + +/** "q6155-e-60hz" → "q6155-e"; "f4105-lre-unknown" is left as is. */ +function stripVariantSuffix(word) { + let out = word; + for (;;) { + const cut = out.lastIndexOf('-'); + if (cut <= 0 || !VARIANT_SUFFIXES.has(out.slice(cut + 1))) return out; + out = out.slice(0, cut); } - return [...out]; +} + +/** Manufacturer names (lowercased words) accepted for each record brand. */ +const BRAND_ALIASES = { + Hanwha: ['wisenet', 'samsung'], + Hikvision: ['hiwatch'], +}; + +function brandWords(record) { + return new Set([ + ...words(record.brand), + ...(BRAND_ALIASES[record.brand] || []), + ]); } /** - * Token index for the substring fallback: one precompiled whole-word matcher - * per distinctive token. Word boundaries are any non-alphanumeric char (or the - * string ends), so hyphenated/dotted tokens like "q6155-e" or "h.264" match as - * whole words while "700" never matches inside "7000". + * One entry per record name (model + aliases). `required` is every + * non-generic word of the name — all must appear in a query, so a generation + * or suffix ("Mk II", "7000i" vs "7000") is never dropped. `hasCode` marks + * names carrying a letter+digit model code; names without one ("3950", + * "01959-004", "AUTODOME 800 Series") match only when the brand does. */ -const TOKEN_INDEX = RECORDS.flatMap((record) => - distinctiveTokens(record).map((token) => ({ - token, - record, - re: new RegExp( - `(?:^|[^a-z0-9])${token.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}(?:[^a-z0-9]|$)`, - ), - })), -); +const NAME_INDEX = RECORDS.flatMap((record) => { + const brand = brandWords(record); + return [record.model, ...(record.aliases || [])].map((name) => { + const nameWords = words(name); + return { + record, + brand, + allowed: new Set(nameWords), + required: nameWords.filter((word) => !isGeneric(word)), + hasCode: nameWords.some(isModelCode), + }; + }); +}); /** * Looks up a spec record for a hardware-model string. * - * Exact match first (case-insensitive, whitespace-collapsed) on the model and - * every alias. When that misses, a token fallback lets a VERBOSE registry - * string resolve — e.g. "AXIS Q6155-E PTZ Dome Network Camera" finds the - * Q6155-E via its distinctive "q6155-e" token. The most specific (longest) - * matched token wins; if two different records tie on the longest matched - * token the result is ambiguous and returns null (never guesses). + * A record name matches when every non-generic word of that name appears in + * the query (case-insensitive, punctuation-trimmed) and every other query word + * is generic or the record's brand. That lets a VERBOSE registry string + * resolve — "AXIS Q6155-E PTZ Dome Network Camera" finds the Q6155-E — while + * an unrelated code ("Q1615"), a generation suffix ("Mk II") or a longer part + * number ("F4105-LRE-UNKNOWN") rejects the match. Codec, resolution and + * product-type words never identify hardware on their own. A name with no + * letter+digit model code ("3950") needs the brand, from `manufacturer` or the + * query itself. A `manufacturer` that is not the record's brand always + * rejects. Matches on two different records are ambiguous → null (never + * guesses). * @param {string} model - Hardware model as named by a source pack or seed. + * @param {string} [manufacturer] - The source's manufacturer, when published. * @returns {Object|null} The verbatim spec record, or null when unknown. */ -export function lookupModelSpec(model) { - if (!model) return null; - const query = normalize(model); - if (!query) return null; - const exact = BY_MODEL.get(query); - if (exact) return exact; - const matches = TOKEN_INDEX.filter((entry) => entry.re.test(query)); - if (!matches.length) return null; - const maxLen = Math.max(...matches.map((m) => m.token.length)); - const top = matches.filter((m) => m.token.length === maxLen); - const records = new Set(top.map((m) => m.record)); - return records.size === 1 ? top[0].record : null; +export function lookupModelSpec(model, manufacturer) { + const query = words(model).map(stripVariantSuffix); + if (!query.length) return null; + const maker = words(manufacturer); + const found = new Set(); + for (const entry of NAME_INDEX) { + const makerMatches = maker.some((word) => entry.brand.has(word)); + if (maker.length && !makerMatches) continue; + if ( + !entry.hasCode && + !makerMatches && + !query.some((word) => entry.brand.has(word)) + ) + continue; + if (!entry.required.length) continue; + if (!entry.required.every((word) => query.includes(word))) continue; + if ( + !query.every( + (word) => + entry.allowed.has(word) || entry.brand.has(word) || isGeneric(word), + ) + ) + continue; + found.add(entry.record); + } + return found.size === 1 ? [...found][0] : null; } /** @@ -416,6 +486,16 @@ export function fixedHorizontalFovDeg(spec) { return range && range.minDeg === range.maxDeg ? range.minDeg : null; } +/** + * True when the camera's CURRENT FOV is the datasheet figure: it was filled + * from a fixed-lens datasheet and no manual calibration offset has moved it. + * @param {Object} camera - Catalog camera (see applyModelEnrichment). + * @returns {boolean} + */ +export function isDatasheetFov(camera) { + return camera?.fovSource === 'datasheet' && !camera.calibration?.fovDeg; +} + /** * HUD token for a datasheet FOV that describes capability rather than the * camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'. diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 06ffe3a64..9fbdb00dd 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -9,6 +9,7 @@ import { horizontalFovRangeDeg, fixedHorizontalFovDeg, specFovCapabilityToken, + isDatasheetFov, specSummary, } from './modelSpecs.js'; import { createCatalog } from './catalog.js'; @@ -21,7 +22,8 @@ test('lookupModelSpec matches case-insensitively and trims', () => { }); test('lookupModelSpec matches aliases', () => { - const byAlias = lookupModelSpec('01959-004'); + // A part number without letters needs the brand. + const byAlias = lookupModelSpec('01959-004', 'Axis'); assert.ok(byAlias); assert.equal(byAlias.model, 'Q6135-LE'); }); @@ -29,7 +31,8 @@ test('lookupModelSpec matches aliases', () => { test('vendored table covers the integrated catalogs published hardware', () => { // Models the live packs publish: King County (Cohu, AUTODOME IP 5000i), // Sarasota (Bosch AUTODOME 7000/VG4), Sioux Falls (Axis). - const cohu = lookupModelSpec('3950'); // King County publishes "3950" + // King County publishes "3950" with Manufacturer "Cohu". + const cohu = lookupModelSpec('3950', 'Cohu'); assert.equal(cohu?.brand, 'Cohu'); const autodome = lookupModelSpec('AUTODOME IP 5000i'); assert.ok(autodome && horizontalFovRangeDeg(autodome).maxDeg > 0); @@ -71,8 +74,8 @@ test('token fallback never matches on generic words or wrong models', () => { }); test('exact match still wins over the token fallback', () => { - // "3965" is an alias of the 3960; exact alias match, not a token scan. - assert.equal(lookupModelSpec('3965')?.id, 'cohu-3960'); + // "3965" is an alias of the 3960, not the 3950. + assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960'); assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30'); }); @@ -167,10 +170,10 @@ function browserCatalog() { }); } -test('catalog keeps the estimated FOV for PTZ hardware and substitutes only fixed-lens datasheets', () => { +test('catalog keeps the estimated FOV for PTZ hardware and fills only fixed-lens datasheets', () => { const [ptz, fixed, unknown] = browserCatalog().buildCatalogFromSources([ { id: 'ptz', lat: 47.7, lon: -122, fovDeg: 44, model: 'Q6135-LE' }, - { id: 'fixed', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' }, + { id: 'fixed', lat: 47.7, lon: -122, model: 'F4105-LRE' }, { id: 'unknown', lat: 47.7, lon: -122, fovDeg: 44, model: 'NOPE-1' }, ]); @@ -191,6 +194,99 @@ test('catalog keeps the estimated FOV for PTZ hardware and substitutes only fixe assert.equal(unknown.specFovMaxDeg, undefined); }); +test('codec, format and product words never identify hardware', () => { + // "h.264" is in the VG4 AUTODOME alias; it is a codec, not a model code. + assert.equal(lookupModelSpec('Axis Q1615 H.264 Network Camera'), null); + assert.equal(lookupModelSpec('H.264 Network Camera'), null); + assert.equal(lookupModelSpec('H.265 IP Camera'), null); +}); + +test('generation and suffix distinctions are preserved', () => { + // A Mk II is not the vendored (Mk I) Q6135-LE. + assert.equal(lookupModelSpec('AXIS Q6135-LE Mk II'), null); + // A longer part number is a different product... + assert.equal(lookupModelSpec('F4105-LRE-UNKNOWN'), null); + // ...unless the rest is a mains-frequency variant suffix. + assert.equal(lookupModelSpec('Q6155-E-60Hz')?.id, 'axis-q6155-e'); +}); + +test('manufacturer must agree with the record brand', () => { + // Purely numeric models need a brand match. + assert.equal(lookupModelSpec('3950'), null); + assert.equal(lookupModelSpec('Pelco 3950'), null); + assert.equal(lookupModelSpec('3950', 'Pelco'), null); + // A model code under the wrong manufacturer is not that record either. + assert.equal(lookupModelSpec('Q6135-LE', 'Hikvision'), null); + // Case-insensitive, with company suffixes and aliases. + assert.equal(lookupModelSpec('3950', 'COHU')?.id, 'cohu-3950-iview'); + assert.equal( + lookupModelSpec('Q6135-LE', 'Axis Communications AB')?.id, + 'axis-q6135-le', + ); + assert.equal( + lookupModelSpec('XNP-6400RW', 'Hanwha Vision')?.id, + 'hanwha-xnp-6400rw', + ); + assert.equal( + lookupModelSpec('XNP-6400RW', 'Wisenet')?.id, + 'hanwha-xnp-6400rw', + ); +}); + +test('King County snapshot model strings still resolve', () => { + // Model/Manufacturer pairs as published in the King County feed. + assert.equal(lookupModelSpec('3950', 'Cohu')?.id, 'cohu-3950-iview'); + assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960'); + assert.equal(lookupModelSpec('3925 Dome', 'Cohu')?.id, 'cohu-3920-idome'); + assert.equal(lookupModelSpec('Q6135-LE', 'Axis')?.id, 'axis-q6135-le'); + assert.equal( + lookupModelSpec('AUTODOME IP 5000i', 'BOSCH')?.id, + 'bosch-ndp-5502-30', + ); +}); + +test('catalog never overwrites an explicit FOV or a curated pose', () => { + const [explicit, curated, cohu, pelco] = + browserCatalog().buildCatalogFromSources([ + { id: 'explicit', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' }, + { + id: 'curated', + lat: 47.7, + lon: -122, + model: 'F4105-LRE', + poseSource: 'curated', + }, + { id: 'cohu', lat: 47.7, lon: -122, model: '3950', manufacturer: 'Cohu' }, + { + id: 'pelco', + lat: 47.7, + lon: -122, + model: '3950', + manufacturer: 'Pelco', + }, + ]); + assert.equal(explicit.fovDeg, 44); + assert.equal(explicit.fovSource, undefined); + assert.equal(explicit.spec.model, 'F4105-LRE'); + assert.equal(curated.fovDeg, 74); + assert.equal(curated.fovSource, undefined); + // The source's manufacturer reaches the lookup. + assert.equal(cohu.spec?.id, 'cohu-3950-iview'); + assert.equal(pelco.spec, undefined); +}); + +test('the DATASHEET label drops once calibration moves the FOV', () => { + const camera = { fovSource: 'datasheet', fovDeg: 110 }; + assert.equal(isDatasheetFov(camera), true); + assert.equal(isDatasheetFov({ ...camera, calibration: { fovDeg: 0 } }), true); + assert.equal( + isDatasheetFov({ ...camera, calibration: { fovDeg: 6 } }), + false, + ); + assert.equal(isDatasheetFov({ fovDeg: 74 }), false); + assert.equal(isDatasheetFov(null), false); +}); + test('specSummary renders the HUD line and degrades by omission', () => { assert.equal( specSummary(lookupModelSpec('Q6135-LE')), diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index efa18e462..34c7dbcc9 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,7 +1,11 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js'; import { headingHudToken, isHeadingEstimated } from './headingConfidence.js'; -import { specSummary, specFovCapabilityToken } from './modelSpecs.js'; +import { + specSummary, + specFovCapabilityToken, + isDatasheetFov, +} from './modelSpecs.js'; export function createPresentation({ state: layerState, @@ -40,8 +44,9 @@ export function createPresentation({ // A synthetic bearing (headingConfidence 'low', no human calibration) // is tagged so a hashed guess never reads as a surveyed facing (#639). headingHudToken(active.camera), - // Identified hardware labels its FOV as datasheet-sourced (catalog.js). - `FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`, + // Identified hardware labels its FOV as datasheet-sourced (catalog.js) + // only while no manual calibration offset has moved it. + `FOV ${Math.round(active.camera.fovDeg)}°${isDatasheetFov(active.camera) ? ' (DATASHEET)' : ''}`, // A PTZ/varifocal datasheet range is what the lens can do, not its // current zoom, so it is shown beside the estimate, never as it. specFovCapabilityToken(active.camera), @@ -96,7 +101,11 @@ export function createPresentation({ pitchDeg: camera.pitchDeg, fovDeg: camera.fovDeg, // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). - fovSource: camera.fovSource || 'estimated', + fovSource: isDatasheetFov(camera) + ? 'datasheet' + : camera.fovSource === 'datasheet' + ? 'calibrated' + : camera.fovSource || 'estimated', specFovMinDeg: camera.specFovMinDeg ?? null, specFovMaxDeg: camera.specFovMaxDeg ?? null, spec: camera.spec || null, From cf63278126a512c7e2761dd9ab38913bd126b291 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 17:12:35 -0700 Subject: [PATCH 24/24] Take TTC vehicle mode from the fleet number before the route Replacement buses on 5xx streetcar routes were drawn as streetcars, and Flexity streetcars reporting no route were drawn as buses. A feed can now supply vehicleMode(id); TTC maps 4400-4699 to streetcars and every other fleet number to buses, falling back to the route otherwise. --- CHANGELOG.md | 4 +- server/providers/transitHistory.test.mjs | 23 ++++++++++++ src/data/transitFeeds.js | 47 ++++++++++++++++++++---- src/data/transitFeeds.test.mjs | 16 ++++++++ src/layers/transit/ingestion.js | 8 +++- src/sources/transitHistoryStore.js | 6 ++- 6 files changed, 93 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca1ca0de..2e7478531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own licence quote and attribution. A feed can now name its own modes, so Toronto's trams read "Streetcar" while Helsinki's stay "Tram", and a feed whose route ids - are internal keys can translate them to the number a rider would say. + are internal keys can translate them to the number a rider would say. TTC + vehicles take their mode from the fleet number first, so replacement buses on + streetcar routes read as buses and out-of-service streetcars stay streetcars. - Cyclone advisories no longer go dark while NHC publishes an advisory ahead of its nominal time: advisory and position times up to 90 minutes ahead of diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs index cb06c182a..551fee7a7 100644 --- a/server/providers/transitHistory.test.mjs +++ b/server/providers/transitHistory.test.mjs @@ -179,3 +179,26 @@ test('snapshot capacity pressure preserves members not yet visited and discloses assert.equal(recreated.fixes.length, 1); assert.equal(recreated.truncated, true); }); +test('a feed fleet-number mode outranks its route mode in the epoch context', (t) => { + const h = history(t); + const ttc = { + id: 'ttc', + historyRetention: true, + defaultMode: 'bus', + routeMode: (route) => (/^5\d\d$/.test(route ?? '') ? 'tram' : 'bus'), + vehicleMode: (id) => + /^4[4-6]\d\d$/.test(id) ? 'tram' : /^\d{4}$/.test(id) ? 'bus' : 'unknown', + }; + h.ingest( + ttc, + [ + row(100000, '8515', { routeId: '506' }), + row(100000, '4507', { routeId: null }), + row(100000, 'x1', { routeId: '501' }), + ], + 100000, + ); + assert.equal(h.get('ttc', '8515', 100000).epochs[0].mode, 'bus'); + assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram'); + assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram'); +}); diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index cdf4b030b..dfd3d6b18 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -130,6 +130,20 @@ function ttcRouteMode(routeId) { return 'bus'; } +/** + * The fleet number settles TTC's mode before the route does: buses replace + * streetcars on 5xx routes during track work, and streetcars running out of + * service report no route at all. Flexity streetcars are numbered 4400–4699; + * every other TTC number is a bus. + * @param {string|null} vehicleId GTFS-RT vehicle id (the fleet number). + * @returns {string} + */ +function ttcVehicleMode(vehicleId) { + if (!/^\d{4}$/.test(String(vehicleId ?? ''))) return 'unknown'; + const fleet = Number(vehicleId); + return fleet >= 4400 && fleet < 4700 ? 'tram' : 'bus'; +} + /** * Guelph Transit's realtime feed publishes the internal database key of a * route (`2991`), not the number on the front of the bus (`1`). Without this @@ -541,6 +555,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ tram: Object.freeze({ word: 'Streetcar', abbr: 'STREETCAR' }), }), routeMode: ttcRouteMode, + vehicleMode: ttcVehicleMode, }), Object.freeze({ id: 'miway-mississauga', @@ -795,14 +810,32 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) { } /** - * Mode for a vehicle: the feed's route hint when it has one, else its default. + * The feed's own hint for a vehicle: its fleet number when the feed maps + * those (`vehicleMode`), else its route (`routeMode`). Null when neither knows. + * @param {object} feed Registry entry. + * @param {string|null} routeId + * @param {string|null} vehicleId + * @returns {string|null} + */ +function hintedMode(feed, routeId, vehicleId) { + const byVehicle = + typeof feed?.vehicleMode === 'function' + ? feed.vehicleMode(vehicleId) + : null; + if (byVehicle && byVehicle !== 'unknown') return byVehicle; + return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null; +} + +/** + * Mode for a vehicle: the feed's fleet or route hint when it has one, else + * its default. * @param {object} feed Registry entry. * @param {string|null} routeId GTFS route_id from the vehicle's trip. + * @param {string|null} [vehicleId] GTFS-RT vehicle id. * @returns {string} One of TRANSIT_MODES. */ -export function transitModeFor(feed, routeId) { - const hinted = - typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null; +export function transitModeFor(feed, routeId, vehicleId = null) { + const hinted = hintedMode(feed, routeId, vehicleId); const mode = hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown'; return TRANSIT_MODES.includes(mode) ? mode : 'unknown'; @@ -815,11 +848,11 @@ export function transitModeFor(feed, routeId) { * not hold a defaulted vehicle to a bus's limits. * @param {object} feed Registry entry. * @param {string|null} routeId + * @param {string|null} [vehicleId] * @returns {boolean} */ -export function transitModeResolved(feed, routeId) { - const hinted = - typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null; +export function transitModeResolved(feed, routeId, vehicleId = null) { + const hinted = hintedMode(feed, routeId, vehicleId); return ( Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted) ); diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 22281e46d..fd990af45 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -279,6 +279,22 @@ test('TTC route numbers tell a streetcar from a bus', () => { ); }); +test('a TTC fleet number outranks the route', () => { + // Buses replace streetcars on 5xx routes during track work, and streetcars + // out of service report no route; the Flexity fleet is 4400–4699. + const ttc = getTransitFeed('ttc-toronto'); + assert.equal(transitModeFor(ttc, '506', '8515'), 'bus'); + assert.equal(transitModeFor(ttc, '512', '3101'), 'bus'); + assert.equal(transitModeFor(ttc, null, '4507'), 'tram'); + assert.equal(transitModeResolved(ttc, null, '4507'), true); + assert.equal(transitModeFor(ttc, '504', '4400'), 'tram'); + assert.equal(transitModeFor(ttc, '29', '4699'), 'tram'); + assert.equal(transitModeFor(ttc, '501', '4700'), 'bus'); + // A vehicle id that is not a fleet number leaves the route to decide. + assert.equal(transitModeFor(ttc, '501', 'abc'), 'tram'); + assert.equal(transitModeFor(ttc, '501'), 'tram'); +}); + test('the TTC row records that BusTime carries no subway', () => { const sources = readFileSync( new URL('../../DATA_SOURCES.md', import.meta.url), diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index cb610267e..be8833270 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -221,8 +221,12 @@ export function createIngestion({ state, services, parts, source }) { for (const record of records) { if (isStaleVehicleFix(record, now, fetchedAt)) continue; const key = transitVehicleKey(feed.id, record.id); - const mode = transitModeFor(feed, record.routeId); - const modeInferred = !transitModeResolved(feed, record.routeId); + const mode = transitModeFor(feed, record.routeId, record.id); + const modeInferred = !transitModeResolved( + feed, + record.routeId, + record.id, + ); // A stamp ahead of the fetch is a clock fault; the fetch time bounds it. const stamped = vehicleReportTimeMs(record, fetchedAt); const reportAt = diff --git a/src/sources/transitHistoryStore.js b/src/sources/transitHistoryStore.js index 36440975c..9fe29f59d 100644 --- a/src/sources/transitHistoryStore.js +++ b/src/sources/transitHistoryStore.js @@ -328,8 +328,12 @@ export function createTransitHistory({ t > now + 10000 ) continue; + const byVehicle = feed.vehicleMode?.(record.id); const mode = - feed.routeMode?.(record.routeId) || feed.defaultMode || 'unknown'; + (byVehicle !== 'unknown' && byVehicle) || + feed.routeMode?.(record.routeId) || + feed.defaultMode || + 'unknown'; const context = contextOf(record, mode); if (!context) continue; const key = `${feed.id}\u0000${record.id}`;