From d58ba23fba2e432ae8845061bec7d4c8fef5d4eb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:48:14 -0800 Subject: [PATCH] Refresh live Data Library datasets once older than cacheSeconds A dataset layer loaded its features once and kept them until it was removed: the parsed set survives disable/enable, so NWS alerts, GDACS and EONET showed what they held when first switched on for the whole session. A library dataset now asks the manager for a refresh every cacheSeconds. Once its data is that old it loads again, and swaps the new set in only when it is ready, so the old features stay drawn while the request runs and stay if it fails. An enable after cacheSeconds also loads afresh. Disable and destroy cancel a refresh in flight. --- src/data/localGeojsonCore.js | 9 ++ src/library/datasetLayer.test.mjs | 154 ++++++++++++++++++++++++++++++ src/library/layers.js | 66 ++++++++++++- 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/library/datasetLayer.test.mjs diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index e079b5e8a..ddc52a9a3 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1102,6 +1102,15 @@ export function createLocalGeoJsonLayer( disable: disableLayer, + /** + * Forget the parsed features so the next enable() loads them again: a + * caller-owned live loader (the Data Library) uses it once its data is + * older than the source allows. + */ + dropCachedFeatures: () => { + _cachedFeatures = null; + }, + destroy: (viewer) => { if (_destroyed) return; _destroyed = true; diff --git a/src/library/datasetLayer.test.mjs b/src/library/datasetLayer.test.mjs new file mode 100644 index 000000000..8d143c2d9 --- /dev/null +++ b/src/library/datasetLayer.test.mjs @@ -0,0 +1,154 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; +import { createLibraryDatasetLayer } from './layers.js'; + +const entry = { + id: 'test-alerts', + kind: 'dataset', + title: 'Test alerts', + icon: '!', + provider: 'Test', + display: { color: '#ff0000' }, + source: { cacheSeconds: 60 }, + attribution: { text: 'Test' }, + licence: { name: 'CC0', url: 'https://example.org/licence' }, +}; + +const point = (name) => ({ + type: 'Feature', + properties: { name }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [0.01, 0], + [0, 0.01], + [0, 0], + ], + ], + }, +}); + +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +}; + +function harness(t, loads) { + const previousWindow = globalThis.window; + globalThis.window = { dispatchEvent() {} }; + const sources = new Set(); + const event = { addEventListener: () => () => {} }; + const viewer = { + dataSources: { + async add(source) { + sources.add(source); + return source; + }, + remove(source) { + return sources.delete(source); + }, + }, + scene: { canvas: {}, preRender: event, requestRender() {} }, + camera: { moveEnd: event }, + }; + let calls = 0; + const source = { + async loadDataset(_entry, { signal }) { + const answer = loads[calls++]; + return typeof answer === 'function' ? answer(signal) : answer; + }, + }; + const layer = createLibraryDatasetLayer(entry, { + services: { + ...localGeoJsonServices, + overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, + }, + source, + screenSpaceEventHandlerFactory: () => ({ + setInputAction() {}, + destroy() {}, + }), + }); + t.after(() => { + layer.destroy(viewer); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + }); + return { layer, viewer, sources, calls: () => calls }; +} + +const result = (...names) => ({ + features: names.map(point), + truncated: false, +}); + +test('a library dataset refreshes on the manager tick once older than cacheSeconds', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 0 }); + const env = harness(t, [result('a'), result('b', 'c')]); + assert.equal(env.layer.refreshInterval, 60_000); + await env.layer.enable(env.viewer); + assert.equal(env.layer.getStats().count, 1); + await env.layer.update(env.viewer); + assert.equal(env.calls(), 1, 'fresh data is not refetched'); + t.mock.timers.tick(60_000); + await env.layer.update(env.viewer); + assert.equal(env.calls(), 2); + assert.equal(env.layer.getStats().count, 2); + assert.equal(env.sources.size, 1, 'the old source is replaced, not kept'); +}); + +test('a stale library dataset reloads on the next enable', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 0 }); + const env = harness(t, [result('a'), result('b', 'c')]); + await env.layer.enable(env.viewer); + env.layer.disable(env.viewer); + await env.layer.enable(env.viewer); + assert.equal(env.calls(), 1, 'a re-enable within cacheSeconds reuses the data'); + env.layer.disable(env.viewer); + t.mock.timers.tick(60_000); + await env.layer.enable(env.viewer); + assert.equal(env.calls(), 2); + assert.equal(env.layer.getStats().count, 2); +}); + +test('a failed refresh keeps the drawn features', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 0 }); + const env = harness(t, [ + result('a'), + () => Promise.reject(new Error('HTTP 503')), + ]); + await env.layer.enable(env.viewer); + t.mock.timers.tick(60_000); + await assert.rejects(env.layer.update(env.viewer), /HTTP 503/); + assert.equal(env.layer.getStats().count, 1); + assert.equal(env.sources.size, 1); +}); + +test('disable during a refresh cancels it and draws nothing late', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 0 }); + const pending = deferred(); + let refreshSignal = null; + const env = harness(t, [ + result('a'), + (signal) => { + refreshSignal = signal; + return pending.promise; + }, + ]); + await env.layer.enable(env.viewer); + t.mock.timers.tick(60_000); + const refreshing = env.layer.update(env.viewer); + env.layer.disable(env.viewer); + assert.equal(refreshSignal.aborted, true); + pending.resolve(result('b', 'c')); + await refreshing; + assert.equal(env.sources.size, 0); +}); diff --git a/src/library/layers.js b/src/library/layers.js index 42b6eb237..c1c42b793 100644 --- a/src/library/layers.js +++ b/src/library/layers.js @@ -147,13 +147,27 @@ export function libraryCredit(entry) { */ export function createLibraryDatasetLayer( entry, - { services, source, onZoom = null, onRemove = null }, + { + services, + source, + onZoom = null, + onRemove = null, + screenSpaceEventHandlerFactory, + }, ) { let truncated = false; let loaded = 0; let extent = null; let rowListener = null; let legendCounts = null; + // Live data: features older than the source's cacheSeconds are loaded + // again, on the next enable and on a timer while the layer is on. + const maxAgeMs = entry.source.cacheSeconds * 1000; + let loadedAt = 0; + let prefetched = null; + let refreshController = null; + let on = false; + const stale = () => Date.now() - loadedAt >= maxAgeMs; const colorBy = entry.display.colorBy || null; const layer = createLocalGeoJsonLayer( { @@ -164,12 +178,18 @@ export function createLibraryDatasetLayer( icon: entry.icon, source: entry.provider, labels: true, + ...(screenSpaceEventHandlerFactory + ? { screenSpaceEventHandlerFactory } + : {}), featureColor: colorBy ? (properties) => colorBy.colors[String(properties?.__gevColorKey ?? '')] || null : null, loadFeatures: async ({ signal }) => { - const result = await source.loadDataset(entry, { signal }); + const result = + prefetched ?? (await source.loadDataset(entry, { signal })); + prefetched = null; + loadedAt = Date.now(); truncated = result.truncated; loaded = result.features.length; extent = featureExtent(result.features); @@ -181,13 +201,55 @@ export function createLibraryDatasetLayer( services, ); const enable = layer.enable; + const disable = layer.disable; + const destroy = layer.destroy; const getStats = layer.getStats; + const cancelRefresh = () => { + refreshController?.abort(); + refreshController = null; + prefetched = null; + }; return Object.assign(layer, { libraryEntryId: entry.id, + refreshInterval: maxAgeMs, async enable(viewer, ...rest) { + on = true; registerDynamicCredit(viewer, libraryCredit(entry)); + if (stale()) layer.dropCachedFeatures(); return enable(viewer, ...rest); }, + /** + * The manager's refresh tick: once the data is stale, load it again and + * swap it in only when the new set is ready, so the old features stay + * drawn while the request runs and stay if it fails. + */ + async update(viewer) { + if (!on || !stale() || refreshController) return; + const current = new AbortController(); + refreshController = current; + try { + const result = await source.loadDataset(entry, { + signal: current.signal, + }); + if (!on || current.signal.aborted) return; + prefetched = result; + layer.dropCachedFeatures(); + disable(viewer); + await enable(viewer); + } finally { + if (refreshController === current) refreshController = null; + } + }, + disable(viewer, ...rest) { + on = false; + cancelRefresh(); + return disable(viewer, ...rest); + }, + destroy(viewer, ...rest) { + on = false; + cancelRefresh(); + return destroy(viewer, ...rest); + }, getStats() { const stats = getStats(); // A capped dataset reads PARTIAL ("incomplete snapshot"), never as