diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3cb1585..cd6ff006b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ - Director timeline seeks cross the dateline along the short longitude arc. -- Surface Tab cycling also skips negative-tabindex, fieldset-disabled and inert controls. +- 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 919e5bad2..cf3f7648f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4542,10 +4542,7 @@ are omitted rather than framing the wrong part of the globe. ### Recent Imagery (September 2026) -Programmatic boxes passed to the exported `setBox` and `searchHls` operations -reject missing, blank or boolean edges instead of interpreting them as zero -coordinates. Valid finite numeric edges remain accepted; the app’s drag tool, -pins and share links already supply complete boxes. +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. 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 {