From 9b1967a59e9b9d56d339676a9e44d90f94f14ecb Mon Sep 17 00:00:00 2001 From: manjunath Date: Sun, 18 Oct 2026 11:13:30 +0530 Subject: [PATCH 01/21] fix(voice): report actual cctv toggle results --- docs/CURRENT-STATE.md | 7 + src/voice/cctvRouting.test.mjs | 482 +++++++++++++++++++++++++++++++++ src/voice/gevActions.js | 12 + 3 files changed, 501 insertions(+) create mode 100644 src/voice/cctvRouting.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cb7b6d17..d6b75b47e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -595,6 +595,13 @@ Voice controls bind to a protocol-independent session factory. The default WebRT Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. +`createGevActionRunner` routes `control_cctv` enable/disable through the shared +layer visibility lifecycle. Results distinguish successful changes from +manager-reported failures or cancellation and report enabled state and lifecycle +uncertainty. A caller-supplied abort signal reaches the manager; a late abort +preserves an already committed change. Missing-layer errors, other CCTV actions +and the exported `controlCctv` helper retain their existing behavior. + `src/voice/layerManifest.js` declares each catalog layer's voice capabilities: spoken aliases, `toggle`, in-view `context`, `track`, and analyst `query` fields with type and unit, or `noQuery` with the reason. The `set_layer_visibility`, `show_data_layers_menu`, `get_entity_context` and `analyst_query` layer enums, the runner's alias table and the analyst field hints are generated from it. `src/voice/layerManifest.test.mjs` fails when a catalog layer is neither listed nor named in `VOICE_OFF_LAYERS` (today only the two scene-driven Bhote Koshi layers). Global Context is listed with `toggle: false`; `set_context_mode` reaches it. `set_panel_open` also opens `weather-panel` and `recent-imagery-panel`. Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged. diff --git a/src/voice/cctvRouting.test.mjs b/src/voice/cctvRouting.test.mjs new file mode 100644 index 000000000..58f3a135b --- /dev/null +++ b/src/voice/cctvRouting.test.mjs @@ -0,0 +1,482 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DataLayerManager } from '../data/manager.js'; +import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; +import { createGevActionRunner } from './gevActions.js'; + +const originalWindow = globalThis.window; +before(() => { + globalThis.window ??= { clearTimeout, setTimeout, requestIdleCallback: null }; +}); +after(() => { + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; +}); + +function viewerFixture() { + return { + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { moveEnd: { addEventListener() {} } }, + }; +} + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function runnerFixture(t, dataManager, options = {}) { + const runner = createGevActionRunner({ + viewer: viewerFixture(), + styleManager: {}, + dataManager, + ...options, + }); + t.after(() => runner.dispose()); + return runner; +} + +// Only the inert module's lifecycle completion is controlled. The manager's +// intent queue, cancellation, compensation, events, and runner are production. +async function lifecycleFixture(t, targetEnabled) { + const viewer = viewerFixture(); + const manager = new DataLayerManager(viewer); + const calls = []; + const gates = []; + const pending = []; + let nextGate = null; + let moduleEnabled = false; + const phase = targetEnabled ? 'enable' : 'disable'; + const lifecycle = async (name, options = {}) => { + calls.push(name); + if (name === phase && nextGate) { + const gate = nextGate; + nextGate = null; + gate.entered.resolve(options.signal); + await gate.release.promise; + if (gate.outcome instanceof Error) throw gate.outcome; + if (gate.outcome === false) return false; + } + moduleEnabled = name === 'enable'; + return true; + }; + manager.register({ + id: 'cctv', + name: 'CCTV', + source: 'test', + updateInterval: -1, + init: async () => true, + enable: (_viewer, options) => lifecycle('enable', options), + disable: (_viewer, options) => lifecycle('disable', options), + update: async () => true, + getStats: () => ({ count: 0, lastUpdate: 0 }), + }); + if (!targetEnabled) + assert.equal(await manager.setEnabled('cctv', true), true); + calls.length = 0; + const events = []; + const unsubscribe = manager.subscribe((event) => events.push(event)); + const runner = runnerFixture(t, manager, { viewer }); + t.after(async () => { + for (const gate of gates) gate.release.resolve(); + await Promise.allSettled(pending); + unsubscribe(); + await manager.destroyAll(); + }); + return { + manager, + events, + calls, + get moduleEnabled() { + return moduleEnabled; + }, + gate(outcome = true) { + const gate = { entered: deferred(), release: deferred(), outcome }; + gates.push(gate); + nextGate = gate; + return gate; + }, + run(action, options = {}) { + const work = runner('control_cctv', { action }, options); + pending.push(work); + return work; + }, + }; +} + +function assertLifecycle(result, manager, enabled, uncertain = false) { + assert.equal(result.action, 'control_cctv'); + assert.equal(result.layerId, 'cctv'); + assert.equal(result.enabled, enabled); + assert.equal(result.lifecycleState, enabled ? 'enabled' : 'disabled'); + assert.equal(result.lifecycleUncertain, uncertain); + assert.deepEqual(manager.getLayerLifecycleState('cctv'), { + enabled, + lifecycleState: result.lifecycleState, + uncertain, + }); +} + +const terminalTypes = new Set([ + 'visibility', + 'visibility-cancelled', + 'visibility-failed', + 'visibility-blocked', +]); + +for (const action of ['enable', 'disable']) { + const targetEnabled = action === 'enable'; + for (const outcome of [ + 'success', + 'false', + 'throw', + 'caller-abort', + 'resource-abort', + 'late-abort', + ]) { + test( + `CCTV ${action}: ${outcome} preserves the manager outcome`, + { timeout: 2000 }, + async (t) => { + const h = await lifecycleFixture(t, targetEnabled); + const controller = new AbortController(); + const failure = + outcome === 'resource-abort' + ? Object.assign(new Error('fixture resource ended'), { + name: 'AbortError', + }) + : outcome === 'throw' + ? new Error('fixture lifecycle failed') + : null; + const gate = h.gate(failure || (outcome === 'false' ? false : true)); + let lateAbortObserved = false; + if (outcome === 'late-abort') { + t.after( + h.manager.subscribe((event) => { + if (event.type === 'visibility' && event.origin === 'voice') { + lateAbortObserved = true; + controller.abort(); + } + }), + ); + } + let settled = false; + // Uppercase exercises the existing case-insensitive action contract. + const work = h.run( + outcome === 'success' ? action.toUpperCase() : action, + { + signal: controller.signal, + isCurrent: () => !controller.signal.aborted, + }, + ); + void work.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + const moduleSignal = await gate.entered.promise; + assert.equal( + settled, + false, + 'the tool must wait for lifecycle completion', + ); + if (outcome === 'caller-abort') { + controller.abort(); + assert.ok( + moduleSignal, + 'the manager must receive a lifecycle signal', + ); + assert.equal( + moduleSignal.aborted, + true, + 'caller abort must reach the manager lifecycle', + ); + } + gate.release.resolve(); + const result = await work; + const committed = outcome === 'success' || outcome === 'late-abort'; + const cancelled = + outcome === 'caller-abort' || outcome === 'resource-abort'; + assert.equal(result.ok, committed); + assert.equal(result.cancelled, cancelled ? true : undefined); + assertLifecycle( + result, + h.manager, + committed ? targetEnabled : !targetEnabled, + !targetEnabled && (outcome === 'false' || outcome === 'throw'), + ); + assert.equal( + h.moduleEnabled, + committed ? targetEnabled : !targetEnabled, + 'the module must match the committed or compensated manager state', + ); + if (cancelled) { + assert.equal(result.cancellationReason, outcome); + assert.equal(result.phase, action); + assert.equal(result.successorIntentEpoch, null); + assert.equal(result.successorEnabled, null); + assert.equal(result.successorOrigin, null); + } else if (!committed) { + assert.equal(result.error, `Could not ${action} the requested layer`); + } + const terminal = h.events.filter( + (event) => event.origin === 'voice' && terminalTypes.has(event.type), + ); + assert.equal( + terminal.length, + 1, + 'each voice intent has one terminal manager event', + ); + assert.equal( + terminal[0].type, + committed + ? 'visibility' + : cancelled + ? 'visibility-cancelled' + : 'visibility-failed', + ); + if (outcome === 'resource-abort') + assert.equal(controller.signal.aborted, false); + if (outcome === 'late-abort') assert.equal(lateAbortObserved, true); + }, + ); + } + + test( + `CCTV ${action}: already-aborted caller cannot enter lifecycle work`, + { timeout: 2000 }, + async (t) => { + const h = await lifecycleFixture(t, targetEnabled); + const controller = new AbortController(); + controller.abort(); + const result = await h.run(action, { signal: controller.signal }); + assert.equal(result.ok, false); + assert.equal(result.cancelled, true); + assert.equal(result.cancellationReason, 'caller-abort'); + assert.equal(result.phase, 'queued'); + assertLifecycle(result, h.manager, !targetEnabled); + assert.equal(h.moduleEnabled, !targetEnabled); + assert.deepEqual(h.calls, []); + assert.equal( + h.events.filter((event) => terminalTypes.has(event.type)).length, + 1, + ); + }, + ); +} + +test( + 'CCTV toggles retain missing-module errors without calling visibility', + { timeout: 2000 }, + async (t) => { + for (const layers of [new Map(), new Map([['cctv', {}]])]) { + const runner = runnerFixture(t, { + layers, + getAll: () => [], + setEnabled: () => + assert.fail('missing CCTV must not dispatch visibility'), + }); + for (const action of ['enable', 'disable']) { + assert.deepEqual(await runner('control_cctv', { action }), { + ok: false, + action: 'control_cctv', + error: 'CCTV layer unavailable', + }); + } + } + }, +); + +test( + 'CCTV fallback managers report rejection, transitional state, and uncertainty', + { timeout: 2000 }, + async (t) => { + for (const enabled of [true, false]) { + const action = enabled ? 'enable' : 'disable'; + for (const mode of [ + 'rejection', + 'mismatch', + 'transitional', + 'uncertain', + ]) { + const actualEnabled = + mode === 'rejection' || mode === 'mismatch' ? !enabled : enabled; + const lifecycleState = + mode === 'transitional' + ? enabled + ? 'enabling' + : 'disabling' + : actualEnabled + ? 'enabled' + : 'disabled'; + const manager = { + layers: new Map([['cctv', { module: {} }]]), + getAll: () => [{ id: 'cctv', name: 'CCTV' }], + isEnabled: () => actualEnabled, + getLayerLifecycleState: () => ({ + enabled: actualEnabled, + lifecycleState, + uncertain: mode === 'uncertain', + }), + async setEnabled() { + if (mode === 'rejection') throw new Error('adapter rejected'); + return true; + }, + }; + const runner = runnerFixture(t, manager); + const result = await runner('control_cctv', { action }); + assert.equal(result.ok, false, `${action}: ${mode}`); + assert.equal(result.action, 'control_cctv'); + assert.equal(result.enabled, actualEnabled); + assert.equal(result.lifecycleState, lifecycleState); + assert.equal(result.lifecycleUncertain, mode === 'uncertain'); + assert.equal( + result.error, + mode === 'rejection' + ? 'adapter rejected' + : `Could not ${action} the requested layer`, + ); + } + } + }, +); + +test( + 'CCTV visibility composes only its outer speech envelope', + { timeout: 2000 }, + async (t) => { + let enabled = false; + const calls = []; + const runner = runnerFixture( + t, + { + layers: new Map([['cctv', { module: {} }]]), + getAll: () => [{ id: 'cctv', name: 'CCTV' }], + isEnabled: () => enabled, + async setEnabled(_id, value) { + enabled = value; + return true; + }, + }, + { + speechBuilders: { + control_cctv(result, args) { + calls.push(['cctv', result.action, args.action]); + return { + say: 'CCTV response', + display: { title: 'CCTV', lines: [] }, + }; + }, + set_layer_visibility() { + calls.push(['generic']); + return { say: 'Layer response' }; + }, + }, + }, + ); + const result = await runner('control_cctv', { action: 'enable' }); + assert.equal(result.ok, true); + assert.equal(result.action, 'control_cctv'); + assert.equal(result.say, 'CCTV response'); + assert.deepEqual(result.display, { title: 'CCTV', lines: [] }); + assert.deepEqual(calls, [['cctv', 'control_cctv', 'enable']]); + }, +); + +test( + 'CCTV non-toggle actions retain camera navigation and durable coverage', + { timeout: 2000 }, + async (t) => { + const calls = []; + const cameras = [ + { id: 'a', name: 'Camera A' }, + { id: 'b', name: 'Camera B' }, + ]; + let activeCameraId = 'a'; + let coverageMode = 'viewshed'; + const module = { + getUIState: () => ({ + cameras, + activeCameraId, + activeCamera: cameras.find((camera) => camera.id === activeCameraId), + coverageMode, + showCoverage: coverageMode !== 'off', + }), + selectCamera(id) { + calls.push(['select', id]); + activeCameraId = id; + return true; + }, + cycleCamera(step) { + calls.push(['cycle', step]); + activeCameraId = step > 0 ? 'b' : 'a'; + return activeCameraId; + }, + focusNearest(options) { + calls.push(['nearest', options]); + activeCameraId = 'a'; + return 'a'; + }, + focusCamera(id, duration) { + calls.push(['focus', id, duration]); + return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW; + }, + }; + let superseded = 0; + const runner = runnerFixture( + t, + { + layers: new Map([['cctv', { module }]]), + isEnabled: () => true, + setEnabled: () => + assert.fail('non-toggle action must not dispatch visibility'), + setLayerParams(id, params, options) { + calls.push(['params', id, params, options]); + coverageMode = params.coverageMode; + }, + }, + { + styleManager: { + supersedeDeferredNavigation() { + superseded += 1; + }, + }, + }, + ); + for (const args of [ + { action: 'select', cameraQuery: 'Camera B' }, + { action: 'next' }, + { action: 'prev' }, + { action: 'nearest' }, + { action: 'focus' }, + ]) { + const result = await runner('control_cctv', args); + assert.equal(result.ok, false); + assert.equal(result.action, 'control_cctv'); + assert.match(result.error, /tracking holds the view/); + } + const coverage = await runner('control_cctv', { action: 'coverage' }); + assert.equal(coverage.ok, true); + assert.equal(coverage.coverageMode, 'off'); + assert.equal(superseded, 5, 'coverage must not acquire camera navigation'); + assert.deepEqual(calls, [ + ['select', 'b'], + ['focus', 'b', 1.8], + ['cycle', 1], + ['focus', 'b', 1.8], + ['cycle', -1], + ['focus', 'a', 1.8], + ['nearest', { focus: false }], + ['focus', 'a', 1.8], + ['focus', 'a', undefined], + ['params', 'cctv', { coverageMode: 'off' }, { origin: 'voice' }], + ]); + }, +); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index db089cecf..a23f9751e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1153,6 +1153,18 @@ export function createGevActionRunner({ } if (name === 'control_cctv') { + const action = String(args.action || '').toLowerCase(); + if ( + (action === 'enable' || action === 'disable') && + dataManager.layers.get('cctv')?.module + ) { + const result = await runGevAction( + 'set_layer_visibility', + { layerId: 'cctv', enabled: action === 'enable' }, + runOptions, + ); + return { ...result, action: 'control_cctv' }; + } return controlCctv(dataManager, args, styleManager); } From e20e858ba8ed23a1a423c2e2ae4b15fcfa3ec3e5 Mon Sep 17 00:00:00 2001 From: manjunath Date: Sun, 18 Oct 2026 11:15:09 +0530 Subject: [PATCH 02/21] docs(changelog): record cctv routing fix --- changelog.d/1205-cctv-tool-routing.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1205-cctv-tool-routing.md diff --git a/changelog.d/1205-cctv-tool-routing.md b/changelog.d/1205-cctv-tool-routing.md new file mode 100644 index 000000000..ed7552591 --- /dev/null +++ b/changelog.d/1205-cctv-tool-routing.md @@ -0,0 +1,3 @@ +- Report actual outcomes when voice enables or disables CCTV through the shared + layer lifecycle, including failed and cancelled changes (manjunath22466, + #1205). From 48c8a64c973b68135a3c58bf772a6f89c59f85a0 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:36:10 -0700 Subject: [PATCH 03/21] fix(cctv): Ontario 511 cameras take the developer key the API now requires The Ontario 511 camera list answered 400 "Invalid Key" on every catalog load ("[CCTV] Ontario 511 camera download failed: 400"), so the pack loaded nothing. 511on.ca's developer docs now say the API "Requires a developer key" (free, from 511on.ca/developers; 10 calls a minute). The loader reads ONTARIO_511_API_KEY and sends it as `key`; without it the pack is skipped with one log line instead of a failed request on every load. The key is never logged. .env.example and DATA_SOURCES no longer call the endpoint keyless. Co-Authored-By: Claude Opus 5.5 --- .env.example | 1 + DATA_SOURCES.md | 2 +- server/providers/cctv/ontario511.test.mjs | 65 +++++++++++++++++++++++ server/providers/cctv/sources.js | 34 +++++++++--- 4 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 server/providers/cctv/ontario511.test.mjs diff --git a/.env.example b/.env.example index a2f8afa24..6731e449e 100644 --- a/.env.example +++ b/.env.example @@ -201,6 +201,7 @@ AISSTREAM_API_KEY= # CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams # CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL # CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras +# ONTARIO_511_API_KEY= # free developer key from 511on.ca/developers; the camera list needs it # CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511 # CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack # CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index d930bcb8f..558d805fe 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -152,7 +152,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. - **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover. -- **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. +- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`, from 511on.ca/developers; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. - **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms. - **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch. - **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel. diff --git a/server/providers/cctv/ontario511.test.mjs b/server/providers/cctv/ontario511.test.mjs new file mode 100644 index 000000000..d654cf8be --- /dev/null +++ b/server/providers/cctv/ontario511.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { loadOntarioSourcesFromOpenData } from './sources.js'; + +const ROW = { + Id: '42', + Latitude: 43.6532, + Longitude: -79.3832, + Location: 'Hwy 401 near Yonge St', + Direction: 'Eastbound', + Views: [ + { + Status: 'Enabled', + Url: 'https://511on.ca/map/Cctv/4201', + Description: 'Eastbound', + }, + ], +}; + +test('without ONTARIO_511_API_KEY the Ontario pack is skipped, logged once', async (t) => { + const warnings = []; + t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' '))); + t.mock.method(globalThis, 'fetch', async () => + assert.fail('no request without a key'), + ); + assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []); + assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /ONTARIO_511_API_KEY/); +}); + +test('with a key the camera list is requested with it and parsed', async (t) => { + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return Response.json([ROW]); + }); + const cameras = await loadOntarioSourcesFromOpenData( + { sourceRoot: '/ignored' }, + { ONTARIO_511_API_KEY: ' test-key ' }, + ); + const url = new URL(requested[0]); + assert.equal(url.host, '511on.ca'); + assert.equal(url.searchParams.get('key'), 'test-key'); + assert.equal(url.searchParams.get('format'), 'json'); + assert.equal(cameras.length, 1); + assert.equal(cameras[0].id, 'on-42'); + assert.equal(cameras[0].url, 'https://511on.ca/map/Cctv/4201'); +}); + +test('a rejected key is reported by status, never by the key itself', async (t) => { + const warnings = []; + t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' '))); + t.mock.method(globalThis, 'fetch', async () => + Response.json({ Message: 'Invalid Key' }, { status: 400 }), + ); + const cameras = await loadOntarioSourcesFromOpenData( + {}, + { ONTARIO_511_API_KEY: 'secret-key-value' }, + ); + assert.deepEqual(cameras, []); + assert.match(warnings.join('\n'), /400/); + assert.doesNotMatch(warnings.join('\n'), /secret-key-value/); +}); diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index c3d11e752..19307aaea 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -502,17 +502,39 @@ function pickOntarioCctvView(views) { ); } +let ontarioKeyMissingLogged = false; + /** - * Fetch Ontario 511 CCTV cameras. Keyless: the catalog is exposed by the - * public 511 API, while frame URLs are stable still-image endpoints under - * 511on.ca/map/Cctv/. Only rows with finite Ontario coords and at least one - * enabled official still view are kept. + * Fetch Ontario 511 CCTV cameras. The camera list comes from the 511 API, + * which now requires a free developer key (511on.ca/developers, "Requires a + * developer key"; unkeyed calls answer 400 "Invalid Key"), read from + * ONTARIO_511_API_KEY. Without one the pack is skipped with a single log line. + * Frame URLs are stable still-image endpoints under 511on.ca/map/Cctv/. Only + * rows with finite Ontario coords and at least one enabled official still + * view are kept. * + * @param {object} [_options] The catalog's loader options (unused). + * @param {object} [env] Environment to read the key from. * @returns {Promise>} Normalized camera source objects. */ -export async function loadOntarioSourcesFromOpenData() { +export async function loadOntarioSourcesFromOpenData( + _options = {}, + env = process.env, +) { + const key = String(env.ONTARIO_511_API_KEY ?? '').trim(); + if (!key) { + if (!ontarioKeyMissingLogged) { + ontarioKeyMissingLogged = true; + console.warn( + '[CCTV] Ontario 511 cameras skipped: the 511on.ca API needs a free developer key (set ONTARIO_511_API_KEY)', + ); + } + return []; + } try { - const resp = await fetch(ONTARIO_511_CAMERAS_URL, { + const url = new URL(ONTARIO_511_CAMERAS_URL); + url.searchParams.set('key', key); + const resp = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), }); From fa085cbd648460b14f9210717985112d7ab924f0 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:37:34 -0700 Subject: [PATCH 04/21] docs(cctv): point to the 511on.ca pages that exist for the Ontario key 511on.ca/developers is a 404. Sign up at 511on.ca/my511/register, then request the key while logged in at 511on.ca/developers/doc (both checked). Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 +- DATA_SOURCES.md | 2 +- server/providers/cctv/sources.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 6731e449e..520b95e96 100644 --- a/.env.example +++ b/.env.example @@ -201,7 +201,7 @@ AISSTREAM_API_KEY= # CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams # CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL # CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras -# ONTARIO_511_API_KEY= # free developer key from 511on.ca/developers; the camera list needs it +# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc # CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511 # CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack # CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 558d805fe..8034a7f6d 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -152,7 +152,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. - **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover. -- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`, from 511on.ca/developers; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. +- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. - **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms. - **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch. - **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel. diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 19307aaea..7c048c68f 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -506,7 +506,7 @@ let ontarioKeyMissingLogged = false; /** * Fetch Ontario 511 CCTV cameras. The camera list comes from the 511 API, - * which now requires a free developer key (511on.ca/developers, "Requires a + * which now requires a free developer key (511on.ca/developers/doc, "Requires a * developer key"; unkeyed calls answer 400 "Invalid Key"), read from * ONTARIO_511_API_KEY. Without one the pack is skipped with a single log line. * Frame URLs are stable still-image endpoints under 511on.ca/map/Cctv/. Only From cd0956eef7b2f064d09cd7d0b14b89f55712fce1 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:46:13 -0700 Subject: [PATCH 05/21] docs(changelog): fragment for #1228 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1228-cctv-ontario-511-key.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1228-cctv-ontario-511-key.md diff --git a/changelog.d/1228-cctv-ontario-511-key.md b/changelog.d/1228-cctv-ontario-511-key.md new file mode 100644 index 000000000..d9dc7986f --- /dev/null +++ b/changelog.d/1228-cctv-ontario-511-key.md @@ -0,0 +1,4 @@ +- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a + free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is + otherwise skipped with one log line saying where to get a key + (quanticsoul4772, #1228). From 91461381ee8708c366ff5f4e144083774c5488f5 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:43:15 -0700 Subject: [PATCH 06/21] fix(cctv): point a rejected Ontario key at ONTARIO_511_API_KEY; README notes the key A wrong or revoked key logged only "download failed: 400"; a 400, 401 or 403 now adds "(check ONTARIO_511_API_KEY)". The README's CCTV row listed Ontario among the default cameras with no mention of the key it now needs. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- server/providers/cctv/ontario511.test.mjs | 2 +- server/providers/cctv/sources.js | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c49c53ece..1be14d337 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **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 | 🟢 | diff --git a/server/providers/cctv/ontario511.test.mjs b/server/providers/cctv/ontario511.test.mjs index d654cf8be..d0cb1f73e 100644 --- a/server/providers/cctv/ontario511.test.mjs +++ b/server/providers/cctv/ontario511.test.mjs @@ -60,6 +60,6 @@ test('a rejected key is reported by status, never by the key itself', async (t) { ONTARIO_511_API_KEY: 'secret-key-value' }, ); assert.deepEqual(cameras, []); - assert.match(warnings.join('\n'), /400/); + assert.match(warnings.join('\n'), /400 \(check ONTARIO_511_API_KEY\)/); assert.doesNotMatch(warnings.join('\n'), /secret-key-value/); }); diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 7c048c68f..fe8b150b7 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -539,7 +539,14 @@ export async function loadOntarioSourcesFromOpenData( signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), }); if (!resp.ok) { - console.warn('[CCTV] Ontario 511 camera download failed:', resp.status); + // The API answers 400 "Invalid Key" to a wrong or revoked key. + console.warn( + '[CCTV] Ontario 511 camera download failed:', + resp.status, + resp.status === 400 || resp.status === 401 || resp.status === 403 + ? '(check ONTARIO_511_API_KEY)' + : '', + ); return []; } const rows = await resp.json(); From 83b99407330bcf89025e25564f36c2f420e4bbff Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:48:02 -0700 Subject: [PATCH 07/21] docs(cctv): Ontario 511 docs match the key requirement and the developer agreement Review fixes for #1228: CURRENT-STATE.md and the constants comment no longer call Ontario 511 keyless. DATA_SOURCES.md notes Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario, key requests are reviewed case by case, attribution by link is encouraged and the logo rules don't apply because the app shows none, and the "as is" disclaimer should be passed on, which the credit now does. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 2 +- docs/CURRENT-STATE.md | 6 ++++-- server/providers/cctv/constants.js | 2 +- src/data/dataCredits.js | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 8034a7f6d..f4da7f433 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -152,7 +152,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. - **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover. -- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. +- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does. - **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms. - **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch. - **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9562dc9f5..552e3d7ce 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4388,8 +4388,10 @@ silently demoting every later lookup for the session. default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of 1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego, Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default - sources. Ontario 511 (2026-09-12) adds keyless highway cameras including Kitchener-area routes - (cap 1,000, all enabled rows from the current ~944-camera catalog). All sources are RAW PRIOR + sources. Ontario 511 (2026-09-12) adds highway cameras including Kitchener-area routes + (cap 1,000, all enabled rows from the current ~944-camera catalog). The 511on.ca API now + requires a developer key, so the pack needs `ONTARIO_511_API_KEY` and is skipped without one + (#1228). All sources are RAW PRIOR poses, and the layer is stills-first. sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`, kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 20b963770..b0880533f 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -119,7 +119,7 @@ export const TFL_IMAGE_ORIGIN = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/'; export const DEFAULT_TFL_MAX_SOURCES = 250; export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 }; -/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */ +/** Ontario 511: CARS/511 camera catalog, which needs a developer key (ONTARIO_511_API_KEY); frame URLs are still images. */ export const ONTARIO_511_CAMERAS_URL = 'https://511on.ca/api/v2/get/cameras?format=json&lang=en'; export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/'; diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 8d3949351..931452068 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -239,7 +239,8 @@ export const DATA_CREDITS = [ html: 'CCTV cameras & frames (Ontario): ' + 'Ontario 511 ' + - '(Open Government Licence - Ontario)', + '(Open Government Licence - Ontario) ' + + '· provided as is, without warranty; actual road conditions may differ', }, { key: 'fintraffic-cctv', From a331616a9d31b40f7e4a642770c9ab55803b4d49 Mon Sep 17 00:00:00 2001 From: manjunath Date: Mon, 19 Oct 2026 11:01:18 +0530 Subject: [PATCH 08/21] fix(location): preserve geocoded places and region provenance --- docs/CURRENT-STATE.md | 8 + scripts/qa-voice-routing.mjs | 8 +- src/annotations/adminOutline.test.mjs | 152 +++++++++++++ src/annotations/regionRing.test.mjs | 106 +++++++++ src/annotations/resolver.js | 95 +++++++- src/data/analystEngine.js | 16 +- src/data/analystEngine.test.mjs | 303 ++++++++++++++++++++++++++ src/data/naturalEarthRegions.js | 41 ++-- src/data/naturalEarthRegions.test.mjs | 37 +++- src/locations.js | 64 +++++- src/locations.test.mjs | 202 +++++++++++++++++ 11 files changed, 999 insertions(+), 33 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9562dc9f5..fa6d301e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +Location search preserves successful geocoder results, including contemporary +places that share a Natural Earth name. After geocoding and nearby-place recovery +miss, a natural region can resolve from the current view only when exactly one +matching region contains it. Annotation outlines use the same containment rule +for view-based recovery. Analyst queries retain their offline unique-name lookup; +duplicate names use a geocoded anchor, and follow-ups retain boundary provenance. +Cancelled region lookups preserve the previous analyst cohort and boundary source. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index f1b2b0ced..1c5c2eb83 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -1745,8 +1745,9 @@ async function runBehaviorLayer() { 'behavior: analyst follow-up re-filters the remembered set', `count=${r?.count} followUp=${r?.coverage?.followUp}`, ); - // A marine region resolves from the bundled Natural Earth pack in the page, - // without the slower geocode fallback. + // Require the bundled boundary source and the existing whole-query limit. + // This elapsed time includes record acquisition and filtering; the region + // resolver has its own separate fallback budget. const gulfStarted = Date.now(); r = await run('analyst_query', { layers: ['flights'], @@ -1757,9 +1758,10 @@ async function runBehaviorLayer() { report( r?.ok === true && String(r?.coverage?.scope || '').includes('Gulf of Mexico') && + r?.coverage?.scopeSource === 'natural-earth' && gulfMs < 3000, 'behavior: analyst resolves the Gulf of Mexico from the bundled pack', - `scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`, + `scope=${r?.coverage?.scope} source=${r?.coverage?.scopeSource} ms=${gulfMs} err=${r?.error || ''}`, ); // (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band) diff --git a/src/annotations/adminOutline.test.mjs b/src/annotations/adminOutline.test.mjs index 29761a2e3..d3d1cc4b9 100644 --- a/src/annotations/adminOutline.test.mjs +++ b/src/annotations/adminOutline.test.mjs @@ -120,6 +120,123 @@ test('Georgia resolves from camera geography without a geocoder', async () => { assert.deepEqual(geocodes, []); }); +test('the Alps under the view resolve before an unrelated remote geocode', async () => { + const { resolve, lookups, geocodes } = harness({ + lat: -27.96, + lng: 152.2, + label: 'The Alps, Queensland', + name: 'The Alps', + types: ['locality', 'political'], + }); + const resolved = await resolve('the Alps', viewerAt(46.5, 10, 248_000)); + assert.equal(resolved?.source, 'bundled'); + assert.equal(resolved.naturalRegion, 'Alps'); + assert.equal(resolved.synthesized, false); + assert.equal(resolved.footprintKind, 'area'); + assert.ok(resolved.ring.length >= 8); + assert.deepEqual(resolved.ring[0], resolved.ring.at(-1)); + assert.equal(resolved.resolveOutline, undefined); + assert.deepEqual(geocodes, []); + assert.deepEqual(lookups, []); +}); + +test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => { + for (const types of [['locality', 'political'], ['natural_feature']]) { + const { resolve, geocodes } = harness({ + lat: -28.0167, + lng: 153.4, + label: 'Gold Coast, Australia', + name: 'Gold Coast', + types, + }); + const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4)); + assert.equal(resolved?.source, 'geocode'); + assert.equal(resolved.lat, -28.0167); + assert.equal(resolved.lon, 153.4); + assert.equal(resolved.naturalRegion, undefined); + const outline = await resolved.resolveOutline(); + assert.equal(outline?.naturalRegion, undefined); + assert.deepEqual(geocodes, ['Gold Coast']); + } +}); + +test('unique natural names without a containing view still need a geocoded anchor', async () => { + for (const viewer of [AUSTIN, {}]) { + const miss = harness(); + assert.equal(await miss.resolve('the Alps', viewer), null); + assert.ok(miss.geocodes.includes('the Alps')); + } + const anchored = harness({ + lat: 46.5, + lng: 10, + label: 'Alps', + name: 'Alps', + types: ['natural_feature'], + }); + const resolved = await anchored.resolve('the Alps', {}); + assert.equal(resolved.source, 'geocode'); + const outline = await resolved.resolveOutline(); + assert.equal(outline.naturalRegion, 'Alps'); + assert.ok(outline.ring.length >= 8); +}); + +test('overlapping natural candidates continue through geocoding without choosing a ring', async () => { + const { resolve, geocodes } = harness({ + lat: 45.6336, + lng: -74.86493, + label: 'Canadian Shield', + name: 'Canadian Shield', + types: ['natural_feature'], + }); + const resolved = await resolve( + 'Canadian Shield', + viewerAt(45.6336, -74.86493), + ); + assert.equal(resolved?.source, 'geocode'); + assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined); + assert.deepEqual(geocodes, ['Canadian Shield']); +}); + +test('natural view recovery preserves point, explicit-coordinate, entity and around intent', async () => { + for (const extra of [ + { footprint: false }, + { latitude: 46.5, longitude: 10 }, + { entityKind: 'building' }, + { entityKind: 'compound' }, + { entityKind: 'district' }, + { entityKind: 'street' }, + { entityKind: 'point_feature' }, + { intent: 'around_the_thing' }, + ]) { + const { resolve, geocodes } = harness({ + lat: 46.5, + lng: 10, + label: 'Alps', + name: 'Alps', + types: + extra.entityKind === 'district' ? ['locality'] : ['natural_feature'], + }); + const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra); + assert.notEqual(resolved?.source, 'bundled'); + assert.equal(resolved?.naturalRegion, undefined); + if (extra.entityKind === 'district') + assert.ok(geocodes.includes('the Alps')); + if (extra.intent === 'around_the_thing' || extra.entityKind === 'district') + assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined); + } +}); + +test('cancellation while a natural view lookup is pending cannot publish its outline', async () => { + const controller = new AbortController(); + const { resolve, geocodes } = harness(); + const pending = resolve('the Alps', viewerAt(46.5, 10), { + signal: controller.signal, + }); + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + assert.deepEqual(geocodes, []); +}); + test('country asks and geocoded country scopes use the bundled outline', async () => { const direct = harness(); for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) { @@ -234,3 +351,38 @@ test('the engine keeps every part on the mark, drawn at once or upgraded', async ); engine.destroy(); }); + +test('cancellation after the admin miss rejects the resumed natural view lookup', async () => { + const controller = new AbortController(); + const signal = controller.signal; + const throwIfAborted = signal.throwIfAborted.bind(signal); + const checkpoints = []; + let abortCheckpoint = null; + // This exact path has entry, post-admin-miss and post-natural checkpoints. + // Queue at the known post-admin checkpoint: after this callback returns, + // the real natural lookup runs synchronously to its await before the queued + // abort executes. No timeout or guessed number of promise turns is used. + Object.defineProperty(signal, 'throwIfAborted', { + value() { + checkpoints.push(signal.aborted); + throwIfAborted(); + if (checkpoints.length === 2) { + queueMicrotask(() => { + abortCheckpoint = checkpoints.length; + controller.abort(); + }); + } + }, + }); + const { resolve, lookups, geocodes } = harness(); + const pending = resolve('the Alps', viewerAt(46.5, 10), { signal }); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal( + abortCheckpoint, + 2, + 'abort follows admin miss, before natural resume', + ); + assert.deepEqual(checkpoints, [false, false, true]); + assert.deepEqual(lookups, []); + assert.deepEqual(geocodes, []); +}); diff --git a/src/annotations/regionRing.test.mjs b/src/annotations/regionRing.test.mjs index cd8ad6447..b1a5ac524 100644 --- a/src/annotations/regionRing.test.mjs +++ b/src/annotations/regionRing.test.mjs @@ -44,10 +44,115 @@ test('region ring: Natural Earth names resolve without a geocoder', async () => for (const name of ['Gulf of Mexico', 'the Alps']) { const region = await resolveRegionRingForQuery(name); assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`); + assert.equal(region.source, 'natural-earth'); assert.equal(region.error, undefined); } }); +test('region ring: duplicate natural names need a containing geocoded anchor', async () => { + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource(), + }); + const miss = geocoderFor(null); + assert.equal( + await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss), + null, + ); + assert.deepEqual(miss.calls, ['Cordillera Oriental']); + + const inside = geocoderFor({ + lat: 5, + lng: -73, + label: 'Cordillera Oriental', + name: 'Cordillera Oriental', + types: ['natural_feature'], + }); + const region = await resolveRegionRingForQuery( + 'Cordillera Oriental', + undefined, + inside, + ); + assert.equal(region?.source, 'natural-earth'); + assert.equal(region.name, 'Cordillera Oriental'); + assert.ok(region.ring.length >= 8); + assert.deepEqual(inside.calls, ['Cordillera Oriental']); +}); + +test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => { + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource(), + }); + for (const [name, lat, lng] of [ + ['Cordillera Oriental', 30.27, -97.74], + ['Canadian Shield', 45.6336, -74.86493], + ]) { + const geocoder = geocoderFor({ + lat, + lng, + label: name, + name, + types: ['natural_feature'], + }); + assert.equal( + await resolveRegionRingForQuery(name, undefined, geocoder), + null, + ); + assert.deepEqual(geocoder.calls, [name]); + } +}); + +test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => { + const lookups = []; + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource({ + getAdministrativeAreas: async (point) => { + lookups.push(point); + return []; + }, + }), + }); + const geo = geocoderFor({ + lat: 5, + lng: -73, + label: 'Cordillera Oriental', + name: 'Cordillera Oriental', + types: ['locality'], + }); + const resolved = await resolveRegionRingForQuery( + 'Cordillera Oriental', + undefined, + geo, + ); + assert.equal( + resolved, + null, + 'an unavailable city boundary is not a natural-region outline', + ); + assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]); + assert.deepEqual(geo.calls, ['Cordillera Oriental']); +}); + +test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => { + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: featureSource(), + }); + const controller = new AbortController(); + const pending = resolveRegionRingForQuery('the Alps', controller.signal); + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + + const later = new AbortController(); + await assert.rejects( + resolveRegionRingForQuery('Cordillera Oriental', later.signal, { + async geocode() { + later.abort(); + return { place: null }; + }, + }), + { name: 'AbortError' }, + ); +}); + test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => { let adminSignal; let release; @@ -119,6 +224,7 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn ]) { const region = await resolveRegionRingForQuery(query, undefined, geocoder); assert.equal(region?.name, name, query); + assert.equal(region.source, 'bundled-admin'); assert.ok(region.ring.length >= 8, `${query} has a real ring`); } assert.deepEqual(geocoder.calls, [], 'no geocoding'); diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 7d5f55d0d..ce22b44f2 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -202,6 +202,22 @@ export function createAnnotationResolver({ ); signal?.throwIfAborted(); if (admin) return bundledAdminTarget(viewer, admin, trace.query); + + // A unique pack name can still name an unrelated modern place. Recover + // natural outlines only from a containing view, including unique names. + if ( + center && + entityKind !== 'district' && + intent !== 'around_the_thing' + ) { + const natural = await lookupNaturalRegionOutline( + trace.query, + center.lat, + center.lon, + ).catch(() => null); + signal?.throwIfAborted(); + if (natural) return bundledNaturalTarget(viewer, natural, trace.query); + } } if (!Number.isFinite(lat) || !Number.isFinite(lon)) { @@ -444,6 +460,7 @@ export function createAnnotationResolver({ * AFTER the anchor mark is drawn and upgrade the mark in place. */ const resolveOutline = async () => { + signal?.throwIfAborted(); let scope = baseScope; const isAdmin = scope === 'country' || @@ -469,6 +486,7 @@ export function createAnnotationResolver({ const ne = await lookupNaturalRegionOutline(target, lat, lon).catch( () => null, ); + signal?.throwIfAborted(); if (ne) { registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT); const neCentroid = ringCentroid(ne.ring); @@ -838,6 +856,37 @@ export function createAnnotationResolver({ }; } + /** A complete annotation target for a natural ring containing the view. */ + function bundledNaturalTarget(viewer, natural, query) { + const ring = closeRing([...natural.ring]); + const centroid = ringCentroid(ring); + if (!centroid) return null; + registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT); + const lons = ring.map(([lon]) => lon); + const lats = ring.map(([, lat]) => lat); + console.log( + `[Resolver] "${query}": bundled ${natural.kind} "${natural.name}" ` + + '(natural-earth, view-contained) → FINAL source=bundled', + ); + return { + lon: centroid.lon, + lat: centroid.lat, + height: sampleGroundHeight(viewer, centroid.lon, centroid.lat), + ring, + footprintKind: 'area', + buildingHeight: null, + label: natural.name, + source: 'bundled', + synthesized: false, + outlineUnavailable: false, + naturalRegion: natural.name, + viewport: { + low: { latitude: Math.min(...lats), longitude: Math.min(...lons) }, + high: { latitude: Math.max(...lats), longitude: Math.max(...lons) }, + }, + }; + } + /** * Synthesize an approximate circular AREA by buffering a label point. Returns a ring * marked `synthesized:true` so the renderers draw it "approximate" (dashed/feathered), @@ -2004,8 +2053,8 @@ export function createAnnotationResolver({ /** * Region ring for ANALYST queries ("how many flights over Texas / the Alps") — * a name-only entry point that reuses this module's boundary machinery - * without the annotation pipeline. Natural Earth pack first (offline, - * instant; largest-area match is correct for a global name-only ask), then + * without the annotation pipeline. Unique Natural Earth names resolve first + * (offline); duplicate names require a geocoded containment anchor, then * geocode + admin boundary for states/countries/counties (Tier A disk-cached * Overpass, so repeat asks are instant). Returns null when the name doesn't * resolve to a region-like boundary — the analyst engine reports that @@ -2020,7 +2069,7 @@ export function createAnnotationResolver({ * @param {AbortSignal} [signal] * @param {object} [placeSearch] * @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup. - * @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>} + * @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>} */ async function resolveRegionRingForQuery( name, @@ -2037,16 +2086,24 @@ export function createAnnotationResolver({ const q = String(name || '').trim(); if (!q) return null; const ne = await findNaturalRegion(q).catch(() => null); - if (ne?.polygons?.length) { + signal?.throwIfAborted(); + if (ne?.candidates === 1 && ne.polygons?.length) { // Largest ring carries the query scope; multi-ring regions (Andes) keep // their main cordillera — good enough for containment counting. const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0]; - if (ring?.length >= 3) return { name: ne.name, ring }; + if (ring?.length >= 3) + return { name: ne.name, ring, source: 'natural-earth' }; } // Bundled states/provinces/counties by name (offline); the main part only, // like the Natural Earth rung above. const admin = await findAdminArea(q).catch(() => null); - if (admin) return { name: admin.name, ring: [...admin.ring] }; + signal?.throwIfAborted(); + if (admin) + return { + name: admin.name, + ring: [...admin.ring], + source: 'bundled-admin', + }; const lookup = resolveAdminRegionRing(q, signal, placeSearch); if (!Number.isFinite(budgetMs)) return lookup; let timer; @@ -2067,8 +2124,26 @@ export function createAnnotationResolver({ const geo = await geocodePlace(q, null, signal, placeSearch).catch( () => null, ); + signal?.throwIfAborted(); if (!geo) return null; const scope = scopeFromTypes(geo.types); + if ( + scope === 'auto' || + (scope === 'compound' && geo.types?.includes('natural_feature')) + ) { + const natural = await lookupNaturalRegionOutline( + q, + geo.lat, + geo.lon, + ).catch(() => null); + signal?.throwIfAborted(); + if (natural) + return { + name: natural.name, + ring: natural.ring, + source: 'natural-earth', + }; + } if (!['country', 'state', 'county', 'city'].includes(scope)) return null; if (scope === 'state' || scope === 'county' || scope === 'country') { const admin = await findAdminAreaAt( @@ -2077,12 +2152,16 @@ export function createAnnotationResolver({ geo.lon, scope, ).catch(() => null); - if (admin) return { name: q, ring: [...admin.ring] }; + signal?.throwIfAborted(); + if (admin) + return { name: q, ring: [...admin.ring], source: 'bundled-admin' }; } const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch( () => null, ); - if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring }; + signal?.throwIfAborted(); + if (fp?.ring?.length >= 3) + return { name: q, ring: fp.ring, source: 'remote-boundary' }; return null; } diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 6c3361aff..59a631421 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -881,6 +881,7 @@ export function createAnalystEngine(providers) { followUp && !spec.scope ? lastResult.distanceOf || null : null; let resolvedScope = null; let scopeNote = rememberedScope?.coverage || 'anywhere'; + let scopeSource = rememberedScope?.source || null; // Human phrasing for the same scope, so every spoken count can name what it // measured ("8 in view", "about 30 within 250 km of Austin") instead of // arriving as a bare number that contradicts the panel. @@ -890,8 +891,16 @@ export function createAnalystEngine(providers) { // further when a new scope is given. const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' }; if (scope.kind === 'region') { - const region = await providers.resolveRegionRing(scope.name, signal); - if (!isCurrent()) return cancelled(); + let region; + try { + region = await providers.resolveRegionRing(scope.name, signal); + } catch (error) { + // Cancellation belongs to the query owner, not an error's name: a + // resource-local AbortError from a current lookup still propagates. + if (signal?.aborted || !isCurrent()) return cancelled(); + throw error; + } + if (signal?.aborted || !isCurrent()) return cancelled(); if (region?.error === 'region-timeout') { return { ok: false, @@ -910,6 +919,7 @@ export function createAnalystEngine(providers) { } resolvedScope = region; scopeNote = `region:${region.name}`; + scopeSource = region.source || null; scopeLabel = `over ${region.name}`; scopeDetail = scopeLabel; } else if (scope.kind === 'radius') { @@ -1049,6 +1059,7 @@ export function createAnalystEngine(providers) { layersQueried, records: totals, scope: scopeNote, + ...(scopeSource ? { scopeSource } : {}), ...(rememberedScope?.note ? { note: rememberedScope.note } : {}), ...(queriedSnapshots.length ? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) } @@ -1081,6 +1092,7 @@ export function createAnalystEngine(providers) { label: memoryOverride?.scopeLabel || result.scopeLabel, detail: memoryOverride?.scopeDetail || result.display.scope, coverage: committedCoverage.scope, + source: committedCoverage.scopeSource || null, note: committedCoverage.note || null, }, }; diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 8da85a4d6..b7aaa2a45 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -1,6 +1,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js'; +import { createAnnotationResolver } from '../annotations/resolver.js'; +import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js'; // Stub world: a square "Texland" region, flights + ships + fires around it. const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] }; @@ -131,6 +133,175 @@ test('analyst: count flights over a region', async () => { assert.ok(r.coverage.scope.includes('Texland')); }); +test('analyst: region coverage identifies the source of the boundary used for counting', async () => { + for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) { + const engine = createAnalystEngine({ + getRecords: () => FLIGHTS, + resolveRegionRing: async () => ({ ...TEXLAND, source }), + }); + const result = await engine.query({ + layers: ['flights'], + scope: { kind: 'region', name: 'Texland' }, + }); + + assert.equal(result.ok, true); + assert.equal(result.count, 3, 'the boundary excludes the Oregon flight'); + assert.equal(result.coverage.scope, 'region:Texland'); + assert.equal(result.coverage.scopeSource, source); + } +}); + +test('analyst: filter-only follow-ups retain the region source without resolving again', async () => { + const lookups = []; + const engine = createAnalystEngine({ + getRecords: () => FLIGHTS, + resolveRegionRing: async (name) => { + lookups.push(name); + return { ...TEXLAND, source: 'natural-earth' }; + }, + }); + const first = await engine.query({ + layers: ['flights'], + scope: { kind: 'region', name: 'Texland' }, + }); + const airborne = await engine.query({ + followUp: true, + filters: [{ field: 'onGround', op: 'eq', value: false }], + }); + const military = await engine.query({ + followUp: true, + filters: [{ field: 'military', op: 'eq', value: true }], + }); + + assert.equal(first.count, 3); + assert.equal(airborne.count, 2); + assert.deepEqual(military.items.map((item) => item.id), ['RCH01']); + for (const result of [first, airborne, military]) { + assert.equal(result.coverage.scope, 'region:Texland'); + assert.equal(result.scopeLabel, 'over Texland'); + assert.equal(result.coverage.scopeSource, 'natural-earth'); + } + assert.deepEqual(lookups, ['Texland']); +}); + +test('analyst: an explicit replacement region replaces retained provenance', async () => { + const engine = createAnalystEngine({ + getRecords: () => FLIGHTS, + resolveRegionRing: async (name) => name === 'Texland' + ? { ...TEXLAND, source: 'natural-earth' } + : { + name, + ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]], + source: 'remote-boundary', + }, + }); + await engine.query({ + layers: ['flights'], + scope: { kind: 'region', name: 'Texland' }, + }); + const replacement = await engine.query({ + followUp: true, + scope: { kind: 'region', name: 'Northern Texland' }, + }); + const followUp = await engine.query({ followUp: true }); + + for (const result of [replacement, followUp]) { + assert.deepEqual(result.items.map((item) => item.id), ['RCH01']); + assert.equal(result.coverage.scope, 'region:Northern Texland'); + assert.equal(result.coverage.scopeSource, 'remote-boundary'); + } +}); + +test('analyst: an explicit scope without provenance clears the retained region source', async () => { + for (const scope of [ + { kind: 'anywhere' }, + { kind: 'view' }, + { kind: 'radius', km: 150 }, + { kind: 'region', name: 'Uncredited Texland' }, + ]) { + const engine = createAnalystEngine({ + getRecords: () => FLIGHTS, + resolveRegionRing: async (name) => ({ + ...TEXLAND, + name, + ...(name === 'Texland' ? { source: 'natural-earth' } : {}), + }), + getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), + }); + const first = await engine.query({ + layers: ['flights'], + scope: { kind: 'region', name: 'Texland' }, + }); + const replacement = await engine.query({ followUp: true, scope }); + const followUp = await engine.query({ followUp: true }); + + assert.equal(first.coverage.scopeSource, 'natural-earth'); + for (const result of [replacement, followUp]) { + assert.equal(result.ok, true, scope.kind); + assert.equal(result.count, 3, scope.kind); + assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind); + assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind); + } + } +}); + +for (const [label, committedScope, scopeSource] of [ + ['replacement boundary source', 'region:Presented Texland', 'remote-boundary'], + ['window without a boundary source', 'window:presented', null], +]) { + test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => { + const committedSnapshot = { + id: 'flights', enabled: true, feedState: 'stale', + source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000, + }; + let reads = 0; + let lookups = 0; + const engine = createAnalystEngine({ + getRecords: () => { reads++; return FLIGHTS; }, + getLayerSnapshot: () => ({ + id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed', + }), + resolveRegionRing: async () => { + lookups++; + return { ...TEXLAND, source: 'natural-earth' }; + }, + }); + await engine.query( + { layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }, + { + beforeCommit: async (result) => { + assert.equal(result.count, 3); + assert.equal(result.coverage.scopeSource, 'natural-earth'); + const { scopeSource: _originalSource, ...coverage } = result.coverage; + return { + matched: [{ key: 'flights', rows: [FLIGHTS[1]] }], + scopeLabel: 'the presented cohort', + scopeDetail: 'the presented cohort window', + coverage: { + ...coverage, + scope: committedScope, + ...(scopeSource ? { scopeSource } : {}), + feedProvenance: { layers: [committedSnapshot] }, + }, + }; + }, + }, + ); + const followUp = await engine.query({ followUp: true }); + assert.equal(followUp.count, 1); + assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']); + assert.equal(followUp.scopeLabel, 'the presented cohort'); + assert.equal(followUp.display.scope, 'the presented cohort window'); + assert.equal(followUp.coverage.scope, committedScope); + if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource); + else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false); + assert.equal(followUp.coverage.feedProvenance.overall, 'stale'); + assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]); + assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read'); + assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again'); + }); +} + test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => { const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, @@ -317,6 +488,138 @@ test('analyst: region lookup receives and obeys the owning action signal', async assert.equal(result.cancelled, true); }); +for (const [label, reason, trackAuthority] of [ + ['default abort reason', undefined, false], + ['custom error reason', new Error('operator stopped'), false], + ['primitive reason and revoked authority', 'operator stopped', true], +]) { + test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => { + const started = Promise.withResolvers(); + const geocode = Promise.withResolvers(); + const controller = new AbortController(); + const lookups = []; + const { resolveRegionRingForQuery } = createAnnotationResolver({ + featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [ + method, + async () => { throw new Error('offline region query reached feature source'); }, + ])), + }); + const placeSearch = { + geocode(name, { signal }) { + lookups.push(name); + assert.equal(signal, controller.signal); + started.resolve(); + return geocode.promise; + }, + }; + const engine = createAnalystEngine({ + getRecords: () => FLIGHTS, + resolveRegionRing: (name, signal) => resolveRegionRingForQuery( + name, signal, placeSearch, { budgetMs: Infinity }, + ), + }); + const first = await engine.query({ + layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, + }); + assert.equal(first.ok, true); + assert.equal(first.coverage.scopeSource, 'bundled-admin'); + assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']); + let commits = 0; + const pending = engine.query( + { layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } }, + { + signal: controller.signal, + ...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}), + beforeCommit: async () => { commits++; }, + }, + ); + await started.promise; + controller.abort(reason); + // Let an actual late geocode result cross the resolver's post-await check. + geocode.resolve({ place: { + lat: 5, lng: -73, label: 'Cordillera Oriental', + name: 'Cordillera Oriental', types: ['natural_feature'], + } }); + const result = await pending; + assert.equal(result.ok, false); + assert.equal(result.code, 'CANCELLED'); + assert.equal(result.cancelled, true); + assert.equal(commits, 0); + const followUp = await engine.query({ followUp: true }); + assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id)); + assert.equal(followUp.coverage.scope, first.coverage.scope); + assert.equal(followUp.coverage.scopeSource, 'bundled-admin'); + assert.equal(followUp.scopeLabel, first.scopeLabel); + assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again'); + }); +} + +test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => { + const lookup = Promise.withResolvers(); + let current = true; + const engine = createAnalystEngine({ + getRecords: () => FLIGHTS, + resolveRegionRing: (name) => name === 'Texland' + ? { ...TEXLAND, source: 'natural-earth' } + : lookup.promise, + }); + const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }); + const pending = engine.query( + { layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } }, + { isCurrent: () => current }, + ); + current = false; + lookup.reject(new Error('late lookup failed')); + assert.equal((await pending).code, 'CANCELLED'); + const followUp = await engine.query({ followUp: true }); + assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id)); + assert.equal(followUp.coverage.scopeSource, 'natural-earth'); + assert.equal(followUp.coverage.scope, first.coverage.scope); +}); + +test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => { + const controller = new AbortController(); + const lookup = Promise.withResolvers(); + const engine = createAnalystEngine({ + getRecords: () => FLIGHTS, + resolveRegionRing: () => lookup.promise, + }); + const pending = engine.query( + { layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }, + { signal: controller.signal }, + ); + controller.abort(); + lookup.resolve({ ...TEXLAND, source: 'natural-earth' }); + assert.equal((await pending).code, 'CANCELLED'); + assert.equal(engine.hasMemory(), false); +}); + +for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) { + test(`analyst: live region ${error.name} propagates without changing memory`, async () => { + const controller = new AbortController(); + const engine = createAnalystEngine({ + getRecords: () => FLIGHTS, + resolveRegionRing: async (name) => { + if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' }; + throw error; + }, + }); + const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } }); + await assert.rejects( + engine.query( + { layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } }, + { signal: controller.signal }, + ), + (thrown) => thrown === error, + ); + assert.equal(controller.signal.aborted, false); + const followUp = await engine.query({ followUp: true }); + assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id)); + assert.equal(followUp.coverage.scope, first.coverage.scope); + assert.equal(followUp.coverage.scopeSource, 'natural-earth'); + }); +} + test('analyst: route fields queryable from cached enrichment only', async () => { const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'anywhere' }, diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js index c40e180ac..a060d2260 100644 --- a/src/data/naturalEarthRegions.js +++ b/src/data/naturalEarthRegions.js @@ -189,7 +189,7 @@ const loadIndex = createRetryableLoader(async () => { return index; }); -function toResult(entry) { +function toResult(entry, candidates = 1) { return { name: entry.name, featurecla: entry.featurecla, @@ -197,6 +197,7 @@ function toResult(entry) { polygons: entry.polygons, bboxDiagonalKm: entry.bboxDiagonalKm, areaKm2: entry.areaKm2, + candidates, }; } @@ -209,7 +210,9 @@ function toResult(entry) { * @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico" * @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine', * polygons:Array>, bboxDiagonalKm:number, - * areaKm2:number}|null>} largest-area match, or null when nothing matches. + * areaKm2:number, candidates:number}|null>} largest-area match plus the + * number of same-name entries, or null when nothing matches. Uniqueness in + * this pack alone does not establish the user's intended place. */ export async function findNaturalRegion(query) { const norm = normalizeName(query); @@ -223,7 +226,7 @@ export async function findNaturalRegion(query) { for (const key of candidates) { if (!key) continue; const list = index.get(key); - if (list && list.length) return toResult(list[0]); + if (list && list.length) return toResult(list[0], list.length); } return null; } @@ -270,9 +273,8 @@ export function pointInRing(ring, lat, lon) { /** * Resolve a natural-region OUTLINE ring for the annotation resolver's first * rung. Stricter than `findNaturalRegion`: walks ALL entries sharing the - * matched name (duplicate names included — the US and Spanish "Sierra - * Nevada" both exist upstream) and returns the single ring that CONTAINS the - * geocoded anchor. Containment is simultaneously the disambiguator and the + * matched name (duplicate names included) and returns a ring only when exactly + * one indexed entry CONTAINS the anchor. Containment is the disambiguator and the * wrong-place guard: when no ring contains the anchor this returns null and * the resolver's normal ladder continues unchanged. * @@ -292,24 +294,27 @@ export async function lookupNaturalRegionOutline(query, lat, lon) { ...suffixVariants(ALIASES[norm] || norm), ]; const seen = new Set(); + const containingEntries = new Map(); for (const key of candidates) { if (!key || seen.has(key)) continue; seen.add(key); for (const entry of index.get(key) || []) { - for (const ring of entry.polygons) { - if (pointInRing(ring, lat, lon)) { - return { - name: entry.name, - kind: entry.kind, - featurecla: entry.featurecla, - ring, - areaKm2: entry.areaKm2, - }; - } - } + if (containingEntries.has(entry)) continue; + const ring = entry.polygons.find((candidate) => + pointInRing(candidate, lat, lon), + ); + if (ring) containingEntries.set(entry, ring); } } - return null; + if (containingEntries.size !== 1) return null; + const [[entry, ring]] = containingEntries; + return { + name: entry.name, + kind: entry.kind, + featurecla: entry.featurecla, + ring, + areaKm2: entry.areaKm2, + }; } /** Resolve the smallest containing bundled physical region without a network geocoder. */ diff --git a/src/data/naturalEarthRegions.test.mjs b/src/data/naturalEarthRegions.test.mjs index 5dd78adea..9d58fa6e0 100644 --- a/src/data/naturalEarthRegions.test.mjs +++ b/src/data/naturalEarthRegions.test.mjs @@ -33,7 +33,10 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)' }); test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => { - assert.equal((await findNaturalRegion('the Alps'))?.name, 'Alps'); + const alps = await findNaturalRegion('the Alps'); + assert.equal(alps?.name, 'Alps'); + assert.equal(alps?.candidates, 1); + assert.equal((await findNaturalRegion('Alps mountains'))?.name, 'Alps'); assert.equal((await findNaturalRegion('Rockies'))?.name, 'Rocky Mountains'); assert.equal((await findNaturalRegion('the Rockies'))?.name, 'Rocky Mountains'); assert.equal((await findNaturalRegion('Sahara Desert'))?.name, 'Sahara'); @@ -41,6 +44,11 @@ test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', assert.equal((await findNaturalRegion(' THE ALPS '))?.name, 'Alps'); }); +test('duplicate natural-region names expose ambiguity instead of claiming uniqueness', async () => { + assert.ok((await findNaturalRegion('Cordillera Oriental'))?.candidates > 1); + assert.ok((await findNaturalRegion('Canadian Shield'))?.candidates > 1); +}); + test('major deserts/ranges resolve with sane areas', async () => { const sahara = await findNaturalRegion('Sahara'); assert.ok(sahara && sahara.areaKm2 > 8_000_000 && sahara.areaKm2 < 12_000_000, @@ -134,12 +142,39 @@ test('outline lookup: duplicate names disambiguate by anchor containment', async const us = await lookupNaturalRegionOutline('Sierra Nevada', 37.2, -119.0); assert.ok(us, 'US anchor must match a Sierra Nevada'); assert.ok(pointInRing(us.ring, 37.2, -119.0), 'returned ring contains the US anchor'); + const colombia = await lookupNaturalRegionOutline('Cordillera Oriental', 5, -73); + assert.ok(colombia, 'a single containing candidate resolves a duplicate name'); + assert.ok(pointInRing(colombia.ring, 5, -73)); +}); + +test('outline lookup: overlapping duplicate candidates remain unresolved', async () => { + assert.equal(await lookupNaturalRegionOutline('Canadian Shield', 45.6336, -74.86493), null); }); test('outline lookup: anchor outside every ring → null (wrong-place guard)', async () => { assert.equal(await lookupNaturalRegionOutline('the Alps', 30.26, -97.77), null); }); +test('outline lookup: a unique physical-region name does not override an outside contemporary place', async () => { + for (const [name, lat, lon] of [ + ['Gold Coast', -28, 153.4], + ['Santa Cruz', 37, -122], + ['Isabela', 17, 122], + ['India', 28, 95], // Northeast India lies outside the physical peninsula. + ['Korea', 33.5, 126.5], // Jeju lies outside the physical peninsula. + ]) { + assert.equal((await findNaturalRegion(name))?.candidates, 1, `${name} has a unique physical namesake`); + assert.equal(await lookupNaturalRegionOutline(name, lat, lon), null, `${name} requires containment`); + } +}); + +test('outline lookup: aliases retain containment checks', async () => { + const alps = await lookupNaturalRegionOutline('Alps mountains', 46.5, 10); + assert.equal(alps?.name, 'Alps'); + assert.ok(pointInRing(alps.ring, 46.5, 10)); + assert.equal(await lookupNaturalRegionOutline('Alps mountains', -28, 153.4), null); +}); + test('outline lookup: non-region names never match', async () => { assert.equal(await lookupNaturalRegionOutline('Zilker Park', 30.26, -97.77), null); assert.equal(await lookupNaturalRegionOutline('Texas', 31.0, -99.0), null); diff --git a/src/locations.js b/src/locations.js index d17186905..80777dbbe 100644 --- a/src/locations.js +++ b/src/locations.js @@ -7,11 +7,13 @@ import { greatCircleKm } from './geoDistance.js'; import { viewportBias, placesNearViewRecovery, + pickWorldFromScreen, } from './annotations/annotationResolver.js'; import { unavailablePlaceSearch } from './search/placeSearch.js'; import { cancelCameraArrival } from './data/cameraArrival.js'; import { guardCameraAboveGround } from './cameraGroundGuard.js'; import { CITY_POIS } from './cityPois.js'; +import { lookupNaturalRegionOutline } from './data/naturalEarthRegions.js'; /** Newest landmark arrival; an older post-arrival ground guard yields to it. */ let arrivalGeneration = 0; @@ -299,6 +301,47 @@ export function findPoiByName(query) { /** Distinguishes an authority veto from a genuine not-found result. */ export const CANCELLED_SEARCH = Object.freeze({ cancelled: true }); +/** Frame a natural ring only after its name and view containment are resolved. */ +function naturalRegionSearchPlace(region) { + const points = region?.ring || []; + if (points.length < 3) return null; + const latitudes = points.map(([, lat]) => lat).filter(Number.isFinite); + const longitudes = points + .map(([lon]) => lon) + .filter(Number.isFinite) + .map((lon) => ((lon % 360) + 360) % 360) + .sort((a, b) => a - b); + if (latitudes.length < 3 || longitudes.length < 3) return null; + let gapIndex = longitudes.length - 1; + let largestGap = longitudes[0] + 360 - longitudes.at(-1); + for (let index = 0; index < longitudes.length - 1; index += 1) { + const gap = longitudes[index + 1] - longitudes[index]; + if (gap > largestGap) { + largestGap = gap; + gapIndex = index; + } + } + const viewport = { + southwest: { + lat: Math.min(...latitudes), + lng: wrapLongitude(longitudes[(gapIndex + 1) % longitudes.length]), + }, + northeast: { + lat: Math.max(...latitudes), + lng: wrapLongitude(longitudes[gapIndex]), + }, + }; + const metrics = viewportMetrics(viewport); + if (!metrics) return null; + return { + lat: metrics.centerLat, + lng: metrics.centerLng, + label: region.name, + types: ['natural_feature'], + viewport, + }; +} + /** * Geocode a place name through the supplied service, then fly there at a scale * appropriate to the request. Countries and cities use their viewport by @@ -344,7 +387,26 @@ export async function searchAndFlyTo(viewer, query, options = {}) { label = recovered.label || label; types = recovered.types || []; viewport = placesViewportToBounds(recovered.viewport) || viewport; - } else if (!result) return null; + } else if (!result) { + // A pack name may also name a contemporary city or country. Only rescue a + // missing geocode, and only with a ring containing what the user is viewing. + const carto = viewer?.camera?.positionCartographic; + const center = + pickWorldFromScreen(viewer, 0.5, 0.5) || + (carto && { + lat: Cesium.Math.toDegrees(carto.latitude), + lon: Cesium.Math.toDegrees(carto.longitude), + }); + const natural = + center && + (await lookupNaturalRegionOutline(query, center.lat, center.lon).catch( + () => null, + )); + signal?.throwIfAborted(); + const place = naturalRegionSearchPlace(natural); + if (!place) return null; + ({ lat, lng, label, types, viewport } = place); + } const requestedRange = finitePositive(options.range); const duration = finitePositive(options.duration) || 3.0; diff --git a/src/locations.test.mjs b/src/locations.test.mjs index c225488a3..65384a158 100644 --- a/src/locations.test.mjs +++ b/src/locations.test.mjs @@ -152,6 +152,208 @@ test('regionFramingPlan: E-W elongated region looks along the east axis', () => assert.equal(plan.headingDeg, 90); }); +// Synthetic service responses exercise collisions with the physical-region +// pack. These are authored fixtures, not captured geocoder responses. +for (const [query, label, lat, lng, type, bounds] of [ + ['Gold Coast', 'Gold Coast, Queensland, Australia', -28, 153.4, 'locality', [-28.2, 153.2, -27.8, 153.6]], + ['Santa Cruz', 'Santa Cruz, California, USA', 37, -122, 'locality', [36.9, -122.1, 37.1, -121.9]], + ['Isabela', 'Isabela, Philippines', 17, 122, 'administrative_area_level_1', [16.5, 121.5, 17.5, 122.5]], + ['India', 'India', 22, 79, 'country', [7, 67, 37, 91]], + ['Korea', 'South Korea', 36, 128, 'country', [33, 126, 39, 130]], +]) { + test(`search keeps the geocoded ${label} over its Natural Earth namesake`, async () => { + const viewer = stubViewer(); + const geocodes = []; + const result = await searchAndFlyTo(viewer, query, { + placeSearch: { + async geocode(request) { + geocodes.push(request); + return { place: { lat, lng, label, types: [type, 'political'], viewport: boxOf(...bounds) } }; + }, + }, + recoverNearView: async () => null, + }); + assert.deepEqual(geocodes, [query]); + assert.equal(result.label, label); + assert.equal(result.navigationMode, type === 'locality' ? 'city-overview' : 'region-overview'); + assert.equal(viewer.flights.length, 1); + const flown = flownRectangleDegrees(viewer); + assert.ok(Math.abs((flown.south + flown.north) / 2 - lat) < 0.000001, `${query}: latitude remains geocoded`); + assert.ok(Math.abs((flown.west + flown.east) / 2 - lng) < 0.000001, `${query}: longitude remains geocoded`); + assert.ok(flown.south <= bounds[0] && flown.north >= bounds[2], 'frames the geocoded north-south extent'); + assert.ok(flown.west <= bounds[1] && flown.east >= bounds[3], 'frames the geocoded east-west extent'); + assert.ok(flown.widthDeg < (bounds[3] - bounds[1]) * 1.6, 'keeps the geocoded place scale'); + }); +} + +function alpsViewer() { + const viewer = stubViewer(); + Object.assign(viewer.camera.positionCartographic, { + latitude: Cesium.Math.toRadians(46.5), + longitude: Cesium.Math.toRadians(10), + height: 248_000, + }); + return viewer; +} + +for (const answered of [true, false]) { + test(`search recovers contained Alps only after geocoder ${answered ? 'miss' : 'unavailability'} and nearby recovery miss`, async () => { + const viewer = alpsViewer(); + const order = []; + const result = await searchAndFlyTo(viewer, 'the Alps', { + placeSearch: { async geocode() { order.push('geocode'); return { place: null, answered }; } }, + recoverNearView: async () => { order.push('nearby'); return null; }, + }); + assert.deepEqual(order, ['geocode', 'nearby']); + assert.equal(result?.label, 'Alps'); + assert.equal(result?.navigationMode, 'natural-region-swath'); + assert.equal(viewer.flights.length, 1); + const target = Cesium.Cartographic.fromCartesian(viewer.flights[0].sphere.center); + assert.ok(Cesium.Math.toDegrees(target.latitude) > 43 && Cesium.Math.toDegrees(target.latitude) < 49); + assert.ok(Cesium.Math.toDegrees(target.longitude) > 5 && Cesium.Math.toDegrees(target.longitude) < 17); + }); +} + +test('a successful nearby recovery keeps precedence over a contained natural region', async () => { + const viewer = alpsViewer(); + const result = await searchAndFlyTo(viewer, 'the Alps', { + placeSearch: { async geocode() { return { place: null, answered: true }; } }, + recoverNearView: async () => ({ + lat: 46.6, + lon: 10.1, + label: 'The Alps local landmark', + types: ['locality', 'political'], + viewport: { low: { latitude: 46.5, longitude: 10 }, high: { latitude: 46.7, longitude: 10.2 } }, + }), + }); + assert.equal(result.label, 'The Alps local landmark'); + assert.equal(result.navigationMode, 'city-overview'); + assert.equal(viewer.flights.length, 1); +}); + +test('missing searches never choose a natural namesake outside the view or without a view anchor', async () => { + const withoutAnchor = alpsViewer(); + delete withoutAnchor.camera.positionCartographic; + for (const [query, viewer] of [['the Alps', stubViewer()], ['not a real location', alpsViewer()], ['the Alps', withoutAnchor]]) { + const result = await searchAndFlyTo(viewer, query, { + placeSearch: { async geocode() { return { place: null, answered: true }; } }, + recoverNearView: async () => null, + }); + assert.equal(result, null, query); + assert.equal(viewer.flights.length, 0, query); + } +}); + +test('a duplicate natural name can recover when exactly one candidate contains the view', async () => { + const viewer = stubViewer(); + Object.assign(viewer.camera.positionCartographic, { + latitude: Cesium.Math.toRadians(5), + longitude: Cesium.Math.toRadians(-73), + }); + const result = await searchAndFlyTo(viewer, 'Cordillera Oriental', { + placeSearch: { async geocode() { return { place: null, answered: true }; } }, + recoverNearView: async () => null, + }); + assert.equal(result?.label, 'Cordillera Oriental'); + assert.equal(result?.navigationMode, 'natural-region-swath'); + assert.equal(viewer.flights.length, 1); + const target = Cesium.Cartographic.fromCartesian(viewer.flights[0].sphere.center); + assert.ok(Cesium.Math.toDegrees(target.latitude) > 0, 'frames the northern candidate'); +}); + +test('an overlapping natural name stays unresolved after geocoder and nearby recovery miss', async () => { + const viewer = stubViewer(); + Object.assign(viewer.camera.positionCartographic, { + latitude: Cesium.Math.toRadians(45.6336), + longitude: Cesium.Math.toRadians(-74.86493), + }); + const result = await searchAndFlyTo(viewer, 'Canadian Shield', { + placeSearch: { async geocode() { return { place: null, answered: true }; } }, + recoverNearView: async () => null, + }); + assert.equal(result, null); + assert.equal(viewer.flights.length, 0); +}); + +test('a geocoder rejection propagates without natural-region recovery or a flight', async () => { + const viewer = alpsViewer(); + const failure = new Error('geocoder failed'); + let recoveries = 0; + await assert.rejects(searchAndFlyTo(viewer, 'the Alps', { + placeSearch: { async geocode() { throw failure; } }, + recoverNearView: async () => { recoveries += 1; return null; }, + }), (error) => error === failure); + assert.equal(recoveries, 0); + assert.equal(viewer.flights.length, 0); +}); + +test('search cancellation before or during geocoding cannot issue a fallback flight', async () => { + for (const preAborted of [true, false]) { + const viewer = alpsViewer(); + const controller = new AbortController(); + if (preAborted) controller.abort(); + let geocodes = 0; + let recoveries = 0; + await assert.rejects(searchAndFlyTo(viewer, 'the Alps', { + signal: controller.signal, + placeSearch: { + async geocode(_query, { signal }) { + geocodes += 1; + assert.equal(signal, controller.signal); + controller.abort(); + return { place: null, answered: true }; + }, + }, + recoverNearView: async () => { recoveries += 1; return null; }, + }), { name: 'AbortError' }); + assert.equal(geocodes, preAborted ? 0 : 1); + assert.equal(recoveries, 0); + assert.equal(viewer.flights.length, 0); + } +}); + +test('cancellation while the bundled fallback is resolving cannot issue a flight', async () => { + const viewer = alpsViewer(); + const carto = viewer.camera.positionCartographic; + const controller = new AbortController(); + let recoveryFinished = false; + let fallbackRead = false; + let authorityChecks = 0; + Object.defineProperty(viewer.camera, 'positionCartographic', { + get() { + if (recoveryFinished && !fallbackRead) { + fallbackRead = true; + // The fallback reads the view after nearby recovery, then awaits the + // bundled lookup. Abort in that async gap, even when the pack is warm. + queueMicrotask(() => controller.abort()); + } + return carto; + }, + }); + await assert.rejects(searchAndFlyTo(viewer, 'the Alps', { + signal: controller.signal, + placeSearch: { async geocode() { return { place: null, answered: true }; } }, + recoverNearView: async () => { recoveryFinished = true; return null; }, + beforeFly: () => { authorityChecks += 1; return true; }, + }), { name: 'AbortError' }); + assert.equal(fallbackRead, true, 'reached the bundled recovery'); + assert.equal(authorityChecks, 0); + assert.equal(viewer.flights.length, 0); +}); + +test('a final authority veto blocks a contained natural-region recovery flight', async () => { + const viewer = alpsViewer(); + const order = []; + const result = await searchAndFlyTo(viewer, 'the Alps', { + placeSearch: { async geocode() { order.push('geocode'); return { place: null, answered: true }; } }, + recoverNearView: async () => { order.push('nearby'); return null; }, + beforeFly: () => { order.push('veto'); return false; }, + }); + assert.deepEqual(order, ['geocode', 'nearby', 'veto']); + assert.equal(result, CANCELLED_SEARCH); + assert.equal(viewer.flights.length, 0); +}); + test('regionFramingPlan: park- and lake-scale viewports keep full framing', () => { // Zilker Park (~3 km). assert.equal(regionFramingPlan({ From 17631433371a61d5dc9bedda3f5b89f77d0011a9 Mon Sep 17 00:00:00 2001 From: manjunath Date: Mon, 19 Oct 2026 11:02:30 +0530 Subject: [PATCH 09/21] docs(changelog): record location resolution fix --- changelog.d/1235-location-resolution.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1235-location-resolution.md diff --git a/changelog.d/1235-location-resolution.md b/changelog.d/1235-location-resolution.md new file mode 100644 index 000000000..9d67783c0 --- /dev/null +++ b/changelog.d/1235-location-resolution.md @@ -0,0 +1 @@ +- Preserve successful place-search matches, require an unambiguous containing natural region for recovery, and retain analyst boundary provenance through follow-ups (manjunath22466, #1235). From 73336b297e2994d4741be5fc46bb36ec096ffbb6 Mon Sep 17 00:00:00 2001 From: Jerry Lai <419vive@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:24:20 +0800 Subject: [PATCH 10/21] feat(cctv): add Taiwan Freeway Bureau camera pack Read ~1,800 national freeway cameras at runtime from the Freeway Bureau's keyless MOTC 2.0 CCTV.xml table. Each published MJPEG stream maps to the same camera server's single-JPEG path; only those exact HTTPS pairs on the four freeway.gov.tw camera servers are registered. On by default, CCTV_TAIWAN_ENABLED=0 skips it. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + DATA_SOURCES.md | 1 + README.md | 2 +- changelog.d/taiwan-freeway-cctv.md | 4 + docs/CURRENT-STATE.md | 16 ++ scripts/format-scope.json | 2 + server/providers/cctv/catalog.js | 6 + server/providers/cctv/taiwan.js | 249 +++++++++++++++++++++++++++++ src/data/cctvTaiwan.test.mjs | 200 +++++++++++++++++++++++ src/data/dataCredits.js | 7 + 10 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 changelog.d/taiwan-freeway-cctv.md create mode 100644 server/providers/cctv/taiwan.js create mode 100644 src/data/cctvTaiwan.test.mjs diff --git a/.env.example b/.env.example index a2f8afa24..ae89e4df9 100644 --- a/.env.example +++ b/.env.example @@ -237,6 +237,8 @@ AISSTREAM_API_KEY= # CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL # CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md) # CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required +# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras +# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index d930bcb8f..a05cd5511 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -110,6 +110,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei **Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent. - **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400. +- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse with attribution; the app credits the Freeway Bureau, MOTC. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000). - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. diff --git a/README.md b/README.md index c49c53ece..c91edecce 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **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 | 🟢 | diff --git a/changelog.d/taiwan-freeway-cctv.md b/changelog.d/taiwan-freeway-cctv.md new file mode 100644 index 000000000..7fc0339e3 --- /dev/null +++ b/changelog.d/taiwan-freeway-cctv.md @@ -0,0 +1,4 @@ +- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV: + ~1,800 cameras read at runtime from the official MOTC 2.0 open-data table, + with on-demand snapshots and attribution. No API key is required; set + `CCTV_TAIWAN_ENABLED=0` to skip it. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9562dc9f5..61aede327 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1075,6 +1075,22 @@ The images are periodically updated road-condition photographs, not continuous video; camera poses use the existing low-confidence heading prior. `CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000). +## Taiwan CCTV source pack + +The CCTV catalog includes Taiwan national freeway cameras from the Freeway +Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table +(~1,800 cameras); the request refuses redirects, has a 15-second deadline and a +6 MiB response cap, and uses the shared 15-minute cache and fair catalog +allocation. An unavailable source contributes an empty pack. The table +publishes MJPEG stream URLs; each is mapped to the same camera server's +single-JPEG path for the same camera number, and only those exact HTTPS pairs +on the four `freeway.gov.tw` camera servers are registered with the frame +proxy. Camera keys come from the server and camera number, because published +CCTV IDs contain Chinese place names and a few repeat across cameras. Poses use +the low-confidence heading prior. `CCTV_TAIWAN_ENABLED=0` disables the pack; +`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000). +Gate: `src/data/cctvTaiwan.test.mjs`. + ## DriveBC CCTV source pack The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5ec1adb0c..7c2c3fdbd 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -114,6 +114,8 @@ "server/providers/cctv/catalog.js", "server/providers/cctv/lithuania.js", "src/data/cctvLithuania.test.mjs", + "server/providers/cctv/taiwan.js", + "src/data/cctvTaiwan.test.mjs", "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 75e58d2a6..35ee4485e 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -5,6 +5,7 @@ import { allocateSourceCap, resolveCatalogCap } from './cap.js'; import { loadGroundHeights, joinGroundHeights } from './groundHeights.js'; import { cameraRegion, normalizeSourceItem } from './normalize.js'; import { loadLithuaniaSources } from './lithuania.js'; +import { loadTaiwanSources } from './taiwan.js'; import { loadAustinSourcesFromOpenData, loadCaltransSourcesFromOpenData, @@ -144,6 +145,11 @@ const LIVE_PACKS = [ enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'), load: loadLithuaniaSources, }, + { + name: 'taiwan', + enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'), + load: loadTaiwanSources, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/taiwan.js b/server/providers/cctv/taiwan.js new file mode 100644 index 000000000..0a876f7cf --- /dev/null +++ b/server/providers/cctv/taiwan.js @@ -0,0 +1,249 @@ +import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js'; +import { + cameraDisplayCode, + fallbackHeadingFromId, + prioritizeSources, +} from './normalize.js'; +import { readResponseTextCapped } from '../common/http.js'; + +/** Freeway Bureau (MOTC) CCTV static table in the national MOTC 2.0 XML + * standard, published keyless and refreshed daily (~1,800 cameras). */ +export const TAIWAN_FREEWAY_CCTV_URL = + 'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml'; +/** The whole network is ~1.2 MB. */ +export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024; +export const DEFAULT_TAIWAN_MAX_SOURCES = 600; + +// The table publishes each camera's MJPEG stream. The same camera servers +// answer a single JPEG for the same camera ID on a sibling path, which is what +// the frame proxy can relay; only these exact pairs are ever registered. +const STREAM_TO_FRAME = new Map([ + ['cctvn.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']], + ['cctvn5.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']], + ['cctvc.freeway.gov.tw', ['/abs2mjpg/bmjpg', '/abs2mjpg/jpg']], + [ + 'cctvs.freeway.gov.tw', + ['/live-view/mjpg/video.cgi', '/live-view/jpg/image.cgi'], + ], +]); + +const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' }; + +/** Prioritization anchors, north to south, so a cap keeps the whole island + * rather than only greater Taipei. */ +const ANCHORS = [ + { lat: 25.033, lon: 121.5654 }, // Taipei + { lat: 24.9936, lon: 121.301 }, // Taoyuan + { lat: 24.8138, lon: 120.9675 }, // Hsinchu + { lat: 24.7021, lon: 121.7378 }, // Yilan + { lat: 24.1477, lon: 120.6736 }, // Taichung + { lat: 22.9999, lon: 120.227 }, // Tainan + { lat: 22.6273, lon: 120.3014 }, // Kaohsiung +]; + +/** Taiwan main island with slack for the coast; the freeway network has no + * offshore-island cameras. */ +export function isLikelyTaiwanCoordinate(lat, lon) { + return ( + Number.isFinite(lat) && + Number.isFinite(lon) && + lat >= 21.8 && + lat <= 25.4 && + lon >= 119.9 && + lon <= 122.1 + ); +} + +const XML_ENTITIES = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", +}; + +function decodeXml(text) { + return String(text ?? '') + .replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => XML_ENTITIES[name]) + .trim(); +} + +function tag(block, name) { + const match = new RegExp(`<${name}>([^<]*)`).exec(block); + return match ? decodeXml(match[1]) : ''; +} + +/** + * Parse the MOTC 2.0 `CCTVList` document into flat rows. Regex-based like the + * Catalonia and Tarktee parsers: the schema is flat, and only the fields used + * below are read. + * + * @param {string} xml + * @returns {Array} + */ +export function parseTaiwanCctvXml(xml) { + const rows = []; + const blockRe = /([\s\S]*?)<\/CCTV>/g; + let match; + while ((match = blockRe.exec(String(xml || ''))) !== null) { + const block = match[1]; + rows.push({ + cctvId: tag(block, 'CCTVID'), + streamUrl: tag(block, 'VideoStreamURL'), + lat: Number(tag(block, 'PositionLat')), + lon: Number(tag(block, 'PositionLon')), + roadName: tag(block, 'RoadName'), + direction: tag(block, 'RoadDirection'), + sectionStart: tag(block, 'Start'), + sectionEnd: tag(block, 'End'), + mile: tag(block, 'LocationMile'), + }); + } + return rows; +} + +/** + * Published MJPEG stream URL -> the same camera's still, plus the host and + * camera number that identify it, or null. + * + * @param {string} streamUrl + * @returns {?{frameUrl: string, host: string, camera: string}} + */ +export function taiwanFrame(streamUrl) { + let url; + try { + url = new URL(String(streamUrl ?? '')); + } catch { + return null; + } + if (url.protocol !== 'https:' || url.username || url.password || url.port) + return null; + const paths = STREAM_TO_FRAME.get(url.hostname); + if (!paths || url.pathname !== paths[0]) return null; + const camera = url.searchParams.get('camera') || ''; + if (!/^[A-Za-z0-9-]{1,40}$/.test(camera)) return null; + return { + // Reconstruct so extra query parameters cannot alter the request. + frameUrl: `https://${url.hostname}${paths[1]}?camera=${camera}`, + host: url.hostname.split('.')[0], + camera, + }; +} + +/** + * One parsed Freeway Bureau row -> one catalog source, or null. + * + * @param {object} row - Output of parseTaiwanCctvXml. + * @returns {?object} + */ +export function taiwanCameraToSource(row) { + if (!row || typeof row !== 'object') return null; + const { lat, lon } = row; + if (!isLikelyTaiwanCoordinate(lat, lon)) return null; + const frame = taiwanFrame(row.streamUrl); + if (!frame) return null; + const imageUrl = frame.frameUrl; + // CCTVID carries Chinese place names and a few IDs repeat across distinct + // cameras, so the stable key is the camera server plus its camera number. + const rawId = String(row.cctvId ?? '') + .trim() + .slice(0, 80); + const cameraId = `tw-freeway-${frame.host}-${frame.camera.toLowerCase()}`; + const road = String(row.roadName ?? '').trim(); + const direction = DIRECTIONS[String(row.direction ?? '').trim()] || ''; + const mile = String(row.mile ?? '').trim(); + const section = [row.sectionStart, row.sectionEnd] + .map((part) => String(part ?? '').trim()) + .filter(Boolean) + .join('→'); + const name = + [road, direction, mile].filter(Boolean).join(' ') + + (section ? ` ${section}` : '') || + rawId || + `Freeway camera ${frame.camera}`; + + return { + id: cameraId, + name: name.slice(0, 160), + // One island-wide category in the camera picker, like Finland and Norway. + city: 'Taiwan / 臺灣', + cityId: 'taiwan', + provider: 'Freeway Bureau, MOTC', + lat, + lon, + // The table gives the carriageway direction, not the camera's bearing, + // so the pose uses the shared low-confidence prior. + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'tw-freeway-motc', + license: + 'Freeway Bureau, MOTC open data, Open Government Data License, Taiwan (Version 1.0)', + code: cameraDisplayCode( + `${road} ${mile}`.trim().toUpperCase() || frame.camera, + ), + }; +} + +/** + * Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table. + * One bounded request per shared catalog refresh. + * + * @returns {Promise>} + */ +export async function loadTaiwanSources() { + try { + const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS); + const response = await fetch( + process.env.CCTV_TAIWAN_URL || TAIWAN_FREEWAY_CCTV_URL, + { + headers: { Accept: 'application/xml,text/xml' }, + redirect: 'manual', + signal, + }, + ); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + console.warn( + '[CCTV] Taiwan camera download failed:', + response.status >= 300 && response.status < 400 + ? 'catalog redirected; redirects are not followed' + : response.status, + ); + return []; + } + const xml = await readResponseTextCapped( + response, + TAIWAN_MAX_CATALOG_BYTES, + signal, + ); + const cameras = new Map(); + for (const row of parseTaiwanCctvXml(xml)) { + const camera = taiwanCameraToSource(row); + if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera); + } + const rawCap = Number( + process.env.CCTV_TAIWAN_MAX_SOURCES || DEFAULT_TAIWAN_MAX_SOURCES, + ); + const cap = Number.isFinite(rawCap) + ? Math.max(1, Math.min(2000, Math.floor(rawCap))) + : DEFAULT_TAIWAN_MAX_SOURCES; + const sources = prioritizeSources([...cameras.values()], cap, ANCHORS); + console.log( + `[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`, + ); + return sources; + } catch (error) { + console.warn( + '[CCTV] Taiwan camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvTaiwan.test.mjs b/src/data/cctvTaiwan.test.mjs new file mode 100644 index 000000000..73604e08c --- /dev/null +++ b/src/data/cctvTaiwan.test.mjs @@ -0,0 +1,200 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + loadTaiwanSources, + parseTaiwanCctvXml, + taiwanCameraToSource, + taiwanFrame, + TAIWAN_FREEWAY_CCTV_URL, +} from '../../server/providers/cctv/taiwan.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +function cctvXml(...blocks) { + return ` + + NFB + ${blocks.join('')} +`; +} + +function block({ + id = 'CCTV-N1-S-0.000-M', + stream = 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000', + lat = '25.1229931', + lon = '121.735695', +} = {}) { + return ` + ${id} + ${stream} + ${lon} + ${lat} + 國道1號 + S + 基隆端基隆交流道 + 0K+000 + `; +} + +test('parses the MOTC 2.0 table and registers the same camera still', () => { + const [row] = parseTaiwanCctvXml(cctvXml(block())); + const camera = taiwanCameraToSource(row); + assert.equal(camera.id, 'tw-freeway-cctvn-10000'); + assert.equal(camera.lat, 25.1229931); + assert.equal(camera.lon, 121.735695); + assert.equal(camera.name, '國道1號 S 0K+000 基隆端→基隆交流道'); + assert.equal(camera.cityId, 'taiwan'); + assert.equal(camera.feedType, 'image'); + assert.equal(camera.headingConfidence, 'low'); + assert.equal( + camera.url, + 'https://cctvn.freeway.gov.tw/abs2mjpg/jpg?camera=10000', + ); + assert.equal(camera.snapshotUrl, camera.url); + assert.match(camera.license, /Open Government Data License, Taiwan/); +}); + +test('each camera server maps its stream path to its own still path only', () => { + assert.equal( + taiwanFrame( + 'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?camera=373', + ).frameUrl, + 'https://cctvs.freeway.gov.tw/live-view/jpg/image.cgi?camera=373', + ); + assert.equal( + taiwanFrame( + 'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4', + ).frameUrl, + 'https://cctvn5.freeway.gov.tw/abs2mjpg/jpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4', + ); + assert.equal( + taiwanFrame( + 'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495&extra=1', + ).frameUrl, + 'https://cctvc.freeway.gov.tw/abs2mjpg/jpg?camera=495', + ); +}); + +test('off-host, downgraded or malformed stream URLs never enter the proxy registry', () => { + for (const stream of [ + 'http://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000', + 'https://cctvn.freeway.gov.tw.evil.test/abs2mjpg/bmjpg?camera=10000', + 'https://user:pass@cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000', + 'https://cctvn.freeway.gov.tw:8443/abs2mjpg/bmjpg?camera=10000', + 'https://cctvn.freeway.gov.tw/live-view/mjpg/video.cgi?camera=10000', + 'https://cctvs.freeway.gov.tw/abs2mjpg/bmjpg?camera=373', + 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=../../admin', + 'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?cacamera=373', + 'http://127.0.0.1/private', + '', + ]) { + assert.equal(taiwanFrame(stream), null, stream); + const [row] = parseTaiwanCctvXml(cctvXml(block({ stream }))); + assert.equal(taiwanCameraToSource(row), null, stream); + } +}); + +test('coordinates outside Taiwan, swapped or missing are rejected', () => { + for (const [lat, lon] of [ + ['121.735695', '25.1229931'], + ['35.68', '139.76'], + ['', '121.7'], + ['NaN', '121.7'], + ]) { + const [row] = parseTaiwanCctvXml(cctvXml(block({ lat, lon }))); + assert.equal(taiwanCameraToSource(row), null, `${lat},${lon}`); + } + assert.equal(taiwanCameraToSource(null), null); +}); + +test('Chinese place names in CCTVID stay in the record without shaping the key', () => { + const [row] = parseTaiwanCctvXml( + cctvXml(block({ id: 'CCTV-N1-N-55-R-中壢服務區&(北)' })), + ); + const camera = taiwanCameraToSource(row); + assert.equal(camera.id, 'tw-freeway-cctvn-10000'); + assert.equal(row.cctvId, 'CCTV-N1-N-55-R-中壢服務區&(北)'); +}); + +function mockCatalog(t, xml) { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url, options) => { + requested.push(String(url)); + if (String(url) === TAIWAN_FREEWAY_CCTV_URL) { + assert.equal(options.redirect, 'manual'); + return new Response(xml, { + headers: { 'content-type': 'application/xml' }, + }); + } + return Response.json([]); + }); + return requested; +} + +test('loader deduplicates cameras that share a camera number', async (t) => { + mockCatalog( + t, + cctvXml( + block(), + block({ id: 'CCTV-N1-S-0.000-M-dup' }), + block({ + stream: 'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495', + lat: '24.1', + lon: '120.6', + }), + ), + ); + assert.deepEqual((await loadTaiwanSources()).map((c) => c.id).sort(), [ + 'tw-freeway-cctvc-495', + 'tw-freeway-cctvn-10000', + ]); +}); + +test('a redirected or failed catalog contributes an empty pack', async (t) => { + t.mock.method(console, 'warn', () => {}); + for (const status of [302, 503]) { + t.mock.method( + globalThis, + 'fetch', + async () => + new Response('', { + status, + headers: status === 302 ? { location: 'https://evil.test/' } : {}, + }), + ); + assert.deepEqual(await loadTaiwanSources(), []); + } +}); + +async function runCatalogWithTaiwanFlag(t, flag) { + const requested = mockCatalog(t, cctvXml(block())); + const saved = process.env.CCTV_TAIWAN_ENABLED; + try { + if (flag === undefined) delete process.env.CCTV_TAIWAN_ENABLED; + else process.env.CCTV_TAIWAN_ENABLED = flag; + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; + } finally { + if (saved === undefined) delete process.env.CCTV_TAIWAN_ENABLED; + else process.env.CCTV_TAIWAN_ENABLED = saved; + } +} + +test('the Taiwan pack is in the catalog by default', async (t) => { + const { sources } = await runCatalogWithTaiwanFlag(t, undefined); + assert.equal( + sources.find((c) => c.id === 'tw-freeway-cctvn-10000')?.cityId, + 'taiwan', + ); +}); + +test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => { + const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0'); + assert.ok(requested.length > 0, 'the other packs still refreshed'); + assert.equal( + requested.some((href) => href.includes('freeway.gov.tw')), + false, + ); + assert.ok(!sources.some((c) => c.cityId === 'taiwan')); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 8d3949351..8ba07e1b0 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -257,6 +257,13 @@ export const DATA_CREDITS = [ 'Road-camera stills (Lithuania, when enabled): ' + 'Via Lietuva / Eismoinfo; periodically updated images', }, + { + key: 'taiwan-cctv', + html: + 'Freeway camera stills (Taiwan): ' + + 'Freeway Bureau, MOTC, ' + + 'Open Government Data License, Taiwan (Version 1.0)', + }, { key: 'calgary-cctv', html: From e324e2231c86e1356b758c091e5fb9b07061cf3a Mon Sep 17 00:00:00 2001 From: Jerry Lai <419vive@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:53:27 +0800 Subject: [PATCH 11/21] docs(changelog): name the Taiwan CCTV fragment for #1237 Co-Authored-By: Claude Opus 5.5 --- .../{taiwan-freeway-cctv.md => 1237-taiwan-freeway-cctv.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{taiwan-freeway-cctv.md => 1237-taiwan-freeway-cctv.md} (80%) diff --git a/changelog.d/taiwan-freeway-cctv.md b/changelog.d/1237-taiwan-freeway-cctv.md similarity index 80% rename from changelog.d/taiwan-freeway-cctv.md rename to changelog.d/1237-taiwan-freeway-cctv.md index 7fc0339e3..1578971d4 100644 --- a/changelog.d/taiwan-freeway-cctv.md +++ b/changelog.d/1237-taiwan-freeway-cctv.md @@ -1,4 +1,4 @@ - Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV: ~1,800 cameras read at runtime from the official MOTC 2.0 open-data table, with on-demand snapshots and attribution. No API key is required; set - `CCTV_TAIWAN_ENABLED=0` to skip it. + `CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237). From 0561277dcbb2774b04ec6a27c2c8f0a06d3f8c4d Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 16:59:52 +0100 Subject: [PATCH 12/21] test(cctv): pin Ontario 511 key handling against regressions - Assert the key never reaches the camera records sent to the browser or the "Loaded" log line. - Count fetches in the keyless test instead of relying on assert.fail inside the mock, which the loader's catch would swallow; a whitespace-only key is treated as missing. - Cover the key hint for 400/401/403 and its absence for 429/500/503, and log the failure as one string without a trailing empty argument. - Add _resetOntarioKeyWarningForTest so the logged-once check no longer depends on test order. - Read CCTV_ONTARIO_MAX_SOURCES from the same env as the key, and keep console output out of the suite. Co-Authored-By: Claude Opus 5.5 --- server/providers/cctv/ontario511.test.mjs | 102 ++++++++++++++++++---- server/providers/cctv/sources.js | 19 ++-- 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/server/providers/cctv/ontario511.test.mjs b/server/providers/cctv/ontario511.test.mjs index d0cb1f73e..51334b3cb 100644 --- a/server/providers/cctv/ontario511.test.mjs +++ b/server/providers/cctv/ontario511.test.mjs @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { loadOntarioSourcesFromOpenData } from './sources.js'; +import { + _resetOntarioKeyWarningForTest, + loadOntarioSourcesFromOpenData, +} from './sources.js'; const ROW = { Id: '42', @@ -18,19 +21,35 @@ const ROW = { ], }; +function captureConsole(t) { + const lines = []; + const capture = (...args) => lines.push(args.join(' ')); + t.mock.method(console, 'warn', capture); + t.mock.method(console, 'log', capture); + return lines; +} + +test.beforeEach(() => _resetOntarioKeyWarningForTest()); + test('without ONTARIO_511_API_KEY the Ontario pack is skipped, logged once', async (t) => { - const warnings = []; - t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' '))); - t.mock.method(globalThis, 'fetch', async () => - assert.fail('no request without a key'), + const lines = captureConsole(t); + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches += 1; + return Response.json([ROW]); + }); + assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []); + assert.deepEqual( + await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: ' ' }), + [], ); - assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []); - assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /ONTARIO_511_API_KEY/); + assert.equal(fetches, 0); + assert.equal(lines.length, 1); + assert.match(lines[0], /ONTARIO_511_API_KEY/); }); test('with a key the camera list is requested with it and parsed', async (t) => { + const lines = captureConsole(t); const requested = []; t.mock.method(globalThis, 'fetch', async (url) => { requested.push(String(url)); @@ -40,6 +59,7 @@ test('with a key the camera list is requested with it and parsed', async (t) => { sourceRoot: '/ignored' }, { ONTARIO_511_API_KEY: ' test-key ' }, ); + assert.equal(requested.length, 1); const url = new URL(requested[0]); assert.equal(url.host, '511on.ca'); assert.equal(url.searchParams.get('key'), 'test-key'); @@ -47,19 +67,63 @@ test('with a key the camera list is requested with it and parsed', async (t) => assert.equal(cameras.length, 1); assert.equal(cameras[0].id, 'on-42'); assert.equal(cameras[0].url, 'https://511on.ca/map/Cctv/4201'); + // The records go to the browser through /api/cctv; the key must stay on + // the server-side catalog request. + assert.doesNotMatch(JSON.stringify(cameras), /test-key/); + assert.doesNotMatch(lines.join('\n'), /test-key/); }); -test('a rejected key is reported by status, never by the key itself', async (t) => { - const warnings = []; - t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' '))); - t.mock.method(globalThis, 'fetch', async () => - Response.json({ Message: 'Invalid Key' }, { status: 400 }), - ); +test('the source cap is read from the same environment as the key', async (t) => { + captureConsole(t); + const rows = Array.from({ length: 12 }, (_, i) => ({ + ...ROW, + Id: String(100 + i), + Latitude: ROW.Latitude + i * 0.01, + Views: [{ ...ROW.Views[0], Url: `https://511on.ca/map/Cctv/${100 + i}` }], + })); + t.mock.method(globalThis, 'fetch', async () => Response.json(rows)); const cameras = await loadOntarioSourcesFromOpenData( {}, - { ONTARIO_511_API_KEY: 'secret-key-value' }, + { ONTARIO_511_API_KEY: 'test-key', CCTV_ONTARIO_MAX_SOURCES: '8' }, ); - assert.deepEqual(cameras, []); - assert.match(warnings.join('\n'), /400 \(check ONTARIO_511_API_KEY\)/); - assert.doesNotMatch(warnings.join('\n'), /secret-key-value/); + assert.equal(cameras.length, 8); }); + +for (const status of [400, 401, 403]) { + test(`a ${status} points at the key by name, never by value`, async (t) => { + const lines = captureConsole(t); + t.mock.method(globalThis, 'fetch', async () => + Response.json({ Message: 'Invalid Key' }, { status }), + ); + const cameras = await loadOntarioSourcesFromOpenData( + {}, + { ONTARIO_511_API_KEY: 'secret-key-value' }, + ); + assert.deepEqual(cameras, []); + assert.match( + lines.join('\n'), + new RegExp(`failed: ${status} \\(check ONTARIO_511_API_KEY\\)`), + ); + assert.doesNotMatch(lines.join('\n'), /secret-key-value/); + }); +} + +for (const status of [429, 500, 503]) { + test(`a ${status} is reported without blaming the key`, async (t) => { + const lines = captureConsole(t); + t.mock.method(globalThis, 'fetch', async () => + Response.json({}, { status }), + ); + const cameras = await loadOntarioSourcesFromOpenData( + {}, + { ONTARIO_511_API_KEY: 'secret-key-value' }, + ); + assert.deepEqual(cameras, []); + assert.equal(lines.length, 1); + assert.equal( + lines[0], + `[CCTV] Ontario 511 camera download failed: ${status}`, + ); + assert.doesNotMatch(lines[0], /secret-key-value/); + }); +} diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 3804ed956..e4b7cf48b 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -511,6 +511,11 @@ function pickOntarioCctvView(views) { let ontarioKeyMissingLogged = false; +/** Test seam: let the next keyless call log the missing-key line again. */ +export function _resetOntarioKeyWarningForTest() { + ontarioKeyMissingLogged = false; +} + /** * Fetch Ontario 511 CCTV cameras. The camera list comes from the 511 API, * which now requires a free developer key (511on.ca/developers/doc, "Requires a @@ -521,7 +526,7 @@ let ontarioKeyMissingLogged = false; * view are kept. * * @param {object} [_options] The catalog's loader options (unused). - * @param {object} [env] Environment to read the key from. + * @param {object} [env] Environment to read the key and the source cap from. * @returns {Promise>} Normalized camera source objects. */ export async function loadOntarioSourcesFromOpenData( @@ -547,12 +552,12 @@ export async function loadOntarioSourcesFromOpenData( }); if (!resp.ok) { // The API answers 400 "Invalid Key" to a wrong or revoked key. - console.warn( - '[CCTV] Ontario 511 camera download failed:', - resp.status, + const keyHint = resp.status === 400 || resp.status === 401 || resp.status === 403 - ? '(check ONTARIO_511_API_KEY)' - : '', + ? ' (check ONTARIO_511_API_KEY)' + : ''; + console.warn( + `[CCTV] Ontario 511 camera download failed: ${resp.status}${keyHint}`, ); return []; } @@ -616,7 +621,7 @@ export async function loadOntarioSourcesFromOpenData( new Map(cameras.map((camera) => [camera.id, camera])).values(), ); const maxRaw = Number( - process.env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES, + env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES, ); const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(1000, Math.floor(maxRaw))) From b48fcc42ae3468d5ff808b0f5af35e87113f8ea7 Mon Sep 17 00:00:00 2001 From: manjunath Date: Tue, 20 Oct 2026 10:00:24 +0530 Subject: [PATCH 13/21] fix(voice): refuse direct cctv toggles outside the shared lifecycle --- changelog.d/1205-cctv-tool-routing.md | 3 +-- docs/CURRENT-STATE.md | 5 +++-- src/voice/cctvRouting.test.mjs | 16 +++++++++++++++- src/voice/gevActions.js | 7 ++----- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/changelog.d/1205-cctv-tool-routing.md b/changelog.d/1205-cctv-tool-routing.md index ed7552591..72e8859c5 100644 --- a/changelog.d/1205-cctv-tool-routing.md +++ b/changelog.d/1205-cctv-tool-routing.md @@ -1,3 +1,2 @@ - Report actual outcomes when voice enables or disables CCTV through the shared - layer lifecycle, including failed and cancelled changes (manjunath22466, - #1205). + layer lifecycle, including failed and cancelled changes (Manjunath, #1205). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d8cc977c9..af59b5429 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -628,8 +628,9 @@ Voice action argument schemas have one portable owner under `src/voice/actionSch layer visibility lifecycle. Results distinguish successful changes from manager-reported failures or cancellation and report enabled state and lifecycle uncertainty. A caller-supplied abort signal reaches the manager; a late abort -preserves an already committed change. Missing-layer errors, other CCTV actions -and the exported `controlCctv` helper retain their existing behavior. +preserves an already committed change. Missing-layer errors and other CCTV +actions retain their existing behavior. The exported `controlCctv` helper refuses direct +enable/disable requests and directs callers to `set_layer_visibility`. `src/voice/layerManifest.js` declares each catalog layer's voice capabilities: spoken aliases, `toggle`, in-view `context`, `track`, and analyst `query` fields with type and unit, or `noQuery` with the reason. The `set_layer_visibility`, `show_data_layers_menu`, `get_entity_context` and `analyst_query` layer enums, the runner's alias table and the analyst field hints are generated from it. `src/voice/layerManifest.test.mjs` fails when a catalog layer is neither listed nor named in `VOICE_OFF_LAYERS` (today only the two scene-driven Bhote Koshi layers). Global Context is listed with `toggle: false`; `set_context_mode` reaches it. `set_panel_open` also opens `weather-panel` and `recent-imagery-panel`. diff --git a/src/voice/cctvRouting.test.mjs b/src/voice/cctvRouting.test.mjs index 58f3a135b..0df5dd12c 100644 --- a/src/voice/cctvRouting.test.mjs +++ b/src/voice/cctvRouting.test.mjs @@ -2,7 +2,7 @@ import { after, before, test } from 'node:test'; import assert from 'node:assert/strict'; import { DataLayerManager } from '../data/manager.js'; import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; -import { createGevActionRunner } from './gevActions.js'; +import { controlCctv, createGevActionRunner } from './gevActions.js'; const originalWindow = globalThis.window; before(() => { @@ -294,6 +294,20 @@ test( }, ); +test('exported controlCctv refuses toggles without calling the manager', async () => { + const manager = { + layers: new Map([['cctv', { module: {} }]]), + setEnabled: () => assert.fail('direct helper must not change visibility'), + isEnabled: () => assert.fail('direct toggle must not read enabled state'), + }; + for (const action of ['ENABLE', 'disable']) { + const result = await controlCctv(manager, { action }); + assert.equal(result.ok, false); + assert.equal(result.action, 'control_cctv'); + assert.match(result.error, /set_layer_visibility/); + } +}); + test( 'CCTV fallback managers report rejection, transitional state, and uncertainty', { timeout: 2000 }, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 41d59221e..d8b63565f 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1444,13 +1444,10 @@ export async function controlCctv(dataManager, args = {}, styleManager = null) { } if (action === 'enable' || action === 'disable') { - await dataManager.setEnabled('cctv', action === 'enable', { - origin: 'voice', - }); return { - ok: true, + ok: false, action: 'control_cctv', - enabled: dataManager.isEnabled('cctv'), + error: 'Enable or disable CCTV through set_layer_visibility', }; } if (!dataManager.isEnabled('cctv')) { From 22c22d1876d9d874af45beaf043e7bdb668ae464 Mon Sep 17 00:00:00 2001 From: JCY2515 <157453453+JCY2515@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:24:07 +0800 Subject: [PATCH 14/21] feat: add Hong Kong Transport Department CCTV pack Fetch keyless DATA.GOV.HK camera locations at runtime and pin stills to tdcctv.data.one.gov.hk with required attribution. Default cap 1,100 covers the ~1,013-camera TD catalog, prioritized across Hong Kong Island, Kowloon and the New Territories. Co-authored-by: Cursor --- DATA_SOURCES.md | 2 + README.md | 2 +- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 30 ++ server/providers/cctv/normalize.js | 11 + server/providers/cctv/sources.js | 226 ++++++++++++++ src/data/cctvHk.test.mjs | 464 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 7 + 10 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 src/data/cctvHk.test.mjs diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 9ab3052c0..7cc9d84df 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -63,6 +63,7 @@ How to read this: | **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner | | **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) | | **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) | +| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" | | **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" | | **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) | | **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) | @@ -168,6 +169,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. +- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. - **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover. - **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. diff --git a/README.md b/README.md index d96968392..bb8b5d8cd 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **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 with full-screen **EXPAND**. 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 | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ce6b9dad0..d5b972e49 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3458,7 +3458,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| 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, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| 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, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + 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, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index fa5ebc3b2..51dc65509 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -550,6 +550,7 @@ "src/data/cctvWarendorf.test.mjs", "src/data/cctvCalgary.test.mjs", "src/data/cctvCataloniaSource.test.mjs", + "src/data/cctvHk.test.mjs", "src/data/cctvVegvesen.test.mjs", "src/data/cctvNyc.test.mjs", "src/data/cctvNswSource.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 13b817a83..65d903abf 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -19,6 +19,7 @@ import { loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, loadCataloniaSourcesFromOpenData, + loadHkSourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadKingCountySourcesFromOpenData, loadWsdotSourcesFromOpenData, @@ -106,6 +107,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'), load: loadCataloniaSourcesFromOpenData, }, + { + name: 'hk', + enabled: () => envEnabled('CCTV_HK_ENABLED'), + load: loadHkSourcesFromOpenData, + }, { name: 'deldot', enabled: () => envEnabled('CCTV_DELDOT_ENABLED'), diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 18b9f0c9d..257c292b3 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -309,6 +309,36 @@ export const CATALONIA_ANCHORS = [ { lat: 41.5636, lon: 2.0111 }, // Terrassa ]; +/** Hong Kong Transport Department traffic snapshot cameras: keyless English + * locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */ +export const DEFAULT_HK_CAMERAS_URL = + 'https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml'; +/** The only origin HK camera frames may come from. Verified 2026-10-11: still + * URLs answer 200 on this host with no redirect hop. */ +export const HK_IMAGE_ORIGIN = 'https://tdcctv.data.one.gov.hk/'; +/** Above the full TD English catalog (~1,013 cameras) so a default install + * keeps nearly all of them. Catalog-wide round-robin still applies when the + * sum of packs exceeds DEFAULT_CCTV_MAX_SOURCES (Norway-style). */ +export const DEFAULT_HK_MAX_SOURCES = 1100; +/** Prioritization anchors across Hong Kong Island, Kowloon and the New + * Territories so a lowered cap keeps territory-wide coverage rather than + * only the harbour corridor. */ +export const HK_ANCHORS = [ + { lat: 22.2819, lon: 114.158 }, // Central + { lat: 22.2976, lon: 114.1722 }, // Tsim Sha Tsui + { lat: 22.3827, lon: 114.19 }, // Sha Tin + { lat: 22.3701, lon: 114.114 }, // Tsuen Wan + { lat: 22.4503, lon: 114.1688 }, // Tai Po + { lat: 22.493, lon: 114.139 }, // Fanling + { lat: 22.391, lon: 113.977 }, // Tuen Mun + { lat: 22.4445, lon: 114.022 }, // Yuen Long + { lat: 22.307, lon: 114.26 }, // Tseung Kwan O +]; +/** Hard ceiling on the locations XML body. The live English list is ~400 KB + * for ~1,000 cameras; this only exists so an unbounded upstream cannot be + * buffered without limit. */ +export const HK_MAX_CATALOG_BYTES = 4 * 1024 * 1024; + /** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view * of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole * country (~900 cameras). The raw DATEX node needs an account; this view does diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 37d846c1c..0f84fc3e1 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -399,6 +399,17 @@ export function isLikelyNycCoordinate(lat, lon) { ); } +/** Hong Kong SAR extent, with slack for the outlying islands. */ +export function isLikelyHongKongCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 22.15 && + lat <= 22.6 && + lon >= 113.8 && + lon <= 114.45 + ); +} + /** Mainland Norway, with slack for the coast and the Finnmark border. */ export function isLikelyNorwayCoordinate(lat, lon) { return ( diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 57b8d23bf..fbd9cb99d 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -76,6 +76,11 @@ import { TORONTO_DOWNTOWN, TORONTO_GROUND_ELEVATION_M, TORONTO_MAX_CATALOG_BYTES, + DEFAULT_HK_CAMERAS_URL, + HK_IMAGE_ORIGIN, + DEFAULT_HK_MAX_SOURCES, + HK_ANCHORS, + HK_MAX_CATALOG_BYTES, CCTV_SOURCE_FETCH_TIMEOUT_MS, TARKTEE_FETCH_TIMEOUT_MS, DELDOT_CCTV_URL, @@ -138,6 +143,7 @@ import { isLikelyCataloniaCoordinate, decodeNumericEntities, isLikelyNycCoordinate, + isLikelyHongKongCoordinate, isLikelyNorwayCoordinate, isLikelyTorontoCoordinate, cameraDisplayCode, @@ -2003,6 +2009,226 @@ export async function loadCataloniaSourcesFromOpenData() { } } +/** + * Read one XML child tag's text from an `` block. + * + * @param {string} body + * @param {string} tag + * @returns {string} + */ +function hkXmlTag(body, tag) { + const match = new RegExp(`<${tag}>\\s*([^<]*?)\\s*`, 'i').exec( + String(body || ''), + ); + return match ? match[1].trim() : ''; +} + +/** + * Parse the Transport Department English camera-locations XML into rows. + * + * Schema-specific: each `` carries key, description, lat/lon and a + * still URL. No XML dependency — same regex style as the Tarktee DATEX parser. + * + * @param {string} xml + * @returns {Array} + */ +export function parseHkCameraLocationsXml(xml) { + const rows = []; + const blockRe = /([\s\S]*?)<\/image>/gi; + let match; + while ((match = blockRe.exec(String(xml || ''))) !== null) { + const body = match[1]; + rows.push({ + key: hkXmlTag(body, 'key'), + region: hkXmlTag(body, 'region'), + district: hkXmlTag(body, 'district'), + description: hkXmlTag(body, 'description'), + latitude: hkXmlTag(body, 'latitude'), + longitude: hkXmlTag(body, 'longitude'), + url: hkXmlTag(body, 'url'), + }); + } + return rows; +} + +/** + * Build a host-pinned still URL from a Transport Department camera key. + * + * The catalog also publishes a URL; the registered frame is always rebuilt + * from the key so a catalog edit cannot steer the proxy off-origin. + * + * @param {string|null|undefined} key + * @returns {?string} + */ +export function hkImageUrlFromKey(key) { + const id = String(key ?? '') + .trim() + .toUpperCase(); + if (!/^[A-Z0-9]{1,32}$/.test(id)) return null; + return `${HK_IMAGE_ORIGIN}${id}.JPG`; +} + +/** + * Pin a catalog frame URL to the Transport Department still host. + * + * @param {string|null|undefined} raw + * @returns {?string} + */ +export function normalizeHkImageUrl(raw) { + const text = String(raw ?? '').trim(); + if (!text) return null; + let parsed; + try { + parsed = new URL(text); + } catch { + return null; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + parsed.protocol = 'https:'; + const upgraded = parsed.toString(); + return upgraded.startsWith(HK_IMAGE_ORIGIN) ? upgraded : null; +} + +/** + * Label for one HK camera: the published English description, else the key. + * + * @param {object} record + * @param {string} cameraId + * @returns {string} + */ +export function hkCameraName(record, cameraId) { + const description = String(record?.description ?? '').trim(); + if (description) return description; + const key = String(record?.key ?? '').trim(); + if (key) return key; + return `HK Camera ${String(cameraId).replace(/^hk-/, '')}`; +} + +/** + * One Transport Department locations row -> one catalog source, or null. + * + * The dataset publishes no camera facing, so headings use the shared id-hash + * fallback at low confidence and are corrected with the calibration gizmo. + * + * @param {object} record + * @returns {?object} + */ +export function hkCameraToSource(record) { + if (!record || typeof record !== 'object') return null; + const key = String(record.key ?? '') + .trim() + .toUpperCase(); + if (!key) return null; + const lat = toFiniteNumber(record.latitude); + const lon = toFiniteNumber(record.longitude); + if (!isLikelyHongKongCoordinate(lat, lon)) return null; + + const imageUrl = hkImageUrlFromKey(key); + if (!imageUrl) return null; + // If the catalog carries a URL, it must already be on the pinned host. + const catalogUrl = String(record.url ?? '').trim(); + if (catalogUrl) { + const pinnedCatalog = normalizeHkImageUrl(catalogUrl); + if (!pinnedCatalog) return null; + } + + const cameraId = `hk-${key.toLowerCase()}`; + const name = hkCameraName(record, cameraId); + const region = String(record.region ?? '').trim(); + + return { + id: cameraId, + name, + city: region || 'Hong Kong', + cityId: 'hk', + provider: 'Transport Department', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + // Coastal / harbour prior; the client's one-shot ground snap corrects. + groundElevationM: 15, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'hk-td-open-data', + license: + 'Hong Kong Transport Department / DATA.GOV.HK — attribution required', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +/** + * Fetch Hong Kong Transport Department traffic snapshot cameras from the + * keyless DATA.GOV.HK English locations XML. Frames are stills on + * tdcctv.data.one.gov.hk. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadHkSourcesFromOpenData() { + try { + const endpoint = process.env.CCTV_HK_CAMERAS_URL || DEFAULT_HK_CAMERAS_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/xml,text/xml,*/*' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn('[CCTV] HK catalog redirected; redirects are not followed'); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] HK camera download failed:', resp.status); + return discard(); + } + let xml; + try { + xml = await readResponseTextCapped(resp, HK_MAX_CATALOG_BYTES); + } catch (error) { + if (error?.code === 'RESPONSE_TOO_LARGE') { + console.warn('[CCTV] HK catalog body exceeded byte ceiling'); + return []; + } + throw error; + } + const rows = parseHkCameraLocationsXml(xml); + const cameras = []; + const seen = new Set(); + for (const record of rows) { + const camera = hkCameraToSource(record); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_HK_MAX_SOURCES || DEFAULT_HK_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) + : DEFAULT_HK_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, HK_ANCHORS); + console.log( + `[CCTV] Loaded HK camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn('[CCTV] HK camera download error:', error?.message || error); + return []; + } +} + export async function loadDelDOTSourcesFromOpenData() { try { const resp = await fetch(DELDOT_CCTV_URL, { diff --git a/src/data/cctvHk.test.mjs b/src/data/cctvHk.test.mjs new file mode 100644 index 000000000..ecdff231f --- /dev/null +++ b/src/data/cctvHk.test.mjs @@ -0,0 +1,464 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + hkCameraName, + hkCameraToSource, + hkImageUrlFromKey, + loadHkSourcesFromOpenData, + normalizeHkImageUrl, + parseHkCameraLocationsXml, +} from '../../server/providers/cctv/sources.js'; +import { + DEFAULT_CCTV_MAX_SOURCES, + DEFAULT_HK_CAMERAS_URL, + DEFAULT_HK_MAX_SOURCES, + HK_ANCHORS, + HK_IMAGE_ORIGIN, + HK_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; +import { + CAMERA_CODE_MAX_CHARS, + prioritizeSources, +} from '../../server/providers/cctv/normalize.js'; +import { allocateSourceCap } from '../../server/providers/cctv/cap.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +/** + * A response whose body is a live stream, plus a flag that flips when the + * stream is cancelled. A rejection path that returns without cancelling holds + * the transport open, so the flag is what the refusal tests actually assert. + */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +/** One Transport Department `` block as a parsed row. */ +const row = (overrides = {}) => ({ + key: 'BC101F', + region: 'New Territories', + district: 'North', + description: 'San Tin Highway near Lok Ma Chau [BC101F]', + latitude: '22.508', + longitude: '114.078', + url: 'https://tdcctv.data.one.gov.hk/BC101F.JPG', + ...overrides, +}); + +/** Fixture XML matching the published English locations schema. */ +const fixtureXml = (...records) => { + const images = records + .map( + (record) => ` + + ${record.key} + ${record.region} + ${record.district} + ${record.description} + ${record.latitude} + ${record.longitude} + ${record.url} + `, + ) + .join(''); + return `\n${images}\n\n`; +}; + +test('an HK row maps to a source on the pinned HTTPS frame host', () => { + const source = hkCameraToSource(row()); + assert.equal(source.id, 'hk-bc101f'); + assert.equal(source.name, 'San Tin Highway near Lok Ma Chau [BC101F]'); + assert.equal(source.cityId, 'hk'); + assert.equal(source.provider, 'Transport Department'); + assert.equal(source.lat, 22.508); + assert.equal(source.lon, 114.078); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'hk-td-open-data'); + assert.equal( + source.license, + 'Hong Kong Transport Department / DATA.GOV.HK — attribution required', + ); + assert.equal(source.headingConfidence, 'low'); + assert.ok(Number.isFinite(source.headingDeg)); + assert.equal(source.url, 'https://tdcctv.data.one.gov.hk/BC101F.JPG'); + assert.equal(source.url, source.snapshotUrl); + assert.ok(source.url.startsWith(HK_IMAGE_ORIGIN)); +}); + +test('rows outside Hong Kong, off-host frames and unusable keys are dropped', () => { + assert.equal( + hkCameraToSource(row({ latitude: '43.6532', longitude: '-79.3832' })), + null, + ); + assert.equal(hkCameraToSource(row({ latitude: '0', longitude: '0' })), null); + assert.equal(hkCameraToSource(row({ key: '' })), null); + assert.equal(hkCameraToSource(row({ key: 'bad key!' })), null); + assert.equal( + hkCameraToSource(row({ url: 'https://evil.example/BC101F.JPG' })), + null, + ); + assert.equal( + hkCameraToSource( + row({ + url: 'https://tdcctv.data.one.gov.hk.evil.test/BC101F.JPG', + }), + ), + null, + ); + assert.equal(hkCameraToSource(row({ url: 'file:///etc/passwd' })), null); + assert.equal(hkCameraToSource(null), null); +}); + +test('frame URLs are rebuilt from the key and pinned to the TD host', () => { + assert.equal( + hkImageUrlFromKey('bc101f'), + 'https://tdcctv.data.one.gov.hk/BC101F.JPG', + ); + assert.equal(hkImageUrlFromKey(''), null); + assert.equal(hkImageUrlFromKey('bad/key'), null); + + assert.equal( + normalizeHkImageUrl('http://tdcctv.data.one.gov.hk/H429F.JPG'), + 'https://tdcctv.data.one.gov.hk/H429F.JPG', + ); + assert.equal( + normalizeHkImageUrl('https://tdcctv.data.one.gov.hk/H429F.JPG'), + 'https://tdcctv.data.one.gov.hk/H429F.JPG', + ); + assert.equal(normalizeHkImageUrl(''), null); + assert.equal(normalizeHkImageUrl('not a url'), null); + assert.equal(normalizeHkImageUrl('https://evil.example/H429F.JPG'), null); +}); + +test('a nameless row still gets a label', () => { + assert.equal( + hkCameraName( + { description: ' Aberdeen Praya Road near Fish Market [H429F] ' }, + 'hk-h429f', + ), + 'Aberdeen Praya Road near Fish Market [H429F]', + ); + assert.equal(hkCameraName({ key: 'H429F' }, 'hk-h429f'), 'H429F'); + assert.equal(hkCameraName({}, 'hk-h429f'), 'HK Camera h429f'); +}); + +test('the XML parser reads each image block', () => { + const rows = parseHkCameraLocationsXml( + fixtureXml( + row(), + row({ + key: 'H429F', + region: 'Hong Kong Island', + district: 'Southern', + description: 'Aberdeen Praya Road near Fish Market [H429F]', + latitude: '22.24845', + longitude: '114.1505', + url: 'https://tdcctv.data.one.gov.hk/H429F.JPG', + }), + ), + ); + assert.equal(rows.length, 2); + assert.equal(rows[0].key, 'BC101F'); + assert.equal(rows[1].key, 'H429F'); + assert.equal(rows[1].latitude, '22.24845'); +}); + +test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => { + t.mock.method(console, 'log', () => {}); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return new Response( + fixtureXml( + row(), + row({ description: 'San Tin Highway near Lok Ma Chau (duplicate)' }), + row({ + key: 'H210F', + region: 'Hong Kong Island', + district: 'Wan Chai', + description: 'Aberdeen Tunnel - Wan Chai Side [H210F]', + latitude: '22.2747', + longitude: '114.17936', + url: 'https://tdcctv.data.one.gov.hk/H210F.JPG', + }), + row({ url: 'https://evil.example/x.jpg' }), + ), + { headers: { 'Content-Type': 'application/xml' } }, + ); + }); + const cameras = await loadHkSourcesFromOpenData(); + assert.deepEqual(requested, [DEFAULT_HK_CAMERAS_URL]); + assert.deepEqual( + cameras.map((camera) => camera.id), + // Nearest Central/TST first: Wan Chai before the northern New Territories. + ['hk-h210f', 'hk-bc101f'], + ); + assert.equal(cameras[0].name, 'Aberdeen Tunnel - Wan Chai Side [H210F]'); +}); + +test('an upstream failure yields an empty pack and releases the response', async (t) => { + t.mock.method(console, 'warn', () => {}); + const failed = streamingResponse({ status: 503 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadHkSourcesFromOpenData(), []); + assert.equal(failed.state.cancelled, true, 'the failed body is cancelled'); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadHkSourcesFromOpenData(), []); +}); + +test('the unselected label is the description, trimmed to the code width', () => { + assert.equal( + hkCameraToSource(row({ description: 'San Tin Highway [BC101F]' })).code, + 'SAN TIN HIGHWAY [BC101F]', + ); + const long = hkCameraToSource( + row({ + description: + 'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW [BC101F]', + }), + ); + assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS); + assert.ok(long.code.endsWith('…')); + assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF')); +}); + +test('the catalog fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + const seen = []; + const redirected = streamingResponse({ + status: 302, + headers: { location: 'https://evil.example/cameras.xml' }, + }); + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return redirected.response; + }); + assert.deepEqual(await loadHkSourcesFromOpenData(), []); + assert.deepEqual(seen, [[DEFAULT_HK_CAMERAS_URL, 'manual']]); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const body = fixtureXml(row()); + return new Response(body, { + headers: { + 'Content-Type': 'application/xml', + 'content-length': String(HK_MAX_CATALOG_BYTES + 1), + }, + }); + }); + assert.deepEqual(await loadHkSourcesFromOpenData(), []); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const oversized = 'x'.repeat(HK_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(oversized)); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'application/xml' } }, + ); + }); + assert.deepEqual(await loadHkSourcesFromOpenData(), []); +}); + +test('a reduced catalog cap thins every pack instead of dropping HK', () => { + const lane = (name, count) => ({ + name, + sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })), + }); + const { sources, packs } = allocateSourceCap( + [ + lane('austin', 250), + lane('calgary', 215), + lane('hk', DEFAULT_HK_MAX_SOURCES), + ], + 30, + ); + assert.equal(sources.length, 30); + assert.deepEqual( + packs.map((p) => [p.name, p.kept]), + [ + ['austin', 10], + ['calgary', 10], + ['hk', 10], + ], + ); + assert.deepEqual( + sources.filter((s) => s.id.startsWith('hk-')).map((s) => s.id), + Array.from({ length: 10 }, (_, i) => `hk-${i}`), + ); +}); + +/** + * Serve the HK catalog to the HK endpoint and an empty payload to every other + * pack, so one catalog refresh exercises the registration without reaching the + * network. Returns the URLs that were requested. + */ +const runCatalogWithMockedUpstreams = async (t) => { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + requested.push(href); + if (href.includes('Traffic_Camera_Locations_En.xml')) { + return new Response( + fixtureXml( + row(), + row({ + key: 'H210F', + region: 'Hong Kong Island', + district: 'Wan Chai', + description: 'Aberdeen Tunnel - Wan Chai Side [H210F]', + latitude: '22.2747', + longitude: '114.17936', + url: 'https://tdcctv.data.one.gov.hk/H210F.JPG', + }), + ), + { headers: { 'Content-Type': 'application/xml' } }, + ); + } + return Response.json([]); + }); + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +test('the HK lane is wired into the catalog and its loader runs', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_HK_ENABLED; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.ok( + requested.includes(DEFAULT_HK_CAMERAS_URL), + 'the catalog refresh invokes the HK loader', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'hk').map((s) => s.id), + ['hk-h210f', 'hk-bc101f'], + 'HK cameras reach the served catalog through the registered lane', + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + process.env.CCTV_HK_ENABLED = '0'; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.equal( + requested.includes(DEFAULT_HK_CAMERAS_URL), + false, + 'the disabled lane never reaches its upstream', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'hk'), + [], + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('the shipped catalog ceiling is not raised to make room for this pack', () => { + assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000); +}); + +test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => { + assert.ok( + DEFAULT_HK_MAX_SOURCES >= 1013, + 'default cap should keep nearly all published TD cameras', + ); + assert.ok( + DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES, + 'a single pack must not outgrow the catalog-wide ceiling alone', + ); + const places = HK_ANCHORS.map( + (a) => `${a.lat.toFixed(2)},${a.lon.toFixed(2)}`, + ); + assert.ok(places.includes('22.38,114.19'), 'Sha Tin anchor'); + assert.ok(places.includes('22.37,114.11'), 'Tsuen Wan anchor'); + assert.ok(places.includes('22.45,114.17'), 'Tai Po anchor'); + assert.ok(places.includes('22.49,114.14'), 'Fanling anchor'); + assert.ok(places.includes('22.39,113.98'), 'Tuen Mun anchor'); + assert.ok(places.includes('22.44,114.02'), 'Yuen Long anchor'); + assert.ok(places.includes('22.31,114.26'), 'Tseung Kwan O anchor'); + assert.ok(places.includes('22.28,114.16'), 'Central anchor'); + assert.ok(places.includes('22.30,114.17'), 'Tsim Sha Tsui anchor'); +}); + +test('a lowered HK cap still keeps Sha Tin when New Territories anchors are set', () => { + const cameras = [ + hkCameraToSource( + row({ + key: 'H001F', + region: 'Hong Kong Island', + district: 'Central & Western', + description: 'Central harbour [H001F]', + latitude: '22.2819', + longitude: '114.158', + }), + ), + hkCameraToSource( + row({ + key: 'ST101F', + region: 'New Territories', + district: 'Sha Tin', + description: 'Sha Tin Road [ST101F]', + latitude: '22.3827', + longitude: '114.19', + }), + ), + hkCameraToSource( + row({ + key: 'K999F', + region: 'Kowloon', + district: 'Yau Tsim Mong', + description: 'Far Kowloon filler [K999F]', + latitude: '22.32', + longitude: '114.18', + }), + ), + ].filter(Boolean); + const kept = prioritizeSources(cameras, 2, HK_ANCHORS); + assert.deepEqual(kept.map((camera) => camera.id).sort(), [ + 'hk-h001f', + 'hk-st101f', + ]); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 42c6a1409..bac44d79c 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -289,6 +289,13 @@ export const DATA_CREDITS = [ 'KCGIS Center terms. ' + 'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.', }, + { + key: 'hk-cctv', + html: + 'Traffic snapshot cameras (Hong Kong): ' + + 'Transport Department / DATA.GOV.HK ' + + '(Terms of Use — attribution required)', + }, { key: 'vegvesen-cctv', html: From d5545243c5685f89b8fc49465b3a928ea814d629 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 20 Oct 2026 14:40:01 +0100 Subject: [PATCH 15/21] chore(cctv-hk): changelog fragment and .env.example entries Move the Hong Kong CCTV changelog entry into changelog.d/ (main now takes fragments instead of CHANGELOG.md edits) and document CCTV_HK_ENABLED, CCTV_HK_MAX_SOURCES and CCTV_HK_CAMERAS_URL beside the other packs. Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 +++ changelog.d/990-hong-kong-cctv.md | 7 +++++++ 2 files changed, 10 insertions(+) create mode 100644 changelog.d/990-hong-kong-cctv.md diff --git a/.env.example b/.env.example index 92dbcd2a8..424f45691 100644 --- a/.env.example +++ b/.env.example @@ -228,6 +228,9 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL # CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras # CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today) +# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras +# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200 +# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL # CCTV_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras # CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog # CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras diff --git a/changelog.d/990-hong-kong-cctv.md b/changelog.d/990-hong-kong-cctv.md new file mode 100644 index 000000000..a150b66ae --- /dev/null +++ b/changelog.d/990-hong-kong-cctv.md @@ -0,0 +1,7 @@ +- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV + source pack: the public DATA.GOV.HK English locations XML, frames pinned to + `tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By + default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads, + prioritized across Hong Kong Island, Kowloon and the New Territories. Headings + use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets + the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990). From ce5f69b586b4f5d23fa02a080d6ba10508d5b7ec Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 20 Oct 2026 14:51:48 +0100 Subject: [PATCH 16/21] fix(cctv): back off the Taiwan catalog when unreachable; review fixes - taiwan.js: a failed download serves the last good table (up to 24 h) and is not retried for an hour. The table host does not answer from some networks outside Taiwan, and each attempt held the shared catalog refresh for the ~10 s connect timeout. - Own server/providers/cctv/taiwan.js in package-boundaries.json (check:boundaries failed). - Decode numeric character references; name falls back to the CCTVID when a row has no road details. - Credit names the dataset and links the OGDL Taiwan licence. - Document CCTV_TAIWAN_URL; list Taiwan in the CURRENT-STATE source table. - Tests: isolate catalog env, reset loader state, cover the byte cap, failed fetch, backoff, last-good fallback, cap clamping, redirect with a valid body, name fallback, numeric entities and UUID camera ids. Co-Authored-By: Claude Opus 5.5 --- .env.example | 1 + DATA_SOURCES.md | 2 +- docs/CURRENT-STATE.md | 10 +- scripts/package-boundaries.json | 1 + server/providers/cctv/taiwan.js | 88 +++++++++++++---- src/data/cctvTaiwan.test.mjs | 166 +++++++++++++++++++++++++++++--- src/data/dataCredits.js | 3 +- 7 files changed, 237 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index c318bfd49..78264489b 100644 --- a/.env.example +++ b/.env.example @@ -249,6 +249,7 @@ AISSTREAM_API_KEY= # CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first) # CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras # CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required +# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index d9dddf995..728381632 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -112,7 +112,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei **Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent. - **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400. -- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse with attribution; the app credits the Freeway Bureau, MOTC. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000). +- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000). - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a0a190153..1c64f60fe 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1102,14 +1102,18 @@ The CCTV catalog includes Taiwan national freeway cameras from the Freeway Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table (~1,800 cameras); the request refuses redirects, has a 15-second deadline and a 6 MiB response cap, and uses the shared 15-minute cache and fair catalog -allocation. An unavailable source contributes an empty pack. The table +allocation. A failed download serves the last good table (up to a day old), +or an empty pack, and is not retried for an hour: the catalog host does not +answer from some networks outside Taiwan, and without the backoff every refresh +there would wait out the connect timeout. The table publishes MJPEG stream URLs; each is mapped to the same camera server's single-JPEG path for the same camera number, and only those exact HTTPS pairs on the four `freeway.gov.tw` camera servers are registered with the frame proxy. Camera keys come from the server and camera number, because published CCTV IDs contain Chinese place names and a few repeat across cameras. Poses use the low-confidence heading prior. `CCTV_TAIWAN_ENABLED=0` disables the pack; -`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000). +`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000), and +`CCTV_TAIWAN_URL` overrides the table URL. Gate: `src/data/cctvTaiwan.test.mjs`. ## DriveBC CCTV source pack @@ -3474,7 +3478,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| 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, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| 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, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + 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, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5e3f3eb69..bf33bebc7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -191,6 +191,7 @@ "server/providers/cctv/groundHeights.js", "server/providers/cctv/catalog.js", "server/providers/cctv/lithuania.js", + "server/providers/cctv/taiwan.js", "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", diff --git a/server/providers/cctv/taiwan.js b/server/providers/cctv/taiwan.js index 0a876f7cf..830ed47fd 100644 --- a/server/providers/cctv/taiwan.js +++ b/server/providers/cctv/taiwan.js @@ -13,6 +13,13 @@ export const TAIWAN_FREEWAY_CCTV_URL = /** The whole network is ~1.2 MB. */ export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024; export const DEFAULT_TAIWAN_MAX_SOURCES = 600; +/** After a failed download, wait this long before asking again. The catalog + * host does not answer from some networks outside Taiwan, and every attempt + * there holds the shared catalog refresh until the connect timeout. */ +export const TAIWAN_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000; +/** The table is republished daily; a good copy stands in for a failed + * download for up to this long. */ +export const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000; // The table publishes each camera's MJPEG stream. The same camera servers // answer a single JPEG for the same camera ID on a sibling path, which is what @@ -62,9 +69,20 @@ const XML_ENTITIES = { apos: "'", }; +function codePoint(value, radix) { + const code = parseInt(value, radix); + return Number.isInteger(code) && code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : ''; +} + function decodeXml(text) { return String(text ?? '') - .replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => XML_ENTITIES[name]) + .replace( + /&(?:(amp|lt|gt|quot|apos)|#(\d{1,7})|#x([0-9a-fA-F]{1,6}));/g, + (_, name, dec, hex) => + name ? XML_ENTITIES[name] : codePoint(dec ?? hex, dec ? 10 : 16), + ) .trim(); } @@ -156,9 +174,11 @@ export function taiwanCameraToSource(row) { .map((part) => String(part ?? '').trim()) .filter(Boolean) .join('→'); + // A bare carriageway letter is not a name; fall back to the published ID. const name = - [road, direction, mile].filter(Boolean).join(' ') + - (section ? ` ${section}` : '') || + (road || mile || section + ? [road, direction, mile, section].filter(Boolean).join(' ') + : '') || rawId || `Freeway camera ${frame.camera}`; @@ -191,13 +211,42 @@ export function taiwanCameraToSource(row) { }; } +let lastGood = null; +let lastGoodAt = 0; +let retryAfter = 0; + +/** Forget the last good table and any failure backoff (tests only). */ +export function resetTaiwanSourcesCache() { + lastGood = null; + lastGoodAt = 0; + retryAfter = 0; +} + +function fallbackSources() { + return lastGood && Date.now() - lastGoodAt <= TAIWAN_LAST_GOOD_MAX_AGE_MS + ? lastGood + : []; +} + +function taiwanSourceCap() { + const rawCap = Number( + process.env.CCTV_TAIWAN_MAX_SOURCES || DEFAULT_TAIWAN_MAX_SOURCES, + ); + return Number.isFinite(rawCap) + ? Math.max(1, Math.min(2000, Math.floor(rawCap))) + : DEFAULT_TAIWAN_MAX_SOURCES; +} + /** * Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table. - * One bounded request per shared catalog refresh. + * One bounded request per shared catalog refresh. A failed download serves the + * last good table (up to a day old) and is not retried for an hour, so a host + * that cannot reach the Freeway Bureau does not wait on it every refresh. * * @returns {Promise>} */ export async function loadTaiwanSources() { + if (Date.now() < retryAfter) return fallbackSources(); try { const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS); const response = await fetch( @@ -210,13 +259,11 @@ export async function loadTaiwanSources() { ); if (!response.ok) { await response.body?.cancel().catch(() => {}); - console.warn( - '[CCTV] Taiwan camera download failed:', + throw new Error( response.status >= 300 && response.status < 400 ? 'catalog redirected; redirects are not followed' - : response.status, + : `HTTP ${response.status}`, ); - return []; } const xml = await readResponseTextCapped( response, @@ -228,22 +275,29 @@ export async function loadTaiwanSources() { const camera = taiwanCameraToSource(row); if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera); } - const rawCap = Number( - process.env.CCTV_TAIWAN_MAX_SOURCES || DEFAULT_TAIWAN_MAX_SOURCES, + if (!cameras.size) throw new Error('no usable cameras in the table'); + const sources = prioritizeSources( + [...cameras.values()], + taiwanSourceCap(), + ANCHORS, ); - const cap = Number.isFinite(rawCap) - ? Math.max(1, Math.min(2000, Math.floor(rawCap))) - : DEFAULT_TAIWAN_MAX_SOURCES; - const sources = prioritizeSources([...cameras.values()], cap, ANCHORS); console.log( `[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`, ); + lastGood = sources; + lastGoodAt = Date.now(); + retryAfter = 0; return sources; } catch (error) { + retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS; + const fallback = fallbackSources(); console.warn( - '[CCTV] Taiwan camera download error:', - error?.message || error, + `[CCTV] Taiwan camera download failed (${error?.message || error}); ` + + (fallback.length + ? `serving the last good ${fallback.length} cameras` + : 'no Taiwan cameras') + + ', next attempt in 1 h', ); - return []; + return fallback; } } diff --git a/src/data/cctvTaiwan.test.mjs b/src/data/cctvTaiwan.test.mjs index 73604e08c..4794f11a8 100644 --- a/src/data/cctvTaiwan.test.mjs +++ b/src/data/cctvTaiwan.test.mjs @@ -1,14 +1,45 @@ -import { test } from 'node:test'; +import { afterEach, beforeEach, test } from 'node:test'; import assert from 'node:assert/strict'; import { loadTaiwanSources, parseTaiwanCctvXml, + resetTaiwanSourcesCache, taiwanCameraToSource, taiwanFrame, TAIWAN_FREEWAY_CCTV_URL, + TAIWAN_MAX_CATALOG_BYTES, + TAIWAN_RETRY_AFTER_FAILURE_MS, } from '../../server/providers/cctv/taiwan.js'; import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; +// Settings that would steer the loader or the catalog away from the mocks. +const ISOLATED_ENV = [ + 'CCTV_SOURCES_FILE', + 'CCTV_SOURCES_JSON', + 'CCTV_PREFER_AUSTIN', + 'CCTV_FORCE_AUSTIN', + 'CCTV_TAIWAN_ENABLED', + 'CCTV_TAIWAN_URL', + 'CCTV_TAIWAN_MAX_SOURCES', +]; + +let savedEnv = {}; + +beforeEach(() => { + resetTaiwanSourcesCache(); + savedEnv = Object.fromEntries( + ISOLATED_ENV.map((key) => [key, process.env[key]]), + ); + for (const key of ISOLATED_ENV) delete process.env[key]; +}); + +afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + function cctvXml(...blocks) { return ` @@ -158,27 +189,21 @@ test('a redirected or failed catalog contributes an empty pack', async (t) => { globalThis, 'fetch', async () => - new Response('', { + new Response(cctvXml(block()), { status, headers: status === 302 ? { location: 'https://evil.test/' } : {}, }), ); + resetTaiwanSourcesCache(); assert.deepEqual(await loadTaiwanSources(), []); } }); async function runCatalogWithTaiwanFlag(t, flag) { const requested = mockCatalog(t, cctvXml(block())); - const saved = process.env.CCTV_TAIWAN_ENABLED; - try { - if (flag === undefined) delete process.env.CCTV_TAIWAN_ENABLED; - else process.env.CCTV_TAIWAN_ENABLED = flag; - const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); - return { requested, sources }; - } finally { - if (saved === undefined) delete process.env.CCTV_TAIWAN_ENABLED; - else process.env.CCTV_TAIWAN_ENABLED = saved; - } + if (flag !== undefined) process.env.CCTV_TAIWAN_ENABLED = flag; + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; } test('the Taiwan pack is in the catalog by default', async (t) => { @@ -198,3 +223,120 @@ test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => { ); assert.ok(!sources.some((c) => c.cityId === 'taiwan')); }); + +test('a catalog over the byte cap or a failed request contributes an empty pack', async (t) => { + t.mock.method(console, 'warn', () => {}); + const padding = ``; + t.mock.method( + globalThis, + 'fetch', + async () => new Response(cctvXml(block()) + padding), + ); + assert.deepEqual(await loadTaiwanSources(), []); + resetTaiwanSourcesCache(); + t.mock.method(globalThis, 'fetch', async () => { + throw new TypeError('fetch failed'); + }); + assert.deepEqual(await loadTaiwanSources(), []); +}); + +test('a failed download is not retried until the backoff ends', async (t) => { + t.mock.method(console, 'warn', () => {}); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + throw new TypeError('fetch failed'); + }); + t.mock.method(Date, 'now', () => 1_000_000); + assert.deepEqual(await loadTaiwanSources(), []); + assert.deepEqual(await loadTaiwanSources(), []); + assert.equal(calls, 1, 'the second refresh made no request'); + Date.now.mock.mockImplementation( + () => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1, + ); + await loadTaiwanSources(); + assert.equal(calls, 2, 'the backoff ends after an hour'); +}); + +test('a failed download serves the last good table', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const fetchMock = t.mock.method( + globalThis, + 'fetch', + async () => new Response(cctvXml(block())), + ); + const first = await loadTaiwanSources(); + assert.deepEqual( + first.map((c) => c.id), + ['tw-freeway-cctvn-10000'], + ); + fetchMock.mock.mockImplementation( + async () => new Response('', { status: 503 }), + ); + assert.deepEqual( + (await loadTaiwanSources()).map((c) => c.id), + ['tw-freeway-cctvn-10000'], + ); +}); + +test('CCTV_TAIWAN_MAX_SOURCES is clamped to 1-2000', async (t) => { + t.mock.method(console, 'log', () => {}); + const rows = Array.from({ length: 3 }, (_, i) => + block({ + stream: `https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=${i + 1}`, + }), + ); + t.mock.method( + globalThis, + 'fetch', + async () => new Response(cctvXml(...rows)), + ); + for (const [value, expected] of [ + ['0', 1], + ['2', 2], + ['5000', 3], + ['abc', 3], + ]) { + resetTaiwanSourcesCache(); + process.env.CCTV_TAIWAN_MAX_SOURCES = value; + const sources = await loadTaiwanSources(); + assert.equal(sources.length, expected, value); + assert.equal(sources.available, 3, value); + } +}); + +test('names fall back to the published ID when the row has no road details', () => { + const xml = cctvXml(block({ id: 'CCTV-N3-N-100-M' })) + .replace('國道1號', '') + .replace('0K+000', '') + .replace('基隆端基隆交流道', ''); + const camera = taiwanCameraToSource(parseTaiwanCctvXml(xml)[0]); + assert.equal(camera.name, 'CCTV-N3-N-100-M'); +}); + +test('numeric character references in the table are decoded', () => { + const xml = cctvXml(block()).replace( + '國道1號', + '國道1號', + ); + assert.equal(parseTaiwanCctvXml(xml)[0].roadName, '國道1號'); +}); + +test('camera numbers that are UUIDs keep their case in the frame URL only', () => { + const camera = taiwanCameraToSource( + parseTaiwanCctvXml( + cctvXml( + block({ + stream: + 'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=C3A580C9-0232-46A1-BFC6-927590758AB4', + }), + ), + )[0], + ); + assert.equal( + camera.id, + 'tw-freeway-cctvn5-c3a580c9-0232-46a1-bfc6-927590758ab4', + ); + assert.match(camera.url, /camera=C3A580C9-0232-46A1-BFC6-927590758AB4$/); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 06c616dd7..7b6cba9b7 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -263,7 +263,8 @@ export const DATA_CREDITS = [ html: 'Freeway camera stills (Taiwan): ' + 'Freeway Bureau, MOTC, ' + - 'Open Government Data License, Taiwan (Version 1.0)', + 'CCTV static data (MOTC 2.0), released under the ' + + 'Open Government Data License, Taiwan (Version 1.0)', }, { key: 'calgary-cctv', From 5f99cd25ec6ee04cf022f5cc6969a90f9ae6b77e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 20 Oct 2026 09:48:49 -0700 Subject: [PATCH 17/21] docs(changelog): credit the GitHub handle for #1205 --- changelog.d/1205-cctv-tool-routing.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog.d/1205-cctv-tool-routing.md b/changelog.d/1205-cctv-tool-routing.md index 72e8859c5..ed7552591 100644 --- a/changelog.d/1205-cctv-tool-routing.md +++ b/changelog.d/1205-cctv-tool-routing.md @@ -1,2 +1,3 @@ - Report actual outcomes when voice enables or disables CCTV through the shared - layer lifecycle, including failed and cancelled changes (Manjunath, #1205). + layer lifecycle, including failed and cancelled changes (manjunath22466, + #1205). From 803cddd5cb23e39161be4e1356d4d02057c341b9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 20 Oct 2026 09:48:49 -0700 Subject: [PATCH 18/21] docs(analyst): describe region resolution as current state --- changelog.d/1235-location-resolution.md | 4 +++- docs/CURRENT-STATE.md | 17 ++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/changelog.d/1235-location-resolution.md b/changelog.d/1235-location-resolution.md index 85cb62cae..160688005 100644 --- a/changelog.d/1235-location-resolution.md +++ b/changelog.d/1235-location-resolution.md @@ -1 +1,3 @@ -- Require a geocoded containment anchor for ambiguous analyst region names, preserve boundary provenance and cancellation safety, and retain exact-name precedence for natural outlines (manjunath22466, #1235). +- Require a geocoded containment anchor for ambiguous analyst region names, + preserve boundary provenance and cancellation safety, and retain exact-name + precedence for natural outlines (manjunath22466, #1235). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 391b4b3d6..ee070b25f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,14 +1,13 @@ # God's Eye View Current State -Analyst region queries retain the existing offline lookup for unique Natural -Earth names without geocoding: Gold Coast and Ivory Coast still name the West -African natural regions. Duplicate names require a geocoded containment anchor. -When multiple entries for the same name contain that anchor, the lookup now stays -unresolved instead of selecting the largest entry. Natural outlines try exact -names before aliases and broader suffix variants. Follow-ups retain boundary provenance, and -cancelled lookups preserve the previous analyst cohort and boundary source. -Navigation keeps main's existing successful-geocode precedence; this change adds -no search or view-based annotation recovery. +Analyst region queries resolve unique Natural Earth names offline without +geocoding, so Gold Coast and Ivory Coast name the West African natural regions. +Duplicate names require a geocoded containment anchor; when several entries for +the same name contain that anchor, the lookup stays unresolved rather than +selecting the largest entry. Natural outlines try exact names before aliases and +broader suffix variants. Follow-ups retain boundary provenance, and cancelled +lookups preserve the previous analyst cohort and boundary source. Navigation +search keeps a successful geocode, with no search or view-based region recovery. Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is From 40394ff76e408c8eb0bd49adf3aed16904265272 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Wed, 21 Oct 2026 13:07:51 +0200 Subject: [PATCH 19/21] Unify segmented selectors and fix compact panel spacing --- docs/CURRENT-STATE.md | 15 ++ scripts/format-scope.json | 3 + scripts/package-boundaries.json | 5 +- scripts/qa-segmented-controls.mjs | 286 +++++++++++++++++++++++++ scripts/qa-street-level.mjs | 41 ++++ src/annotations/drawTool.js | 20 +- src/ui/aircraftDisplay.js | 20 +- src/ui/displayControls.js | 27 ++- src/ui/recentImagery.js | 41 ++-- src/ui/segmentedControl.js | 109 ++++++++++ src/ui/segmentedControl.test.mjs | 174 +++++++++++++++ src/ui/streetLevelControls.js | 82 +++---- src/ui/styles/controls.css | 24 +-- src/ui/styles/cyber.css | 2 + src/ui/styles/layers.css | 4 +- src/ui/styles/recent-imagery.css | 1 - src/ui/styles/segmented-control.css | 51 +++++ src/ui/styles/street-level.css | 42 ---- src/ui/templates/display-controls.html | 20 +- src/ui/templates/layer-panels.html | 14 +- src/ui/visualSettings.js | 9 +- style.css | 1 + 22 files changed, 794 insertions(+), 197 deletions(-) create mode 100644 scripts/qa-segmented-controls.mjs create mode 100644 src/ui/segmentedControl.js create mode 100644 src/ui/segmentedControl.test.mjs create mode 100644 src/ui/styles/segmented-control.css diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eacfa1147..419e3efb6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1224,6 +1224,21 @@ The application supplies actions and retains effect settings, restore claims and rendering. Native keyboard editing, model modes and current defaults are preserved. Destroying the UI removes these listeners before asynchronous teardown. +## Segmented selectors + +Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js` +and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label +allocation, drawing shape, Street View fit/panorama filters and imagery mode keep +their feature-owned values, actions and persistence. Painting a restored value +does not emit a change or move focus. Existing keyboard policies remain explicit: +Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from +its controlled selection, and Display keeps its existing native button behavior. +Pure value navigation is separate from DOM rendering for reuse by a future view. +Segments keep their word minimum width: compact inline groups such as FIT/FILL +and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows +the same exclusive-rail hiding rule as the other readouts. +Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`. + ## Application shortcuts and shader parameter controls `ui/input` supplies the bubbling application shortcut listener and generated diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 83e961d8b..b365c85c3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -190,7 +190,10 @@ "src/ui/visualInput.js", "src/ui/visualInput.test.mjs", "scripts/qa-visual-input.mjs", + "scripts/qa-segmented-controls.mjs", "src/ui/displayControls.js", + "src/ui/segmentedControl.test.mjs", + "src/ui/styles/segmented-control.css", "src/ui/displayControls.test.mjs", "src/ui/cameraOrientationControls.js", "src/ui/cameraOrientationControls.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index bf33bebc7..b4fd6ace2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -431,7 +431,7 @@ }, "display-controls": { "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js"], + "modules": ["src/ui/displayControls.js", "src/ui/segmentedControl.js"], "external": [] }, "visual-effects": { @@ -701,6 +701,7 @@ "src/ui/contextSubscriptions.js", "src/ui/contextTransactions.js", "src/ui/displayControls.js", + "src/ui/segmentedControl.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", @@ -2176,6 +2177,7 @@ "src/ui/contextSubscriptions.js", "src/ui/contextTransactions.js", "src/ui/displayControls.js", + "src/ui/segmentedControl.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", @@ -3212,6 +3214,7 @@ "src/ui/visualInput.js", "src/ui/visualPresets.js", "src/ui/visualSettings.js", + "src/ui/segmentedControl.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", diff --git a/scripts/qa-segmented-controls.mjs b/scripts/qa-segmented-controls.mjs new file mode 100644 index 000000000..cfa8e51d6 --- /dev/null +++ b/scripts/qa-segmented-controls.mjs @@ -0,0 +1,286 @@ +#!/usr/bin/env node +/** Run against a dev server or built preview with QA_BASE_URL (default :4179). */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), + headless: true, + args: ['--use-gl=angle', '--use-angle=swiftshader'], +}); +const page = await browser.newPage(); +const errors = []; +const failures = []; +page.on('pageerror', (error) => errors.push(error.message)); +const check = (name, passed, detail = '') => { + console.log( + `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`, + ); + if (!passed) failures.push(name); +}; +const base = process.env.QA_BASE_URL || 'http://localhost:4179'; +const shots = process.env.QA_SHOTS_DIR || 'qa-shots/segmented-controls'; +fs.mkdirSync(shots, { recursive: true }); + +try { + await page.setViewport({ width: 1440, height: 900 }); + await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__godsEyeView?.styleManager?._applicationShortcuts, + { timeout: 60000 }, + ); + await page.waitForFunction( + () => + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + await page.evaluate(() => + window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, { + persist: false, + syncShare: false, + }), + ); + + const surface = await page.evaluate(() => { + const groups = [...document.querySelectorAll('[role="radiogroup"]')]; + const buttons = groups.flatMap((group) => [ + ...group.querySelectorAll('[role="radio"]'), + ]); + return { + groups: groups.length, + buttons: buttons.length, + shared: + groups.every((g) => g.classList.contains('gev-segmented')) && + buttons.every((b) => { + const style = getComputedStyle(b); + return ( + b.classList.contains('gev-segment') && + style.fontSize === '9px' && + style.minHeight === '24px' && + style.borderRadius === '0px' + ); + }), + }; + }); + check( + 'all six one-of-many groups use the shared surface', + surface.groups === 6 && surface.buttons === 15 && surface.shared, + JSON.stringify(surface), + ); + + await page.evaluate(() => + document.getElementById('models3d-mode-all').click(), + ); + check( + 'model selection reaches both aircraft layers', + await page.evaluate(() => { + const dm = window.__godsEyeView.dataManager; + return ( + ['flights', 'military'].every( + (id) => dm.layers.get(id).module.getParams().models3dMode === 'all', + ) && + document + .getElementById('models3d-mode-all') + .getAttribute('aria-checked') === 'true' + ); + }), + ); + await page.focus('#models3d-mode-all'); + await page.keyboard.press('ArrowLeft'); + check( + 'Display retains its click-only arrow policy', + await page.evaluate( + () => + document + .getElementById('models3d-mode-all') + .getAttribute('aria-checked') === 'true', + ), + ); + await page.focus('#models3d-mode-proximity'); + await page.keyboard.press('Space'); + check( + 'native Space selects model coverage', + await page.evaluate( + () => + document + .getElementById('models3d-mode-proximity') + .getAttribute('aria-checked') === 'true', + ), + ); + await page.evaluate(() => + document.getElementById('detection-allocation-weighted').click(), + ); + check( + 'allocation selection still updates application tuning', + await page.evaluate( + () => + window.__godsEyeView.styleManager._detectionAllocationPreference === + 'WEIGHTED' && + document + .getElementById('detection-allocation-weighted') + .getAttribute('aria-checked') === 'true', + ), + ); + + await page.evaluate(() => + document.querySelector('[data-shape="pin"]').click(), + ); + check( + 'drawing shape still uses its existing feature action', + await page.evaluate( + () => + window.__gevDrawTool?.shape === 'pin' && + document + .querySelector('[data-shape="pin"]') + .getAttribute('aria-checked') === 'true', + ), + ); + + // Off-layer controls still own params; no catalog/provider request is needed. + await page.evaluate(() => + document.querySelector('[data-sl-pano="flat"]').click(), + ); + check( + 'panorama choice reaches the Street Level layer', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getParams().pano === 'flat', + ), + ); + await page.evaluate(() => + document.querySelector('[data-sl-pano="flat"]').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Home', + bubbles: true, + cancelable: true, + }), + ), + ); + check( + 'Street Level Home retains selection and roving tab state', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getParams().pano === 'all' && + document + .querySelector('[data-sl-pano="all"]') + .getAttribute('tabindex') === '0', + ), + ); + await page.evaluate(() => + document.querySelector('[data-sl-render="fill"]').click(), + ); + check( + 'fit/fill keeps its stored render preference', + await page.evaluate( + () => + localStorage.getItem('gev:street-level:render-mode') === 'fill' && + document + .querySelector('[data-sl-render="fill"]') + .getAttribute('aria-checked') === 'true', + ), + ); + + await page.evaluate(() => + document.querySelector('.ri-mode-btn[data-mode="ab"]').click(), + ); + check( + 'imagery mode reaches its controlled layer state', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('recent-imagery') + .module.getSnapshot().mode === 'ab', + ), + ); + await page.evaluate(() => + document.getElementById('ri-mode').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true, + }), + ), + ); + check( + 'imagery arrows wrap from the current selected value', + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('recent-imagery') + .module.getSnapshot().mode === 'image' && + document + .querySelector('.ri-mode-btn[data-mode="image"]') + .getAttribute('aria-checked') === 'true', + ), + ); + + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager._setDetectionAllocation('ELASTIC', { + syncShare: false, + persist: false, + }); + document + .getElementById('models3d-mode-proximity') + .scrollIntoView({ block: 'center' }); + }); + await page.screenshot({ path: `${shots}/desktop.png` }); + await page.setViewport({ width: 620, height: 900 }); + await page.evaluate(() => + document + .getElementById('models3d-mode-proximity') + .scrollIntoView({ block: 'center' }), + ); + await page.screenshot({ path: `${shots}/narrow.png` }); + check( + 'narrow selectors stay within their panel', + await page.evaluate(() => + [...document.querySelectorAll('.pp-mode-seg')] + .filter((g) => g.getClientRects().length) + .every((group) => group.scrollWidth <= group.clientWidth + 1), + ), + ); + const cyber = await page.evaluate(() => { + const root = document.documentElement; + const previous = root.getAttribute('data-ui-theme'); + root.setAttribute('data-ui-theme', 'cyber'); + const selected = [ + ...document.querySelectorAll('.gev-segment[aria-checked="true"]'), + ]; + // Check the final theme colors, independent of software-GL frame cadence. + for (const button of selected) + for (const animation of button.getAnimations()) animation.finish(); + const backgrounds = selected.map( + (button) => getComputedStyle(button).backgroundColor, + ); + const radii = [...document.querySelectorAll('.gev-segmented')].map( + (group) => getComputedStyle(group).borderRadius, + ); + const consistent = + selected.length === 6 && + backgrounds.every((color) => color === backgrounds[0]) && + radii.every((radius) => radius === '1px'); + if (previous === null) root.removeAttribute('data-ui-theme'); + else root.setAttribute('data-ui-theme', previous); + return { consistent, count: selected.length, backgrounds, radii }; + }); + check( + 'Cyber theme styles every selected segment consistently', + cyber.consistent, + JSON.stringify(cyber), + ); + check('no uncaught browser errors', errors.length === 0, errors.join(' | ')); +} catch (error) { + console.error('Browser errors:', errors); + await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {}); + throw error; +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures.length} failures`); +process.exitCode = failures.length ? 1 : 0; diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index d099ed9b5..1be1a80fa 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -661,6 +661,32 @@ async function main() { }, ); + await step( + 'FIT/FILL keeps native selection, keyboard focus and persistence with a photo open', + async () => { + await page.click('[data-sl-render="fill"]'); + await uiUntil(page, (u) => u.street.renderMode === 'fill'); + assert.equal( + await page.evaluate(() => + localStorage.getItem('gev:street-level:render-mode'), + ), + 'fill', + ); + await page.keyboard.press('Home'); + await uiUntil(page, (u) => u.street.renderMode === 'letterbox'); + assert.deepEqual( + await page.$eval('[data-sl-render="letterbox"]', (node) => ({ + selected: node.getAttribute('aria-checked'), + tabIndex: node.tabIndex, + focused: document.activeElement === node, + })), + { selected: 'true', tabIndex: 0, focused: true }, + ); + await page.keyboard.press('End'); + await uiUntil(page, (u) => u.street.renderMode === 'fill'); + }, + ); + await step('× closes the photo and deselects its sequence', async () => { await press(page, '#sl-viewer-close'); await uiUntil( @@ -673,6 +699,21 @@ async function main() { ); }); + await step( + 'panorama segments keep native arrow and Home navigation', + async () => { + await page.click('[data-sl-pano="flat"]'); + await uiUntil(page, (u) => u.filter.pano === 'flat'); + await page.keyboard.press('ArrowRight'); + await uiUntil(page, (u) => u.filter.pano === 'all'); + await page.keyboard.press('End'); + await uiUntil(page, (u) => u.filter.pano === 'flat'); + await page.keyboard.press('Home'); + await uiUntil(page, (u) => u.filter.pano === 'all'); + await waitForCoverage(page); + }, + ); + await keylessProviderStep({ id: 'kartaview', name: 'KartaView', diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 91adb4d0d..0f4e0fc76 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -30,6 +30,7 @@ * at finish, deliberately and in one place — see `finishSpec` in drawMode.js. */ import * as Cesium from 'cesium'; +import { createSegmentedControl } from '../ui/segmentedControl.js'; import { pickWorldFromScreen } from './annotationResolver.js'; import { claimPointer, @@ -432,22 +433,21 @@ export function initDrawTool({ viewer, annotations }) { const shapeButtons = [ ...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []), ]; - for (const btn of shapeButtons) { - listen(btn, 'click', () => { - shape = normalizeShape(btn.dataset.shape); - for (const other of shapeButtons) { - const on = other === btn; - other.classList.toggle('active', on); - other.setAttribute('aria-checked', String(on)); - } + const shapeControl = createSegmentedControl({ + buttons: shapeButtons, + valueOf: (button) => button.dataset.shape, + listen, + onChange: (value) => { + shape = normalizeShape(value); + shapeControl.update(shape); if (active) { generation += 1; session = createDrawSession(shape); cursor = null; syncPreview(); } - }); - } + }, + }); listen(colorSelect, 'change', () => { color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary'; syncPreview(); diff --git a/src/ui/aircraftDisplay.js b/src/ui/aircraftDisplay.js index 2afc77579..ba325f6e9 100644 --- a/src/ui/aircraftDisplay.js +++ b/src/ui/aircraftDisplay.js @@ -1,3 +1,5 @@ +import { syncSegmentedControl } from './segmentedControl.js'; + /** Own the shared aircraft display preference and its controls. */ export class AircraftDisplay { constructor({ elements, readDataManager, layout }) { @@ -28,12 +30,9 @@ export class AircraftDisplay { this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity'; this._syncModels3dButtonState(); this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled); - for (const button of this._models3dModeBtns || []) { - if (!button) continue; - const active = button.dataset.mode === this._models3dMode; - button.classList.toggle('active', active); - button.setAttribute('aria-checked', String(active)); - } + syncSegmentedControl(this._models3dModeBtns, this._models3dMode, { + valueOf: (button) => button.dataset.mode, + }); this._layoutRightPanels(); } @@ -59,12 +58,9 @@ export class AircraftDisplay { const normalized = mode === 'all' ? 'all' : 'proximity'; this._models3dMode = normalized; this._setModels3dParams({ models3dMode: normalized }); - for (const button of this._models3dModeBtns) { - if (!button) continue; - const active = button.dataset.mode === normalized; - button.classList.toggle('active', active); - button.setAttribute('aria-checked', String(active)); - } + syncSegmentedControl(this._models3dModeBtns, normalized, { + valueOf: (button) => button.dataset.mode, + }); this._syncModels3dButtonState(); } diff --git a/src/ui/displayControls.js b/src/ui/displayControls.js index 09276db31..e0d76d338 100644 --- a/src/ui/displayControls.js +++ b/src/ui/displayControls.js @@ -1,3 +1,5 @@ +import { createSegmentedControl } from './segmentedControl.js'; + /** * Bind Display controls to application actions. Inputs remain native DOM controls; * the caller owns settings and effect state. Destroy before replacing the view. @@ -44,12 +46,25 @@ export function bindDisplayControls({ elements, actions }) { listen(elements[name], 'input', action, integer); for (const el of elements.styleButtons || []) listen(el, 'click', 'setStyle', (el) => el.dataset.style); - for (const el of elements.allocationButtons || []) - listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation); - for (const el of elements.modelModeButtons || []) - listen(el, 'click', 'setModelsMode', (el) => - el.dataset.mode === 'all' ? 'all' : 'proximity', - ); + for (const [buttons, valueOf, action] of [ + [ + elements.allocationButtons || [], + (el) => el.dataset.allocation, + 'setAllocation', + ], + [ + elements.modelModeButtons || [], + (el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'), + 'setModelsMode', + ], + ]) { + const control = createSegmentedControl({ + buttons, + valueOf, + onChange: (value) => actions[action](value), + }); + removers.push(() => control.destroy()); + } for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade'); return { destroy() { diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index 857271c8b..0706ad17d 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -12,6 +12,7 @@ import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js'; import { createRailCardBlocks } from './railCardBlocks.js'; import { createRailCards } from './railCards.js'; import { createImagerySplit } from './imagerySplit.js'; +import { createSegmentedControl } from './segmentedControl.js'; import { PRODUCTS, hhmm, @@ -286,13 +287,13 @@ export function createRecentImageryPanel({ const selection = el('section', 'ri-block ri-selection', root); selection.id = 'ri-selection'; selection.setAttribute('aria-label', t('On the map')); - const modeGroup = el('div', 'ri-mode', selection); + const modeGroup = el('div', 'gev-segmented ri-mode', selection); modeGroup.id = 'ri-mode'; modeGroup.setAttribute('role', 'radiogroup'); modeGroup.setAttribute('aria-label', t('Mode')); const modeButtons = new Map( MODE_LABELS.map(([mode, label]) => { - const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup); + const node = el('button', 'gev-segment ri-mode-btn', modeGroup); node.type = 'button'; node.textContent = t(label); node.dataset.mode = mode; @@ -300,6 +301,15 @@ export function createRecentImageryPanel({ return [mode, node]; }), ); + const modeControl = createSegmentedControl({ + buttons: modeButtons.values(), + valueOf: (button) => button.dataset.mode, + onChange: (mode) => layer.setMode(mode), + keyboard: 'arrows', + keyTarget: modeGroup, + readValue: () => snapshot.mode, + listen, + }); const slotRows = {}; for (const slotId of ['a', 'b']) { const row = el('div', 'ri-slot', selection); @@ -668,12 +678,7 @@ export function createRecentImageryPanel({ function renderSelection() { const { mode } = snapshot; set(selection.dataset, 'mode', mode); - for (const [id, node] of modeButtons) { - const on = id === mode; - attribute(node, 'aria-checked', String(on)); - toggle(node, 'active', on); - set(node, 'tabIndex', on ? 0 : -1); - } + modeControl.update(mode, { rovingFocus: true }); const ab = mode === 'ab'; for (const slotId of ['a', 'b']) { const { row, tag, value, unpin } = slotRows[slotId]; @@ -1065,11 +1070,6 @@ export function createRecentImageryPanel({ strip.focus({ preventScroll: true }); return; } - const mode = target?.closest?.('.ri-mode-btn'); - if (mode) { - layer.setMode(mode.dataset.mode); - return; - } if (target?.closest?.('.ri-swap') && !swap.disabled) { layer.swapSides?.(); return; @@ -1086,21 +1086,6 @@ export function createRecentImageryPanel({ if (event.target === opacity) layer.setAlpha(Number(opacity.value) / 100); }); - listen(modeGroup, 'keydown', (event) => { - const order = MODE_LABELS.map(([mode]) => mode); - const step = - { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key] || - 0; - if (!step) return; - event.preventDefault(); - const next = - order[ - (order.indexOf(snapshot.mode) + step + order.length) % order.length - ]; - layer.setMode(next); - modeButtons.get(next)?.focus?.(); - }); - listen(strip, 'keydown', (event) => { const total = snapshot.candidates.length; const index = snapshot.focusIndex || 0; diff --git a/src/ui/segmentedControl.js b/src/ui/segmentedControl.js new file mode 100644 index 000000000..0ad785986 --- /dev/null +++ b/src/ui/segmentedControl.js @@ -0,0 +1,109 @@ +const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }; + +/** Pure value navigation, also usable by a future template-based view. */ +export function nextSegmentValue(values, value, key, { homeEnd = true } = {}) { + if (!values.length) return undefined; + if (homeEnd && key === 'Home') return values[0]; + if (homeEnd && key === 'End') return values.at(-1); + const step = STEPS[key]; + if (!step) return undefined; + return values[(values.indexOf(value) + step + values.length) % values.length]; +} + +/** Paint controlled selection without emitting changes or moving focus. */ +export function syncSegmentedControl( + buttons, + value, + { valueOf, activeClass = 'active', rovingFocus = false } = {}, +) { + for (const button of buttons || []) { + if (!button) continue; + const selected = valueOf(button) === value; + if (button.classList.contains(activeClass) !== selected) + button.classList.toggle(activeClass, selected); + const set = (name, content) => { + if (button.getAttribute?.(name) !== content) + button.setAttribute(name, content); + }; + set('aria-checked', String(selected)); + if (rovingFocus) set('tabindex', selected ? '0' : '-1'); + } +} + +/** + * Controlled segmented-selector DOM adapter. Feature owners supply values and + * receive changes; rendering a restored value never triggers the callback. + * `keyboard` retains each existing group's policy: none, arrows, or radio + * (arrows plus Home/End). Pointer and native Enter/Space share the click path. + * An injected listener owner keeps the feature's existing teardown boundary. + * @param {object} options + * @param {Iterable} options.buttons + * @param {Function} options.valueOf Maps a button to the feature's value. + * @param {Function} options.onChange Receives value and originating button. + * @param {'none'|'arrows'|'radio'} [options.keyboard] + * @param {HTMLElement} [options.keyTarget] Delegated keyboard target, if any. + * @param {Function} [options.readValue] Current controlled value for delegation. + * @param {Function} [options.listen] Feature-owned event registration. + * @returns {{ update: Function, destroy: Function }} + */ +export function createSegmentedControl({ + buttons, + valueOf, + onChange, + keyboard = 'none', + keyTarget, + readValue, + listen, +}) { + const options = [...buttons].filter(Boolean); + const removers = []; + let destroyed = false; + const bind = (target, type, handler) => { + const owned = (event) => { + if (!destroyed) handler(event); + }; + if (listen) listen(target, type, owned); + else { + target.addEventListener(type, owned); + removers.push(() => target.removeEventListener(type, owned)); + } + }; + for (const button of options) + bind(button, 'click', () => { + if (!button.disabled) onChange(valueOf(button), button); + }); + const navigate = (event, from) => { + const enabled = options.filter((button) => !button.disabled); + const values = enabled.map(valueOf); + const next = nextSegmentValue(values, from, event.key, { + homeEnd: keyboard === 'radio', + }); + if (next === undefined) return; + const button = enabled.find((option) => valueOf(option) === next); + event.preventDefault(); + if (keyTarget) { + button.click(); + button.focus(); + } else { + button.focus(); + if (next !== from) button.click(); + } + }; + if (keyboard !== 'none') { + if (keyTarget) + bind(keyTarget, 'keydown', (event) => navigate(event, readValue())); + else + for (const button of options) + bind(button, 'keydown', (event) => navigate(event, valueOf(button))); + } + return { + update(value, presentation) { + if (!destroyed) + syncSegmentedControl(options, value, { valueOf, ...presentation }); + }, + destroy() { + destroyed = true; + for (const remove of removers.splice(0)) remove(); + }, + }; +} diff --git a/src/ui/segmentedControl.test.mjs b/src/ui/segmentedControl.test.mjs new file mode 100644 index 000000000..ad194ddd8 --- /dev/null +++ b/src/ui/segmentedControl.test.mjs @@ -0,0 +1,174 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { railFixture } from './railTestFixture.mjs'; +import { + createSegmentedControl, + nextSegmentValue, +} from './segmentedControl.js'; + +function fixture(options = {}) { + let writes = 0; + const f = railFixture(() => writes++); + const buttons = ['a', 'b', 'c'].map((value) => { + const button = f.document.createElement('button'); + button.dataset.value = value; + f.container.appendChild(button); + return button; + }); + const calls = []; + const control = createSegmentedControl({ + buttons, + valueOf: (button) => button.dataset.value, + onChange: (value) => calls.push(value), + ...options, + }); + const key = (target, value) => { + const event = new Event('keydown', { cancelable: true }); + Object.defineProperty(event, 'key', { value }); + target.dispatchEvent(event); + return event.defaultPrevented; + }; + return { ...f, buttons, calls, control, key, writes: () => writes }; +} + +test('controlled restoration updates selection without emitting or stealing focus', () => { + const f = fixture(); + f.buttons[0].focus(); + f.control.update('b', { rovingFocus: true }); + assert.deepEqual(f.calls, []); + assert.equal(f.document.activeElement, f.buttons[0]); + assert.deepEqual( + f.buttons.map((b) => b.getAttribute('aria-checked')), + ['false', 'true', 'false'], + ); + assert.deepEqual( + f.buttons.map((b) => b.getAttribute('tabindex')), + ['-1', '0', '-1'], + ); + const before = f.writes(); + f.control.update('b', { rovingFocus: true }); + assert.equal( + f.writes(), + before, + 'unchanged renders must not retrigger rail observers', + ); + f.control.destroy(); +}); + +test('clicks emit current values once, including reselection; disabled controls do not emit', () => { + const f = fixture(); + f.control.update('a'); + f.buttons[0].click(); + f.buttons[0].click(); + f.buttons[1].dataset.value = 'renamed'; + f.buttons[1].click(); + f.buttons[2].disabled = true; + f.buttons[2].dispatchEvent(new Event('click')); + assert.deepEqual(f.calls, ['a', 'a', 'renamed']); + assert.equal( + f.buttons[0].getAttribute('aria-checked'), + 'true', + 'the owner controls selection', + ); + f.control.destroy(); +}); + +test('radio navigation wraps, skips disabled choices, and retains Home/End semantics', () => { + const f = fixture({ keyboard: 'radio' }); + f.buttons[1].disabled = true; + assert.equal(f.key(f.buttons[0], 'ArrowRight'), true); + assert.equal(f.document.activeElement, f.buttons[2]); + assert.deepEqual(f.calls, ['c']); + f.key(f.buttons[2], 'ArrowDown'); + assert.equal(f.document.activeElement, f.buttons[0]); + f.key(f.buttons[0], 'Home'); + assert.deepEqual( + f.calls, + ['c', 'a'], + 'Home on the first button does not reselect it', + ); + f.key(f.buttons[0], 'End'); + assert.equal(f.document.activeElement, f.buttons[2]); + assert.deepEqual(f.calls, ['c', 'a', 'c']); + f.control.destroy(); +}); + +test('legacy click-only groups leave arrows and tab order to their existing owner', () => { + const f = fixture(); + f.control.update('b'); + assert.equal(f.key(f.buttons[0], 'ArrowRight'), false); + assert.equal(f.key(f.buttons[0], 'Home'), false); + assert.deepEqual(f.calls, []); + assert.ok( + f.buttons.every((button) => button.getAttribute('tabindex') === null), + ); + f.control.destroy(); +}); + +test('delegated imagery keys navigate from the controlled value, not stale focus', () => { + const f = railFixture(); + const buttons = ['a', 'b', 'c'].map((value) => { + const button = f.document.createElement('button'); + button.dataset.value = value; + f.container.appendChild(button); + return button; + }); + let selected = 'c'; + const calls = []; + const control = createSegmentedControl({ + buttons, + valueOf: (b) => b.dataset.value, + onChange: (value) => { + calls.push(value); + selected = value; + }, + keyboard: 'arrows', + keyTarget: f.container, + readValue: () => selected, + }); + const event = new Event('keydown', { cancelable: true }); + Object.defineProperty(event, 'key', { value: 'ArrowRight' }); + f.container.dispatchEvent(event); + assert.deepEqual(calls, ['a']); + assert.equal(f.document.activeElement, buttons[0]); + const home = new Event('keydown', { cancelable: true }); + Object.defineProperty(home, 'key', { value: 'Home' }); + f.container.dispatchEvent(home); + assert.equal(home.defaultPrevented, false); + assert.deepEqual(calls, ['a']); + control.destroy(); +}); + +test('disposal revokes detached controls and replacement subscriptions fire once', () => { + const f = fixture({ keyboard: 'radio' }); + f.control.destroy(); + const next = []; + const replacement = createSegmentedControl({ + buttons: f.buttons, + valueOf: (b) => b.dataset.value, + onChange: (value) => next.push(value), + keyboard: 'radio', + }); + f.buttons[0].click(); + f.key(f.buttons[0], 'End'); + assert.deepEqual(f.calls, []); + assert.deepEqual(next, ['a', 'c']); + replacement.destroy(); + replacement.destroy(); + f.buttons[1].click(); + f.key(f.buttons[0], 'End'); + assert.deepEqual(next, ['a', 'c']); +}); + +test('pure value navigation can be reused without a DOM', () => { + assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'ArrowRight'), 'fit'); + assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'ArrowUp'), 'fill'); + assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'Home'), 'fit'); + assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'End'), 'fill'); + assert.equal( + nextSegmentValue(['fit', 'fill'], 'fit', 'Home', { homeEnd: false }), + undefined, + ); + assert.equal(nextSegmentValue([], 'fit', 'ArrowRight'), undefined); + assert.equal(nextSegmentValue(['fit'], 'fit', 'Escape'), undefined); +}); diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 44bd18488..2c6b7e94d 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -1,5 +1,9 @@ import { isExplicitLayerStateOrigin } from '../data/layerState.js'; import { syncChipGroup } from './chipGroup.js'; +import { + createSegmentedControl, + syncSegmentedControl, +} from './segmentedControl.js'; import { presentStreetLevelPanel, SINCE_STOPS, @@ -9,12 +13,6 @@ import { t, translateKnown } from '../i18n/index.js'; const RENDER_MODE_KEY = 'gev:street-level:render-mode'; /** classes of the modes that hide every panel; an expanded photo leaves with them. */ const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode']; -const RADIO_STEPS = Object.freeze({ - ArrowRight: 1, - ArrowDown: 1, - ArrowLeft: -1, - ArrowUp: -1, -}); // Renders run on every layer notification, and the rail's MutationObserver // relays out on each attribute write: write only what changed. @@ -106,13 +104,13 @@ export class StreetLevelControls { } this._toggleProvider(button.dataset.chipId); }); - for (const button of el.panoButtons) { - this.listen(button, 'click', () => - this._setParams({ pano: button.dataset.slPano }), - ); - } - this._bindRadioGroup(el.panoButtons); - this._bindRadioGroup(el.renderButtons); + createSegmentedControl({ + buttons: el.panoButtons, + valueOf: (button) => button.dataset.slPano, + onChange: (pano) => this._setParams({ pano }), + keyboard: 'radio', + listen: (target, type, handler) => this.listen(target, type, handler), + }); // The readout follows the thumb; coverage is rebuilt once, on release. const sinceDays = () => SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0; @@ -160,17 +158,20 @@ export class StreetLevelControls { this._exitExpanded(); }); } else setProp(el.viewerExpand, 'hidden', true); - for (const button of el.renderButtons) { - this.listen(button, 'click', () => { - const mode = button.dataset.slRender; + createSegmentedControl({ + buttons: el.renderButtons, + valueOf: (button) => button.dataset.slRender, + keyboard: 'radio', + listen: (target, type, handler) => this.listen(target, type, handler), + onChange: (mode) => { this.layer.setViewerRenderMode?.(mode); try { localStorage.setItem(RENDER_MODE_KEY, mode); } catch { /* storage unavailable */ } - }); - } + }, + }); try { const stored = localStorage.getItem(RENDER_MODE_KEY); if (stored === 'fill' || stored === 'letterbox') @@ -185,24 +186,6 @@ export class StreetLevelControls { } } - /** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */ - _bindRadioGroup(buttons) { - buttons.forEach((button, index) => { - this.listen(button, 'keydown', (event) => { - let next = null; - if (event.key in RADIO_STEPS) { - const count = buttons.length; - next = buttons[(index + RADIO_STEPS[event.key] + count) % count]; - } else if (event.key === 'Home') next = buttons[0]; - else if (event.key === 'End') next = buttons[buttons.length - 1]; - if (!next) return; - event.preventDefault(); - next.focus(); - if (next !== button) next.click(); - }); - }); - } - /** * Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would * ask for tiles at z=NaN; the observer fires again once it has a size. @@ -416,15 +399,6 @@ export class StreetLevelControls { setProp(el.errorText, 'textContent', view.error || ''); } - _renderRadios(buttons, isChecked) { - for (const button of buttons) { - const checked = isChecked(button); - button.classList.toggle('is-active', checked); - setAttr(button, 'aria-checked', String(checked)); - setAttr(button, 'tabindex', checked ? '0' : '-1'); - } - } - _renderFilters(view) { const el = this._elements; const shows = view.filterControls || { pano: true, since: true }; @@ -434,10 +408,11 @@ export class StreetLevelControls { setProp(el.sinceNote, 'textContent', view.sinceNote || ''); setProp(el.sinceNote, 'hidden', !view.sinceNote); setProp(el.controls, 'hidden', !shows.pano && !shows.since); - this._renderRadios( - el.panoButtons, - (button) => button.dataset.slPano === view.filter.pano, - ); + syncSegmentedControl(el.panoButtons, view.filter.pano, { + valueOf: (button) => button.dataset.slPano, + activeClass: 'is-active', + rovingFocus: true, + }); if (el.sinceRange) { // Never move the thumb under the user's hand; once the committed value // matches the thumb, show the full readout with its cut-off date. @@ -467,10 +442,11 @@ export class StreetLevelControls { setProp(wrap, 'hidden', !viewer.open); } setProp(el.viewerPlaceholder, 'hidden', !viewer.loading); - this._renderRadios( - el.renderButtons, - (button) => button.dataset.slRender === viewer.renderMode, - ); + syncSegmentedControl(el.renderButtons, viewer.renderMode, { + valueOf: (button) => button.dataset.slRender, + activeClass: 'is-active', + rovingFocus: true, + }); // Google's viewer always fills its frame: no FIT/FILL to offer. const renderGroup = el.renderButtons[0]?.parentElement; if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes); diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index f8a7906fe..49d6a1418 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -677,11 +677,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label { padding: 5px 8px 6px; } -.detection-allocation-seg .pp-mode-btn { - font-size: 8px; - letter-spacing: 0.35px; -} - #hud-layout-row { min-width: 200px; } @@ -699,7 +694,7 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label { min-width: 200px; } -.cyber-sonar-toggle-row .pp-mode-btn { +.cyber-sonar-toggle-row .pp-mode-btn:not(.gev-segment) { margin-left: auto; min-width: 48px; } @@ -851,11 +846,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label { #draw-mode-row { min-width: 184px; } -/* Equal thirds with room around the label, so a long translated shape name - never runs edge to edge in its segment. */ -html:not([lang='en']) #draw-mode-row .pp-mode-btn { - padding: 0 4px; -} #draw-label-row { flex-wrap: wrap; row-gap: 6px; @@ -984,11 +974,8 @@ body.gev-geo-import-drag #cesiumContainer { .pp-mode-seg { display: flex; flex: 1; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 6px; - overflow: hidden; } -.pp-mode-btn { +.pp-mode-btn:not(.gev-segment) { flex: 1; height: 22px; background: rgba(255, 255, 255, 0.04); @@ -1003,14 +990,11 @@ body.gev-geo-import-drag #cesiumContainer { background-color var(--transition-fast), color var(--transition-fast); } -.pp-mode-btn + .pp-mode-btn { - border-left: 1px solid rgba(255, 255, 255, 0.14); -} -.pp-mode-btn:hover { +.pp-mode-btn:not(.gev-segment):hover { background: rgba(255, 255, 255, 0.08); color: var(--text-primary); } -.pp-mode-btn.active { +.pp-mode-btn:not(.gev-segment).active { background: var(--accent); color: #000; } diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 04a1efa77..fc93abcb6 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -506,11 +506,13 @@ 0 0 12px rgba(196, 84, 84, 0.14); } +:root[data-ui-theme='cyber'] .gev-segmented, :root[data-ui-theme='cyber'] .pp-mode-seg { border-color: rgba(206, 222, 226, 0.24); border-radius: 1px; } +:root[data-ui-theme='cyber'] .gev-segment[aria-checked='true'], :root[data-ui-theme='cyber'] .pp-mode-btn.active { background: var(--cyber-red); color: #f7f8f7; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 4043751d4..ea64eefc5 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -184,8 +184,8 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail > #weather-panel, -/* Collapsed, it must stay hideable by the rail's exclusive layout. */ +/* Collapsed readouts must stay hideable by the rail's exclusive layout. */ +#right-context-rail > #weather-panel:not(.collapsed), #right-context-rail > #recent-imagery-panel:not(.collapsed) { display: flex; flex-direction: column; diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index e04955e2f..95c149a3c 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -321,7 +321,6 @@ html:not([lang='en']) .recent-imagery-readout .ri-mode { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 4px; } .recent-imagery-readout .ri-selection .data-toggle-chip { height: 20px; diff --git a/src/ui/styles/segmented-control.css b/src/ui/styles/segmented-control.css new file mode 100644 index 000000000..bcf083e71 --- /dev/null +++ b/src/ui/styles/segmented-control.css @@ -0,0 +1,51 @@ +/* Shared one-of-many control. Feature classes retain surrounding placement. */ +.gev-segmented { + display: inline-flex; + min-width: 0; + border: 1px solid var(--glass-border-hover); + border-radius: 6px; + overflow: hidden; +} +.gev-segmented[hidden] { + display: none; +} +.gev-segment { + flex: 1; + min-width: min-content; + min-height: 24px; + margin: 0; + padding: 4px 8px; + border: 0; + border-radius: 0; + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); + font: 9px/1.25 var(--font-mono); + letter-spacing: 0.6px; + text-transform: uppercase; + overflow-wrap: normal; + cursor: pointer; + transition: + background-color var(--transition-fast), + color var(--transition-fast); +} +.gev-segment + .gev-segment { + border-left: 1px solid var(--glass-border); +} +.gev-segment:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); +} +.gev-segment[aria-checked='true'] { + background: var(--accent); + color: #000; +} +.gev-segment:focus-visible { + position: relative; + outline: 2px solid var(--text-primary); + outline-offset: -3px; + box-shadow: inset 0 0 0 4px #061014; +} +.gev-segment:disabled { + opacity: 0.42; + cursor: not-allowed; +} diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index c218765d9..14b47524f 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -222,14 +222,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn { flex-wrap: wrap; } -/* No overflow clipping: it would cut off the buttons' focus ring. The end - buttons round themselves instead. */ -.sl-seg { - display: inline-flex; - border: 1px solid var(--glass-border-hover); - border-radius: 999px; -} - .sl-seg[hidden], .sl-controls[hidden], .sl-filters[hidden], @@ -237,40 +229,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn { display: none; } -.sl-seg-btn { - font-family: var(--font-mono); - font-size: var(--sl-text); - letter-spacing: 0.08em; - padding: 4px 9px; - border: 0; - background: transparent; - color: var(--text-secondary); - cursor: pointer; -} - -.sl-seg-btn:first-child { - border-radius: 999px 0 0 999px; -} - -.sl-seg-btn:last-child { - border-radius: 0 999px 999px 0; -} - -.sl-seg-btn + .sl-seg-btn { - border-left: 1px solid var(--glass-border); -} - -.sl-seg-btn:focus-visible { - position: relative; - outline: 1px solid var(--accent); - outline-offset: 2px; -} - -.sl-seg-btn.is-active { - background: rgba(5, 203, 99, 0.22); - color: var(--text-primary); -} - .sl-since { display: grid; grid-template-columns: auto 1fr; diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index 220dc373b..e067f68bd 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -63,9 +63,9 @@
Allocation -
- - +
+ +
@@ -99,9 +99,9 @@
Models -
- - +
+ +
@@ -128,10 +128,10 @@
Shape -
- - - +
+ + +
diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index b170b01e3..6f581ae91 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -87,9 +87,9 @@
-
- - +
+ +
@@ -118,10 +118,10 @@
IMAGERY -
- - - +
+ + +
diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 2aab58f10..db074dda8 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1,4 +1,5 @@ import { STYLE_STATUS_LABELS } from './visualPresets.js'; +import { syncSegmentedControl } from './segmentedControl.js'; import { t } from '../i18n/index.js'; import { UiLifetime } from './uiLifetime.js'; import { @@ -557,11 +558,9 @@ export class VisualSettings { const normalized = normalizeAllocationStrategy(raw); this._detectionAllocationPreference = normalized; setDetectionTuning({ allocationStrategy: normalized }); - for (const button of this._detectionAllocationBtns) { - const active = button.dataset.allocation === normalized; - button.classList.toggle('active', active); - button.setAttribute('aria-checked', String(active)); - } + syncSegmentedControl(this._detectionAllocationBtns, normalized, { + valueOf: (button) => button.dataset.allocation, + }); if (persist) { try { localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized); diff --git a/style.css b/style.css index 44b7e07bc..693cccb67 100644 --- a/style.css +++ b/style.css @@ -27,4 +27,5 @@ @import './src/ui/styles/weather.css'; @import './src/ui/styles/panel-surfaces.css'; +@import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/cyber.css'; From 7df8b369e4cd05db0a1104482859751ecf3a9d4e Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Wed, 21 Oct 2026 13:08:35 +0200 Subject: [PATCH 20/21] Add changelog for shared segmented selectors --- changelog.d/1301-shared-segmented-selectors.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1301-shared-segmented-selectors.md diff --git a/changelog.d/1301-shared-segmented-selectors.md b/changelog.d/1301-shared-segmented-selectors.md new file mode 100644 index 000000000..96c1ee20d --- /dev/null +++ b/changelog.d/1301-shared-segmented-selectors.md @@ -0,0 +1,4 @@ +- Unify segmented selectors for model coverage, detection allocation, drawing + shape, Street View and imagery comparison while preserving their existing + behavior. Keep compact labels such as FILL and FLAT on one line, and let + collapsed Weather release space to the open rail panel (mazy1998, #1301). From 4bfee31fdb2ecdabed61fa73e291e6ff576e4ca7 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Wed, 21 Oct 2026 13:21:23 +0200 Subject: [PATCH 21/21] Reserve full height for imagery segmented controls --- .../1301-shared-segmented-selectors.md | 3 +- docs/CURRENT-STATE.md | 2 ++ scripts/qa-segmented-controls.mjs | 36 +++++++++++++++++++ src/ui/recentImagery.test.mjs | 6 ++-- src/ui/styles/recent-imagery.css | 4 +-- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/changelog.d/1301-shared-segmented-selectors.md b/changelog.d/1301-shared-segmented-selectors.md index 96c1ee20d..379b8c716 100644 --- a/changelog.d/1301-shared-segmented-selectors.md +++ b/changelog.d/1301-shared-segmented-selectors.md @@ -1,4 +1,5 @@ - Unify segmented selectors for model coverage, detection allocation, drawing shape, Street View and imagery comparison while preserving their existing behavior. Keep compact labels such as FILL and FLAT on one line, and let - collapsed Weather release space to the open rail panel (mazy1998, #1301). + collapsed Weather release space to the open rail panel. Reserve the full mode + button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 419e3efb6..224c9a02a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1237,6 +1237,8 @@ Pure value navigation is separate from DOM rendering for reuse by a future view. Segments keep their word minimum width: compact inline groups such as FIT/FILL and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows the same exclusive-rail hiding rule as the other readouts. +The imagery mode row reserves 26px for the 24px buttons and group borders so +the complete button and inset keyboard-focus outline remain visible. Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`. ## Application shortcuts and shader parameter controls diff --git a/scripts/qa-segmented-controls.mjs b/scripts/qa-segmented-controls.mjs index cfa8e51d6..8612c2f23 100644 --- a/scripts/qa-segmented-controls.mjs +++ b/scripts/qa-segmented-controls.mjs @@ -274,6 +274,42 @@ try { cyber.consistent, JSON.stringify(cyber), ); + await page.evaluate(async () => { + const { dataManager, styleManager } = window.__godsEyeView; + await dataManager.setEnabled('recent-imagery', true); + styleManager.setPanelCollapsed('recent-imagery-panel', false, { + persist: false, + syncShare: false, + }); + }); + for (const width of [1440, 620, 390]) { + await page.setViewport({ width, height: 900 }); + await page.focus('.ri-mode-btn[aria-checked="true"]'); + const modeLayout = await page.evaluate(() => { + const group = document.getElementById('ri-mode'); + const bounds = group.getBoundingClientRect(); + const style = getComputedStyle(group); + const top = bounds.top + parseFloat(style.borderTopWidth); + const bottom = bounds.bottom - parseFloat(style.borderBottomWidth); + const buttons = [...group.querySelectorAll('[role="radio"]')]; + return { + visible: bounds.width > 0 && bounds.height > 0, + contained: buttons.every((button) => { + const rect = button.getBoundingClientRect(); + return rect.top >= top - 0.5 && rect.bottom <= bottom + 0.5; + }), + groupHeight: bounds.height, + buttonHeights: buttons.map( + (button) => button.getBoundingClientRect().height, + ), + }; + }); + check( + `imagery mode buttons and focus outline fit their row at ${width}px`, + modeLayout.visible && modeLayout.contained, + JSON.stringify(modeLayout), + ); + } check('no uncaught browser errors', errors.length === 0, errors.join(' | ')); } catch (error) { console.error('Browser errors:', errors); diff --git a/src/ui/recentImagery.test.mjs b/src/ui/recentImagery.test.mjs index a77556797..fe9496688 100644 --- a/src/ui/recentImagery.test.mjs +++ b/src/ui/recentImagery.test.mjs @@ -412,7 +412,7 @@ test('the body is a fixed stack of seven blocks in a fixed order, each with a fi assert.match(rule('.recent-imagery-readout .ri-card'), /height: 112px/); assert.match( rule('.recent-imagery-readout .ri-selection'), - /grid-template-rows: 20px 20px 20px/, + /grid-template-rows: 26px 20px 20px/, ); assert.match(rule('.recent-imagery-readout .ri-slot'), /height: 20px/); assert.match(rule('.recent-imagery-readout .ri-controls'), /height: 22px/); @@ -445,7 +445,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h /** * Every block that must keep its height: the node carrying the class, that * class, and the rule it gets its height from. The mode row is the first - * 20 px track of the selection grid. The details header has no height of + * 26 px track of the selection grid. The details header has no height of * its own: it is the last block, so it moves nothing below it, and its * title is held to one line. */ @@ -491,7 +491,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h mode.parentNode.children[0] === mode ? mode.parentNode : null, 'ri-selection', readout('.ri-selection'), - /grid-template-rows: 20px 20px 20px/, + /grid-template-rows: 26px 20px 20px/, ], 'ri-slot-a': [ f.byId('ri-slot-a'), diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index 95c149a3c..66b351d26 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -309,10 +309,10 @@ html:not([lang='en']) display: none; } -/* 4. The selection bar: mode, then two rows that are always there. */ +/* 4. The selection bar: 24px mode buttons plus borders, then two stable rows. */ .recent-imagery-readout .ri-selection { display: grid; - grid-template-rows: 20px 20px 20px; + grid-template-rows: 26px 20px 20px; row-gap: 4px; padding: 5px; border: 1px solid var(--weather-card-border);