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