mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
feat: Wildlife Sightings layer from iNaturalist and Acartia
Recent community wildlife observations, as a new Wildlife group in the layer panel. - Server: keyless iNaturalist proxy (v2 observations with only the projected fields; undocumented v1 UTFGrid density tiles, decoded on the server) sharing one paced queue (burst 4, ~1/s, 60/min, 8,000/day budget, up to four in flight), in-memory caches and upstream-only cooldowns; Acartia Salish Sea marine-mammal feed rounded to 0.01 degrees with comments dropped. - Layer: grid counts drawn as a soft view-ranked density glow; real sampled sightings as taxon badges at their true positions with ambient photo thumbnails and labels; obscured records stay privacy cells; a clicked sighting stays pinned through camera moves and refreshes. - Anchored photo card per selection with the single iNaturalist link; attribution in the layer row and the Data attribution popover. - Filters (CONFIRMED, taxa presets) round-trip in share links with token 9. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
edd6212793
commit
14f88b3e08
120 files changed
+21260
-285
No files matched your search
@@ -0,0 +1,804 @@
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js';
|
||||
import { combineWildlifeGridSnapshots } from './source.js';
|
||||
import { wildlifeMeasure } from './performance.js';
|
||||
import { isRecentWildlifeSighting } from './records.js';
|
||||
import {
|
||||
wildlifeGridViewportBoxes,
|
||||
chooseWildlifeGridZoom,
|
||||
wildlifeGridTiles,
|
||||
wildlifeGridCellId,
|
||||
} from './gridViewport.js';
|
||||
import {
|
||||
wildlifeViewportBoxes,
|
||||
snapWildlifeBox,
|
||||
retryDelayMs,
|
||||
} from './viewport.js';
|
||||
import {
|
||||
WILDLIFE_DEBOUNCE_MS,
|
||||
WILDLIFE_MAX_ROWS,
|
||||
WILDLIFE_GRID_HEIGHT_METERS,
|
||||
} from './policy.js';
|
||||
|
||||
/** Manager-owned acquisition with camera debounce and guarded transactions. */
|
||||
export function createLifecycle({ state, services, parts }) {
|
||||
let requestQueryKey = null;
|
||||
let removeManagerListener = null;
|
||||
let lifetimeEpoch = 0;
|
||||
let initialCameraDeadline = 0;
|
||||
let initialSettle = null;
|
||||
// The manager invokes update immediately after enable. Settle startup camera
|
||||
// events before dispatching anything, then acquire only the resulting view.
|
||||
const INITIAL_CAMERA_SETTLE_MS = 300;
|
||||
|
||||
function settleInitialCamera(signal) {
|
||||
if (!initialCameraDeadline || services.now() >= initialCameraDeadline) {
|
||||
initialCameraDeadline = 0;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = null;
|
||||
initialSettle = null;
|
||||
initialCameraDeadline = 0;
|
||||
signal?.removeEventListener('abort', finish);
|
||||
resolve();
|
||||
};
|
||||
initialSettle = {
|
||||
schedule() {
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = services.setTimer(
|
||||
finish,
|
||||
Math.max(0, initialCameraDeadline - services.now()),
|
||||
);
|
||||
},
|
||||
finish,
|
||||
};
|
||||
signal?.addEventListener('abort', finish, { once: true });
|
||||
initialSettle.schedule();
|
||||
});
|
||||
}
|
||||
|
||||
function renderBatch(fn) {
|
||||
parts.rendering.beginBatch?.();
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
parts.rendering.endBatch?.();
|
||||
}
|
||||
}
|
||||
|
||||
function clearPending() {
|
||||
if (!state.pendingParams) return;
|
||||
if (state.error) state.failedParams = state.pendingParams;
|
||||
state.pendingParams = null;
|
||||
parts.controls.notify();
|
||||
}
|
||||
|
||||
function failParams(params) {
|
||||
// An acquisition can fail during an ordinary refresh, when no option is
|
||||
// pending. Keep the chosen taxa visible and retryable in that case too.
|
||||
state.failedParams = {
|
||||
...params,
|
||||
keys: [
|
||||
...new Set([
|
||||
...(state.pendingParams?.keys || []),
|
||||
'taxa',
|
||||
...(params.confirmed ? ['confirmed'] : []),
|
||||
]),
|
||||
],
|
||||
};
|
||||
state.pendingParams = null;
|
||||
}
|
||||
|
||||
function applyRetry(error, retryAfterSec = null) {
|
||||
// Only upstream failures impose cooldown; network/schema errors retry on pan.
|
||||
const status =
|
||||
error?.upstreamStatus ??
|
||||
error?.status ??
|
||||
Number(/HTTP (\d{3})/.exec(error?.message || '')?.[1]);
|
||||
const retryDelays = [error?.retryAfterSec, retryAfterSec].filter(
|
||||
(value) => Number.isFinite(value) && value >= 0,
|
||||
);
|
||||
const retryFromSnapshot =
|
||||
Number.isFinite(retryAfterSec) && retryAfterSec >= 0;
|
||||
if (!(status === 429 || status >= 500) && !retryFromSnapshot) {
|
||||
state.failures = 0;
|
||||
state.retryAt = 0;
|
||||
return;
|
||||
}
|
||||
const backoff = retryDelays.length
|
||||
? Math.max(...retryDelays) * 1000
|
||||
: retryDelayMs(state.failures || 0);
|
||||
state.failures = backoff;
|
||||
state.retryAt = services.now() + backoff;
|
||||
}
|
||||
|
||||
function pageHidden() {
|
||||
return (
|
||||
services.eventTarget?.document?.hidden || globalThis.document?.hidden
|
||||
);
|
||||
}
|
||||
|
||||
function currentQuery() {
|
||||
const height = state.viewer?.camera?.positionCartographic?.height;
|
||||
state.band = getWildlifeHeightBand(height ?? Infinity, state.band);
|
||||
const boxes =
|
||||
state.band === 'global'
|
||||
? null
|
||||
: wildlifeViewportBoxes(state.viewer)?.map((box) =>
|
||||
snapWildlifeBox(box),
|
||||
);
|
||||
const visibleBoxes = wildlifeGridViewportBoxes(state.viewer);
|
||||
const scope =
|
||||
state.band === 'global' ||
|
||||
(!boxes && (height ?? Infinity) > WILDLIFE_GRID_HEIGHT_METERS)
|
||||
? 'global'
|
||||
: 'view';
|
||||
if (scope === 'view' && (!boxes?.length || boxes.some((box) => !box)))
|
||||
return null;
|
||||
const baseKey = JSON.stringify([
|
||||
scope,
|
||||
scope === 'global' ? null : boxes,
|
||||
state.confirmed,
|
||||
state.taxa,
|
||||
]);
|
||||
const canvas = state.viewer?.scene?.canvas;
|
||||
const zoom = visibleBoxes?.length
|
||||
? chooseWildlifeGridZoom({
|
||||
boxes: visibleBoxes,
|
||||
width: canvas?.clientWidth || 1400,
|
||||
height: canvas?.clientHeight || 900,
|
||||
})
|
||||
: 0;
|
||||
const tiles = visibleBoxes?.length
|
||||
? wildlifeGridTiles({ boxes: visibleBoxes, z: zoom })
|
||||
: [];
|
||||
return {
|
||||
scope,
|
||||
boxes: scope === 'global' ? null : boxes,
|
||||
visibleBoxes,
|
||||
tiles,
|
||||
key: JSON.stringify([baseKey, tiles]),
|
||||
};
|
||||
}
|
||||
|
||||
function refreshView() {
|
||||
const query = currentQuery();
|
||||
const superseded = state.query?.key !== query?.key;
|
||||
state.query = query;
|
||||
if (superseded) {
|
||||
state.request?.abort();
|
||||
state.background?.abort();
|
||||
}
|
||||
// Keep the previous validated display while changing filters. Its rows
|
||||
// and parameter chips are replaced together when acquisition succeeds.
|
||||
renderBatch(() => {
|
||||
const pending =
|
||||
!state.pendingClear && parts.rendering.rebuild() === 'pending';
|
||||
parts.rendering.cull();
|
||||
if (pending) return;
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits();
|
||||
});
|
||||
return query;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!state.enabled || pageHidden()) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const pendingParams = state.pendingParams;
|
||||
state.background?.abort();
|
||||
state.background = controller;
|
||||
try {
|
||||
if (state.manager)
|
||||
await state.manager.refreshLayer('wildlife-sightings', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
else await methods.update(undefined, { signal: controller.signal });
|
||||
} catch {
|
||||
// update exposes expected source errors through stats; manager failures
|
||||
// already have their own normalized status path.
|
||||
} finally {
|
||||
if (state.background === controller) {
|
||||
state.background = null;
|
||||
// A manager may decline acquisition while it is enabling, or the view
|
||||
// may disappear before update. Only queued/in-flight work is busy.
|
||||
if (
|
||||
state.pendingParams === pendingParams &&
|
||||
!state.loading &&
|
||||
state.cameraTimer == null
|
||||
)
|
||||
clearPending();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = null;
|
||||
if (!state.enabled || pageHidden()) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
if (initialSettle) {
|
||||
initialSettle.schedule();
|
||||
return;
|
||||
}
|
||||
const query = currentQuery();
|
||||
if (!query || query.key === state.lastQueryKey) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
query.key === requestQueryKey &&
|
||||
state.request &&
|
||||
!state.request.signal.aborted
|
||||
)
|
||||
return;
|
||||
state.cameraTimer = services.setTimer(
|
||||
() => {
|
||||
state.cameraTimer = null;
|
||||
if (!state.enabled || pageHidden()) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
const query = currentQuery();
|
||||
if (!query || query.key === state.lastQueryKey) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
query.key === requestQueryKey &&
|
||||
state.request &&
|
||||
!state.request.signal.aborted
|
||||
)
|
||||
return;
|
||||
if (services.now() < state.retryAt) {
|
||||
scheduleRefresh();
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
},
|
||||
Math.max(WILDLIFE_DEBOUNCE_MS, state.retryAt - services.now()),
|
||||
);
|
||||
}
|
||||
|
||||
function applyAccepted({
|
||||
snapshot,
|
||||
grid,
|
||||
gridError,
|
||||
query,
|
||||
params,
|
||||
request,
|
||||
signal,
|
||||
epoch,
|
||||
replaceRows = true,
|
||||
}) {
|
||||
const current = currentQuery();
|
||||
if (
|
||||
!state.enabled ||
|
||||
state.destroyed ||
|
||||
epoch !== lifetimeEpoch ||
|
||||
request.signal.aborted ||
|
||||
signal?.aborted ||
|
||||
params.taxa !== state.taxa ||
|
||||
params.confirmed !== state.confirmed ||
|
||||
current?.key !== query.key
|
||||
)
|
||||
return;
|
||||
renderBatch(() => {
|
||||
const sameParams =
|
||||
params.taxa === state.drawnParams?.taxa &&
|
||||
params.confirmed === state.drawnParams?.confirmed;
|
||||
const gridParams = state.gridParams || state.drawnParams;
|
||||
const sameGridParams =
|
||||
params.taxa === gridParams?.taxa &&
|
||||
params.confirmed === gridParams?.confirmed;
|
||||
// Tiles may beat a slow observation page. Publish their density without
|
||||
// replacing observations or claiming their filter transaction succeeded.
|
||||
// A pending filter keeps its previous display until both paths agree.
|
||||
if (!snapshot) {
|
||||
if (state.drawnParams && !sameParams) return;
|
||||
if (grid) {
|
||||
state.gridCells = grid.cells || [];
|
||||
state.gridParams = params;
|
||||
state.gridAvailable = true;
|
||||
state.gridTotal = grid.totalResults || 0;
|
||||
if (state.lastUpdate == null)
|
||||
state.lastUpdate = Number.isFinite(grid.fetchedAt)
|
||||
? grid.fetchedAt
|
||||
: services.now();
|
||||
const pendingRender = parts.rendering.rebuild() === 'pending';
|
||||
if (!pendingRender) {
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits();
|
||||
}
|
||||
parts.controls.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (replaceRows) {
|
||||
if (state.selectedId && !state.selectedRow)
|
||||
state.selectedRow =
|
||||
state.rowById.get(state.selectedId) ||
|
||||
state.rows.get(state.selectedId) ||
|
||||
null;
|
||||
const retainedRows = sameParams
|
||||
? (parts.selection.retainedAmbientRows?.() || []).filter(
|
||||
(row) =>
|
||||
state.rows.has(row.stableId) &&
|
||||
isRecentWildlifeSighting(row, services.now(), params.taxa),
|
||||
)
|
||||
: [];
|
||||
const retainedIds = new Set(retainedRows.map((row) => row.stableId));
|
||||
if (state.selectedRow) retainedIds.add(state.selectedRow.stableId);
|
||||
if (state.pendingClear || state.loadedScope !== query.scope)
|
||||
state.rows.clear();
|
||||
state.loadedScope = query.scope;
|
||||
state.pendingClear = false;
|
||||
// A successful page replaces this queried area, including a healthy
|
||||
// empty page. Keep only other visited areas in the bounded LRU.
|
||||
for (const [id, row] of state.rows) {
|
||||
if (rowInWildlifeBoxes(row, query.boxes, 0)) state.rows.delete(id);
|
||||
}
|
||||
for (const row of snapshot.rows) {
|
||||
state.rows.delete(row.stableId);
|
||||
state.rows.set(row.stableId, row);
|
||||
if (row.stableId === state.selectedId) state.selectedRow = row;
|
||||
}
|
||||
// Selection owns a real observation until explicit deselection. It is
|
||||
// independent of sample/window/filter and ambient-card eligibility.
|
||||
if (state.selectedRow && !state.rows.has(state.selectedId))
|
||||
state.rows.set(state.selectedId, state.selectedRow);
|
||||
// Random samples may omit a photograph already displayed. Its bounded
|
||||
// incumbent row stays selectable until the card leaves the view/window.
|
||||
for (const row of retainedRows) {
|
||||
if (!state.rows.has(row.stableId)) state.rows.set(row.stableId, row);
|
||||
}
|
||||
if (state.rows.size > WILDLIFE_MAX_ROWS) {
|
||||
for (const id of state.rows.keys()) {
|
||||
if (!retainedIds.has(id)) state.rows.delete(id);
|
||||
if (state.rows.size <= WILDLIFE_MAX_ROWS) break;
|
||||
}
|
||||
}
|
||||
if (state.rows.size > WILDLIFE_MAX_ROWS) {
|
||||
for (const id of state.rows.keys()) {
|
||||
if (id !== state.selectedId) state.rows.delete(id);
|
||||
if (state.rows.size <= WILDLIFE_MAX_ROWS) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const failedStale = [snapshot, grid].filter(
|
||||
(value) =>
|
||||
value?.stale === true &&
|
||||
Number.isFinite(value.retryAfterSec) &&
|
||||
(value.upstreamStatus === undefined ||
|
||||
value.upstreamStatus === 429 ||
|
||||
value.upstreamStatus >= 500),
|
||||
);
|
||||
const staleError = failedStale.length
|
||||
? Object.assign(
|
||||
new Error(msg('Wildlife sightings source unavailable')),
|
||||
{
|
||||
retryAfterSec: Math.max(
|
||||
...failedStale.map((value) => value.retryAfterSec),
|
||||
),
|
||||
},
|
||||
)
|
||||
: null;
|
||||
const sourceError =
|
||||
gridError ||
|
||||
staleError ||
|
||||
(snapshot.sources?.acartia?.ok === false
|
||||
? new Error(msg('Acartia feed unavailable'))
|
||||
: null);
|
||||
state.stale =
|
||||
!sourceError && (snapshot.stale === true || grid?.stale === true);
|
||||
const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter(
|
||||
Number.isFinite,
|
||||
);
|
||||
state.lastUpdate =
|
||||
(snapshot.stale === true || grid?.stale === true) && fetchedAt.length
|
||||
? Math.min(...fetchedAt)
|
||||
: services.now();
|
||||
state.error = sourceError?.message || null;
|
||||
state.drawnParams = params;
|
||||
const retryAfter = [snapshot, grid]
|
||||
.filter(
|
||||
(value) =>
|
||||
value &&
|
||||
(value.upstreamStatus === undefined ||
|
||||
value.upstreamStatus === 429 ||
|
||||
value.upstreamStatus >= 500),
|
||||
)
|
||||
.map((value) => value.retryAfterSec)
|
||||
.filter(Number.isFinite);
|
||||
if (sourceError) {
|
||||
failParams(params);
|
||||
applyRetry(
|
||||
sourceError,
|
||||
retryAfter.length ? Math.max(...retryAfter) : null,
|
||||
);
|
||||
} else {
|
||||
state.pendingParams = null;
|
||||
state.failedParams = null;
|
||||
state.retryAt = retryAfter.length
|
||||
? services.now() + Math.max(...retryAfter) * 1000
|
||||
: 0;
|
||||
state.failures = 0;
|
||||
}
|
||||
state.query = currentQuery();
|
||||
if (grid !== undefined || !sameGridParams) {
|
||||
state.gridCells = grid?.cells || [];
|
||||
state.gridParams = grid ? params : null;
|
||||
state.gridAvailable = !!grid;
|
||||
state.gridTotal = grid?.totalResults || 0;
|
||||
}
|
||||
const pendingRender = parts.rendering.rebuild() === 'pending';
|
||||
if (pendingRender) {
|
||||
state.lastQueryKey = sourceError ? null : state.query?.key || query.key;
|
||||
parts.controls.notify();
|
||||
return;
|
||||
}
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.notify();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits();
|
||||
state.lastQueryKey = sourceError ? null : state.query?.key || query.key;
|
||||
parts.controls.notify();
|
||||
});
|
||||
}
|
||||
|
||||
function disable() {
|
||||
lifetimeEpoch += 1;
|
||||
initialSettle?.finish();
|
||||
initialCameraDeadline = 0;
|
||||
state.enabled = false;
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = null;
|
||||
state.request?.abort();
|
||||
state.request = null;
|
||||
requestQueryKey = null;
|
||||
state.background?.abort();
|
||||
state.background = null;
|
||||
state.loading = false;
|
||||
clearPending();
|
||||
state.removeCamera?.();
|
||||
state.removeCamera = null;
|
||||
parts.rendering.deactivate?.();
|
||||
removeManagerListener?.();
|
||||
removeManagerListener = null;
|
||||
parts.selection.remove();
|
||||
services.picking?.unregisterPickOwner?.('wildlife-sightings');
|
||||
parts.rendering.clear();
|
||||
parts.photos.clear();
|
||||
parts.controls.notify();
|
||||
parts.rendering.requestRender('disable');
|
||||
return true;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/** Attach this layer's Cesium resources to one viewer. */
|
||||
init(viewer, { signal } = {}) {
|
||||
if (state.viewer)
|
||||
throw new Error('Wildlife sightings layer is already initialized');
|
||||
if (state.destroyed || signal?.aborted) return false;
|
||||
state.viewer = viewer;
|
||||
parts.rendering.init();
|
||||
return true;
|
||||
},
|
||||
/** Show retained observations and acquire the settled camera view. */
|
||||
enable(_viewer, { signal } = {}) {
|
||||
if (state.destroyed || signal?.aborted) return false;
|
||||
if (!state.viewer)
|
||||
throw new Error('Wildlife sightings layer must be initialized');
|
||||
if (state.enabled) return true;
|
||||
state.enabled = true;
|
||||
initialCameraDeadline = services.now() + INITIAL_CAMERA_SETTLE_MS;
|
||||
parts.rendering.activate?.();
|
||||
state.points.show = true;
|
||||
refreshView();
|
||||
for (const id of parts.selection.sourceIds)
|
||||
services.overlayHost?.setVisible?.(id, true);
|
||||
parts.selection.install();
|
||||
services.picking?.registerPickOwner?.(
|
||||
'wildlife-sightings',
|
||||
(id) =>
|
||||
typeof id === 'string' &&
|
||||
id.startsWith('wildlife:') &&
|
||||
state.rowById.has(id.slice(9)),
|
||||
);
|
||||
state.removeCamera = state.viewer.camera?.moveEnd?.addEventListener?.(
|
||||
() => {
|
||||
if (!state.enabled) return;
|
||||
wildlifeMeasure('moveEnd', () => {
|
||||
if (
|
||||
initialCameraDeadline &&
|
||||
services.now() < initialCameraDeadline
|
||||
) {
|
||||
initialCameraDeadline = services.now() + INITIAL_CAMERA_SETTLE_MS;
|
||||
refreshView();
|
||||
initialSettle?.schedule();
|
||||
} else {
|
||||
refreshView();
|
||||
scheduleRefresh();
|
||||
}
|
||||
parts.controls.notify();
|
||||
});
|
||||
},
|
||||
);
|
||||
// A debounce can expire while the manager is still enabling and cannot
|
||||
// accept refreshLayer yet. Its settled visibility event is the one
|
||||
// catch-up edge; completed initial queries are skipped by their key.
|
||||
removeManagerListener =
|
||||
state.manager?.subscribe?.((change) => {
|
||||
if (
|
||||
change.type === 'visibility' &&
|
||||
change.layerId === 'wildlife-sightings' &&
|
||||
change.enabled === true &&
|
||||
state.enabled
|
||||
)
|
||||
scheduleRefresh();
|
||||
}) || null;
|
||||
if (services.sprites?.restoreSpriteOrderOnEnable)
|
||||
services.sprites.restoreSpriteOrderOnEnable(
|
||||
'wildlife-sightings',
|
||||
state.viewer,
|
||||
);
|
||||
else services.sprites?.restoreSpriteOrder?.(state.viewer);
|
||||
return true;
|
||||
},
|
||||
/** Refresh observations and density independently for the current filters. */
|
||||
async update(_viewer, { signal } = {}) {
|
||||
if (
|
||||
!state.enabled ||
|
||||
!state.points ||
|
||||
state.destroyed ||
|
||||
signal?.aborted
|
||||
) {
|
||||
if (!state.request) clearPending();
|
||||
return false;
|
||||
}
|
||||
if (initialCameraDeadline && services.now() < initialCameraDeadline)
|
||||
await settleInitialCamera(signal);
|
||||
initialCameraDeadline = 0;
|
||||
if (!state.enabled || state.destroyed || signal?.aborted) return false;
|
||||
const query = currentQuery();
|
||||
state.query = query;
|
||||
if (!query || pageHidden()) {
|
||||
clearPending();
|
||||
return true;
|
||||
}
|
||||
if (services.now() < state.retryAt) {
|
||||
if (state.pendingParams && state.cameraTimer == null) scheduleRefresh();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
requestQueryKey === query.key &&
|
||||
state.request &&
|
||||
!state.request.signal.aborted
|
||||
)
|
||||
return true;
|
||||
state.request?.abort();
|
||||
const request = new AbortController();
|
||||
const requestLifetime = lifetimeEpoch;
|
||||
const supersededResult = () =>
|
||||
state.enabled &&
|
||||
!state.destroyed &&
|
||||
lifetimeEpoch === requestLifetime &&
|
||||
!signal?.aborted;
|
||||
state.request = request;
|
||||
requestQueryKey = query.key;
|
||||
const onAbort = () => request.abort(signal.reason);
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||
const params = { confirmed: state.confirmed, taxa: state.taxa };
|
||||
state.loading = true;
|
||||
if (!state.pendingParams) {
|
||||
const drawn = state.drawnParams || { confirmed: false, taxa: null };
|
||||
const keys = ['confirmed', 'taxa'].filter(
|
||||
(key) => params[key] !== drawn[key],
|
||||
);
|
||||
if (keys.length) state.pendingParams = { ...params, keys };
|
||||
}
|
||||
parts.controls.notify();
|
||||
let received = false;
|
||||
let gridPending = false;
|
||||
let acceptedSnapshot = null;
|
||||
let gridRequest = null;
|
||||
let gridResult = null;
|
||||
const tileSnapshots = new Map();
|
||||
const gridParams = state.gridParams || state.drawnParams;
|
||||
const retainedGrid =
|
||||
params.taxa === gridParams?.taxa &&
|
||||
params.confirmed === gridParams?.confirmed
|
||||
? state.gridCells || []
|
||||
: [];
|
||||
function aggregateTiles(complete = false) {
|
||||
if (!tileSnapshots.size) return undefined;
|
||||
const grid = combineWildlifeGridSnapshots([...tileSnapshots.values()]);
|
||||
if (!complete && retainedGrid.length) {
|
||||
const zoom = query.tiles[0]?.z;
|
||||
const covered = new Set(tileSnapshots.keys());
|
||||
const retained = retainedGrid.filter(
|
||||
(cell) =>
|
||||
!covered.has(
|
||||
wildlifeGridCellId(cell.lat, cell.lon, zoom)?.split(':')[1],
|
||||
),
|
||||
);
|
||||
return combineWildlifeGridSnapshots([
|
||||
{ ...grid, cells: [...retained, ...grid.cells] },
|
||||
]);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
function completedGrid(value) {
|
||||
if (!value) return value;
|
||||
const aggregate = aggregateTiles(!value.error);
|
||||
return aggregate
|
||||
? combineWildlifeGridSnapshots([aggregate, value], value.error)
|
||||
: value;
|
||||
}
|
||||
function publish(grid, gridError = null, replaceRows = false) {
|
||||
applyAccepted({
|
||||
snapshot: acceptedSnapshot,
|
||||
grid,
|
||||
gridError,
|
||||
replaceRows,
|
||||
query,
|
||||
params,
|
||||
request,
|
||||
signal,
|
||||
epoch: requestLifetime,
|
||||
});
|
||||
}
|
||||
function finishTiles() {
|
||||
signal?.removeEventListener?.('abort', onAbort);
|
||||
if (state.request === request) {
|
||||
state.request = null;
|
||||
requestQueryKey = null;
|
||||
state.loading = false;
|
||||
if (request.signal.aborted && state.cameraTimer == null)
|
||||
clearPending();
|
||||
parts.controls.notify();
|
||||
}
|
||||
}
|
||||
function continueTiles() {
|
||||
if (!gridRequest || gridResult) return;
|
||||
gridPending = true;
|
||||
void gridRequest
|
||||
.then((result) => {
|
||||
if (
|
||||
acceptedSnapshot &&
|
||||
!request.signal.aborted &&
|
||||
state.request === request &&
|
||||
state.enabled
|
||||
)
|
||||
publish(result?.value, result?.error || result?.value?.error);
|
||||
})
|
||||
.finally(finishTiles);
|
||||
}
|
||||
try {
|
||||
const options = {
|
||||
signal: request.signal,
|
||||
boxes: query.boxes,
|
||||
box: query.boxes?.[0] || null,
|
||||
scope: query.scope,
|
||||
...params,
|
||||
};
|
||||
// Observation requests enter the shared proxy queue first. A slow tile
|
||||
// never holds the manager's initial enable or the first observations.
|
||||
const observationRequest =
|
||||
typeof services.source.getSnapshotDetail === 'function'
|
||||
? services.source.getSnapshotDetail(options)
|
||||
: Promise.resolve(services.source.getSnapshot(options)).then(
|
||||
(rows) => ({ rows, stale: false, fetchedAt: services.now() }),
|
||||
);
|
||||
const hasGrid =
|
||||
typeof services.source.getGridSnapshot === 'function' &&
|
||||
query.tiles.length;
|
||||
gridRequest = hasGrid
|
||||
? services.source
|
||||
.getGridSnapshot({
|
||||
signal: request.signal,
|
||||
tiles: query.tiles,
|
||||
...params,
|
||||
onTile(value, { tile }) {
|
||||
if (request.signal.aborted || state.request !== request)
|
||||
return;
|
||||
tileSnapshots.set(`${tile.z}/${tile.x}/${tile.y}`, value);
|
||||
publish(aggregateTiles());
|
||||
},
|
||||
})
|
||||
.then(
|
||||
(value) => {
|
||||
gridResult = { value: completedGrid(value) };
|
||||
if (!acceptedSnapshot)
|
||||
publish(gridResult.value, value?.error);
|
||||
return gridResult;
|
||||
},
|
||||
(error) => (gridResult = { error }),
|
||||
)
|
||||
: null;
|
||||
const snapshot = await observationRequest;
|
||||
if (
|
||||
request.signal.aborted ||
|
||||
state.request !== request ||
|
||||
!state.enabled
|
||||
)
|
||||
return supersededResult();
|
||||
if (!Array.isArray(snapshot?.rows))
|
||||
throw new Error('Malformed iNaturalist response');
|
||||
received = true;
|
||||
acceptedSnapshot = snapshot;
|
||||
publish(
|
||||
gridResult?.value || aggregateTiles(),
|
||||
gridResult?.error || gridResult?.value?.error,
|
||||
true,
|
||||
);
|
||||
if (hasGrid && !gridResult) {
|
||||
continueTiles();
|
||||
} else if (gridResult && !gridResult.value && gridResult.error) {
|
||||
// Observation rows remain visible when the first tile set fails.
|
||||
publish(undefined, gridResult.error);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
request.signal.aborted ||
|
||||
state.request !== request ||
|
||||
!state.enabled
|
||||
)
|
||||
return supersededResult();
|
||||
if (received) throw error;
|
||||
// Observation failures must not cancel independent density tiles.
|
||||
continueTiles();
|
||||
state.stale = false;
|
||||
state.error =
|
||||
error?.message || msg('Wildlife sightings source unavailable');
|
||||
failParams(params);
|
||||
state.lastQueryKey = null;
|
||||
requestQueryKey = null;
|
||||
applyRetry(error);
|
||||
console.warn('[Data:WildlifeSightings] Update failed:', state.error);
|
||||
return true;
|
||||
} finally {
|
||||
if (!gridPending) finishTiles();
|
||||
}
|
||||
},
|
||||
/** Stop acquisition and release visible resources while retaining filters. */
|
||||
disable,
|
||||
/** Release requests, subscriptions, caches and scene resources. */
|
||||
destroy() {
|
||||
if (state.destroyed) return;
|
||||
disable();
|
||||
state.destroyed = true;
|
||||
parts.selection.destroy();
|
||||
parts.photos.destroy();
|
||||
parts.rendering.destroy();
|
||||
state.rows.clear();
|
||||
state.lastUpdate = null;
|
||||
state.error = null;
|
||||
state.stale = false;
|
||||
state.drawnParams = null;
|
||||
state.gridParams = null;
|
||||
state.pendingParams = null;
|
||||
state.failedParams = null;
|
||||
state.controlsListener = null;
|
||||
state.manager = null;
|
||||
state.viewer = null;
|
||||
},
|
||||
};
|
||||
return {
|
||||
methods,
|
||||
scheduleRefresh,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user