From 3962ac52f7195bf50314682742a6e64a33c1bde0 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:02 -0400 Subject: [PATCH 1/3] fix(imagery): require explicit Data-Present proof before decoding thumbnails Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 2 ++ src/layers/recentImagery/source.js | 6 +++- .../thumbnail-presence-proof.test.mjs | 30 +++++++++++++++++++ src/layers/recentImagery/thumbnails.js | 4 +-- 5 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 src/layers/recentImagery/thumbnail-presence-proof.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..6f5ee5c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Recent Imagery thumbnails require explicit presence evidence before reporting available pixels. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..93e5214a5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4449,6 +4449,8 @@ are omitted rather than framing the wrong part of the globe. ### Recent Imagery (September 2026) +Thumbnail decoding requires explicit `Data-Present: true` evidence; false remains empty, while absent or malformed evidence reports an error. Injected `getThumbnail` sources must return an OK Response with readable `Data-Present: true` to admit image bytes, or `false` for confirmed empty coverage. Cross-origin sources must expose that header to browser JavaScript. + - The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. - **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. diff --git a/src/layers/recentImagery/source.js b/src/layers/recentImagery/source.js index 258e8e2e1..9e6749cb7 100644 --- a/src/layers/recentImagery/source.js +++ b/src/layers/recentImagery/source.js @@ -1,7 +1,11 @@ import { searchHls } from './catalog.js'; import { gibsTemplate, wvsSnapshotUrl } from './model.js'; -/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */ +/** + * NASA-compatible imagery acquisition; transport and URL builders are replaceable. + * Thumbnail responses must expose Data-Present: true for image admission, or + * false for confirmed empty coverage, including injected getThumbnail sources. + */ export function createRecentImagerySource({ fetchImpl = (...args) => globalThis.fetch(...args), search = searchHls, diff --git a/src/layers/recentImagery/thumbnail-presence-proof.test.mjs b/src/layers/recentImagery/thumbnail-presence-proof.test.mjs new file mode 100644 index 000000000..4605079b1 --- /dev/null +++ b/src/layers/recentImagery/thumbnail-presence-proof.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createThumbnailLoader } from './thumbnails.js'; +test('missing or malformed presence evidence cannot prove thumbnail pixels', async () => { + for (const header of [null, 'unknown', '', 'true', 'false']) { + let decodes = 0; + const loader = createThumbnailLoader({ + requestThumbnail: async () => ({ + ok: true, + headers: { get: (key) => (key === 'Data-Present' ? header : null) }, + blob: async () => { + decodes++; + return new Blob(['fixture']); + }, + }), + createObjectUrl: () => 'blob:fixture', + }); + loader.request( + { key: 'fixture', product: 'S30', day: '2026-09-18' }, + { west: 0, east: 1, south: 0, north: 1 }, + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + loader.get('fixture').status, + header === 'true' ? 'present' : header === 'false' ? 'empty' : 'error', + ); + assert.equal(decodes, header === 'true' ? 1 : 0); + loader.destroy(); + } +}); diff --git a/src/layers/recentImagery/thumbnails.js b/src/layers/recentImagery/thumbnails.js index 23fe4dd49..4aaec294b 100644 --- a/src/layers/recentImagery/thumbnails.js +++ b/src/layers/recentImagery/thumbnails.js @@ -100,12 +100,12 @@ export function createThumbnailLoader({ cancelled = true; } else if (response?.ok) { const present = String( - response.headers?.get?.('Data-Present') ?? 'true', + response.headers?.get?.('Data-Present') ?? '', ).toLowerCase(); acquisitionTime = response.headers?.get?.('Acquisition-Time') || null; if (present === 'false') { status = 'empty'; - } else { + } else if (present === 'true') { const blob = await response.blob(); if (controller.signal.aborted) cancelled = true; else { From 0555d99ea467454ec567767a7fee5c56e02c5459 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:14:54 -0400 Subject: [PATCH 2/3] fix(cctv): leave conflicting direction fields unresolved Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 5 +++ src/data/ambiguous-camera-direction.test.mjs | 30 +++++++++++++ src/data/directionText.js | 44 +++++++++++++------- 4 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 src/data/ambiguous-camera-direction.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..0ae01540e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Conflicting CCTV direction tokens remain unknown within the highest matching tier, preserving travel-token precedence over street quadrants and bare words. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..29bdb33c0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1172,6 +1172,11 @@ a browser key. Preview is for local build verification, not a production server. ## CCTV and radio provider modules +CCTV direction text resolves travel tokens first, then intercardinals, then +bare cardinals when enabled. Only conflicting headings within the highest +matching tier remain unknown; street quadrants and lower-tier words do not +cancel an explicit travel heading. + CCTV catalog acquisition, source normalization and frame/media delivery now live in separate modules. Each CCTV factory owns its catalog and health state; its `sourceRoot` option resolves relative source files against the application root. diff --git a/src/data/ambiguous-camera-direction.test.mjs b/src/data/ambiguous-camera-direction.test.mjs new file mode 100644 index 000000000..b5996d7a7 --- /dev/null +++ b/src/data/ambiguous-camera-direction.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { directionToHeading } from './directionText.js'; +test('conflicting facing directions cannot manufacture one heading', () => { + for (const bare of [false, true]) { + for (const value of ['NB / SB', 'EASTBOUND WESTBOUND', 'NE SW']) + assert.ok(Number.isNaN(directionToHeading(value, bare)), value); + assert.equal(directionToHeading('NB northbound', bare), 0); + assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH', bare), 180); + } + assert.ok(Number.isNaN(directionToHeading('North / East', true))); + assert.equal(directionToHeading('West / SR-24', true), 270); +}); + +test('travel tokens retain precedence over quadrant streets and bare words', () => { + for (const [text, heading] of [ + ['I-5 NB at NE 45th St', 0], + ['SR 520 EB at NE 148th', 90], + ['US 13 SB @ SW Front St', 180], + ]) { + for (const bare of [false, true]) + assert.equal(directionToHeading(text, bare), heading); + } + assert.equal( + directionToHeading('Hwy 401 east of Keele St - Westbound', true), + 270, + ); + assert.equal(directionToHeading('NE near West St', true), 45); + assert.ok(Number.isNaN(directionToHeading('NB SB near NE St', true))); +}); diff --git a/src/data/directionText.js b/src/data/directionText.js index 78f1be0df..eb78065d2 100644 --- a/src/data/directionText.js +++ b/src/data/directionText.js @@ -28,22 +28,34 @@ export function directionToHeading(value, allowBare = false) { .trim() .toUpperCase(); if (!text) return NaN; - // Explicit travel/intercardinal forms — safe on free-form text (a street - // name almost never contains "NORTHBOUND" or a lone "NB" token). - if (/\bNORTHBOUND\b|\bNB\b/.test(text)) return 0; - if (/\bSOUTHBOUND\b|\bSB\b/.test(text)) return 180; - if (/\bEASTBOUND\b|\bEB\b/.test(text)) return 90; - if (/\bWESTBOUND\b|\bWB\b/.test(text)) return 270; - if (/\bNORTHEAST\b|\bNE\b/.test(text)) return 45; - if (/\bNORTHWEST\b|\bNW\b/.test(text)) return 315; - if (/\bSOUTHEAST\b|\bSE\b/.test(text)) return 135; - if (/\bSOUTHWEST\b|\bSW\b/.test(text)) return 225; - // Bare cardinals — dedicated direction fields only. - if (allowBare) { - if (/\bNORTH\b/.test(text)) return 0; - if (/\bSOUTH\b/.test(text)) return 180; - if (/\bEAST\b/.test(text)) return 90; - if (/\bWEST\b/.test(text)) return 270; + const tiers = [ + [ + [/\bNORTHBOUND\b|\bNB\b/, 0], + [/\bSOUTHBOUND\b|\bSB\b/, 180], + [/\bEASTBOUND\b|\bEB\b/, 90], + [/\bWESTBOUND\b|\bWB\b/, 270], + ], + [ + [/\bNORTHEAST\b|\bNE\b/, 45], + [/\bNORTHWEST\b|\bNW\b/, 315], + [/\bSOUTHEAST\b|\bSE\b/, 135], + [/\bSOUTHWEST\b|\bSW\b/, 225], + ], + ]; + if (allowBare) + tiers.push([ + [/\bNORTH\b/, 0], + [/\bSOUTH\b/, 180], + [/\bEAST\b/, 90], + [/\bWEST\b/, 270], + ]); + for (const forms of tiers) { + const headings = new Set(); + for (const [pattern, heading] of forms) { + if (pattern.test(text)) headings.add(heading); + } + if (headings.size) + return headings.size === 1 ? headings.values().next().value : NaN; } return NaN; } From 12ea0219d1cf73be07b07fd9470f07bbb138758c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:15:28 -0400 Subject: [PATCH 3/3] chore(ui): harden surface Tab cycling Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 3 ++ src/ui/surface-tab-order.test.mjs | 57 +++++++++++++++++++++++++++++++ src/ui/surfaceKeyboard.js | 6 +++- 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/ui/surface-tab-order.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..cbd99b26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Surface Tab cycling also skips negative-tabindex, fieldset-disabled and inert controls. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..3d9c59110 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1060,6 +1060,9 @@ collapse can still be undone. ## Surface keyboard lifecycle +Surface Tab cycling excludes controls with a negative tabindex, native disabled +state or inert ancestry; ordinary visible enabled controls retain their order. + `ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return focus shared by the first-run launcher and Provider Settings. Each caller activates it while open and deactivates it on dismissal; destruction releases diff --git a/src/ui/surface-tab-order.test.mjs b/src/ui/surface-tab-order.test.mjs new file mode 100644 index 000000000..c3f335407 --- /dev/null +++ b/src/ui/surface-tab-order.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSurfaceKeyboard } from './surfaceKeyboard.js'; +test('surface cycling never targets deliberately excluded controls', () => { + let handler; + const documentRef = { + activeElement: {}, + addEventListener: (_, fn) => (handler = fn), + removeEventListener() {}, + }; + const node = ({ + attributes = {}, + fieldsetDisabled = false, + inert = false, + } = {}) => ({ + hasAttribute: (name) => Object.hasOwn(attributes, name), + getClientRects: () => [{}], + getAttribute: (name) => attributes[name] ?? null, + matches: (selector) => selector === ':disabled' && fieldsetDisabled, + closest: (selector) => + selector === '[inert]' && inert ? { inert: true } : null, + focus() { + documentRef.activeElement = this; + }, + }); + const ignored = [ + node({ attributes: { tabindex: '-1' } }), + node({ fieldsetDisabled: true }), + node({ inert: true }), + ]; + const first = node({}), + last = node({}); + const controls = [...ignored, first, last]; + const root = { + ownerDocument: documentRef, + querySelectorAll: () => controls, + contains: (n) => controls.includes(n), + }; + const surface = createSurfaceKeyboard({ + root, + isActive: () => true, + onEscape() {}, + }); + surface.activate(); + const press = (shiftKey) => + handler({ + key: 'Tab', + shiftKey, + preventDefault() {}, + stopPropagation() {}, + }); + press(false); + assert.equal(documentRef.activeElement, first); + press(true); + assert.equal(documentRef.activeElement, last); + surface.destroy(); +}); diff --git a/src/ui/surfaceKeyboard.js b/src/ui/surfaceKeyboard.js index 38417975a..141e22fa6 100644 --- a/src/ui/surfaceKeyboard.js +++ b/src/ui/surfaceKeyboard.js @@ -39,7 +39,11 @@ export function createSurfaceKeyboard({ ), ].filter( (node) => - !node.hasAttribute('disabled') && node.getClientRects().length > 0, + !node.hasAttribute('disabled') && + !node.matches?.(':disabled') && + !(Number(node.getAttribute?.('tabindex')) < 0) && + !node.closest?.('[inert]') && + node.getClientRects().length > 0, ); if (!order.length) return; const first = order[0];