Files
gods-eye-view/src/layers/recentImagery/thumbnails.js
T
Rudy CelekliandClaude Sonnet 5.5 3962ac52f7 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 <[email protected]>
2026-10-14 20:13:02 -04:00

278 lines
8.1 KiB
JavaScript

/**
* Thumbnail loader for the Recent Imagery strip: one Worldview snapshot per
* candidate day. The response's `Data-Present` header is the only proof that
* a day has pixels in the box (GIBS answers an empty tile with HTTP 200), and
* `Acquisition-Time` carries the sensing time. Fetches are bounded, and so
* are decoded images: residency is least-recently-USED, where a request or a
* completed decode is a use and a `get` read never is (the panel reads every
* card on every render, so a read that counted would evict the visible cards
* first). Eviction revokes the image only; what the probe proved is kept.
*/
import { thumbnailOrder } from './model.js';
import { createRecentImagerySource } from './source.js';
const EMPTY = Object.freeze({
status: 'unknown',
objectUrl: null,
acquisitionTime: null,
loading: false,
});
/**
* @param {{ fetchImpl?: typeof fetch, maxInFlight?: number, maxDecoded?: number, createObjectUrl?: (blob: Blob) => string, revokeObjectUrl?: (url: string) => void, size?: number }} [options]
*/
export function createThumbnailLoader({
fetchImpl = globalThis.fetch?.bind(globalThis),
requestThumbnail = createRecentImagerySource({ fetchImpl }).getThumbnail,
maxInFlight = 3,
maxDecoded = 16,
createObjectUrl = (blob) => URL.createObjectURL(blob),
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
size = 256,
} = {}) {
const limit = Math.max(1, Math.trunc(maxInFlight) || 1);
const resident = Math.max(1, Math.trunc(maxDecoded) || 1);
const entries = new Map();
const listeners = new Set();
const queue = [];
let inFlight = 0;
let touchCounter = 0;
let destroyed = false;
function notify(key) {
for (const listener of [...listeners]) {
try {
listener(key);
} catch (error) {
console.warn('[Data:RecentImagery] thumbnail listener failed:', error);
}
}
}
function touch(entry) {
touchCounter += 1;
entry.touched = touchCounter;
}
function revokeImage(entry) {
if (!entry.objectUrl) return;
try {
revokeObjectUrl(entry.objectUrl);
} catch {
/* a revoked URL is already gone */
}
entry.objectUrl = null;
}
function evictDecoded(keep) {
const decoded = [...entries.values()]
.filter((entry) => entry.objectUrl && entry !== keep)
.sort((a, b) => a.touched - b.touched);
let count = decoded.length + (keep?.objectUrl ? 1 : 0);
for (const entry of decoded) {
if (count <= resident) break;
revokeImage(entry);
notify(entry.key);
count -= 1;
}
}
function enqueue(entry) {
if (!queue.includes(entry)) queue.push(entry);
pump();
}
async function run(entry) {
const controller = new AbortController();
entry.controller = controller;
entry.loading = true;
inFlight += 1;
let status = 'error';
let objectUrl = null;
let acquisitionTime = null;
let cancelled = false;
try {
const response = await requestThumbnail(entry.candidate, entry.box, {
size,
signal: controller.signal,
});
if (controller.signal.aborted) {
cancelled = true;
} else if (response?.ok) {
const present = String(
response.headers?.get?.('Data-Present') ?? '',
).toLowerCase();
acquisitionTime = response.headers?.get?.('Acquisition-Time') || null;
if (present === 'false') {
status = 'empty';
} else if (present === 'true') {
const blob = await response.blob();
if (controller.signal.aborted) cancelled = true;
else {
objectUrl = createObjectUrl(blob);
status = 'present';
}
}
}
} catch (error) {
if (controller.signal.aborted || error?.name === 'AbortError')
cancelled = true;
else status = 'error';
} finally {
inFlight -= 1;
entry.loading = false;
entry.controller = null;
}
// Every settlement frees a slot, so a cancelled fetch pumps too.
if (cancelled || destroyed || entries.get(entry.key) !== entry) {
if (objectUrl) revokeObjectUrl(objectUrl);
pump();
return;
}
// A failed re-fetch of an evicted image keeps what the day proved.
if (status !== 'error' || entry.status !== 'present') {
entry.status = status;
entry.acquisitionTime = acquisitionTime;
}
entry.objectUrl = objectUrl;
touch(entry);
if (objectUrl) evictDecoded(entry);
notify(entry.key);
pump();
}
function pump() {
if (destroyed) return;
queue.sort((a, b) => a.priority - b.priority);
while (inFlight < limit && queue.length) {
const entry = queue.shift();
if (entries.get(entry.key) !== entry) continue;
void run(entry);
}
}
return {
/**
* Ask for one candidate's thumbnail. Lower priority loads sooner. A day
* already known is only touched (and re-prioritised while it waits); a
* known-present day whose image was evicted is fetched again.
* @param {object} candidate
* @param {object} box Degrees box.
* @param {number} [priority]
*/
request(candidate, box, priority = 0) {
if (destroyed || !candidate?.key || !box) return;
const key = candidate.key;
const existing = entries.get(key);
if (existing) {
touch(existing);
if (existing.loading) return;
if (existing.status === 'unknown') {
existing.priority = priority;
} else if (existing.status === 'present' && !existing.objectUrl) {
existing.priority = priority;
enqueue(existing);
}
return;
}
const entry = {
key,
candidate,
box,
priority,
status: 'unknown',
objectUrl: null,
acquisitionTime: null,
loading: false,
controller: null,
touched: 0,
};
touch(entry);
entries.set(key, entry);
enqueue(entry);
},
/**
* Request the visible window of a strip in `thumbnailOrder` priority.
* @param {Array<object>} candidates
* @param {object} box
* @param {{ focusIndex: number, firstVisible: number, lastVisible: number, extra?: number }} window
*/
requestOrdered(
candidates,
box,
{ focusIndex, firstVisible, lastVisible, extra },
) {
const order = thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
candidates.length,
extra,
);
order.forEach((index, priority) => {
this.request(candidates[index], box, priority);
});
},
/**
* Abort every fetch in flight and forget every unsettled request. A
* known day whose image was being fetched again keeps what it knows.
*/
cancelAll() {
queue.length = 0;
for (const entry of [...entries.values()]) {
if (entry.loading) entry.controller?.abort();
if (entry.status === 'unknown') entries.delete(entry.key);
}
},
/** Cancel everything and revoke every decoded thumbnail. */
clear() {
this.cancelAll();
for (const entry of entries.values()) revokeImage(entry);
entries.clear();
},
/**
* Read a day's state. A read is not a use: it never changes residency.
* @param {string} key Candidate key.
* @returns {{ status: 'unknown'|'present'|'empty'|'error', objectUrl: string | null, acquisitionTime: string | null, loading: boolean }}
*/
get(key) {
const entry = entries.get(key);
if (!entry) return EMPTY;
return {
status: entry.status,
objectUrl: entry.objectUrl,
acquisitionTime: entry.acquisitionTime,
loading: entry.loading,
};
},
/** @param {(key: string) => void} listener */
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
listeners.add(listener);
return () => listeners.delete(listener);
},
stats() {
return {
inFlight,
queued: queue.length,
decoded: [...entries.values()].filter((entry) => entry.objectUrl)
.length,
tracked: entries.size,
};
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
listeners.clear();
},
};
}