mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +00:00
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]>
110 lines
3.5 KiB
JavaScript
110 lines
3.5 KiB
JavaScript
import { setTimeout as delay } from 'node:timers/promises';
|
|
|
|
// A four-request burst keeps a cold view responsive; thereafter every provider
|
|
// call shares the same one-per-second token supply. The rolling minute ceiling
|
|
// queues the few requests a token bucket alone could allow above 60/min.
|
|
const INAT_INTERVAL_MS = 1000;
|
|
const INAT_BURST = 4;
|
|
const INAT_WINDOW_MS = 60_000;
|
|
const INAT_MAX_PER_WINDOW = 60;
|
|
const INAT_MAX_IN_FLIGHT = 4;
|
|
|
|
/** Observation queries outrank tiles waiting for a token. Aborted consumers
|
|
* leave the queue before an attempt or daily-budget reservation is spent. */
|
|
export function createInatQueue({
|
|
now = () => performance.now(),
|
|
wait = (ms) => delay(ms),
|
|
intervalMs = INAT_INTERVAL_MS,
|
|
burst = INAT_BURST,
|
|
windowMs = INAT_WINDOW_MS,
|
|
maxInFlight = INAT_MAX_IN_FLIGHT,
|
|
} = {}) {
|
|
const pending = [];
|
|
const dispatchedAt = [];
|
|
let tokens = burst;
|
|
let refilledAt = null;
|
|
let running = false;
|
|
let inFlight = 0;
|
|
let slotFreed = null;
|
|
|
|
function refill() {
|
|
const at = now();
|
|
if (refilledAt !== null && intervalMs > 0)
|
|
tokens = Math.min(
|
|
burst,
|
|
tokens + Math.max(0, at - refilledAt) / intervalMs,
|
|
);
|
|
if (intervalMs <= 0) tokens = burst;
|
|
refilledAt = at;
|
|
}
|
|
|
|
function schedule() {
|
|
if (running) return;
|
|
running = true;
|
|
// A microtask also lets same-turn observation queries take priority.
|
|
void Promise.resolve().then(drain);
|
|
}
|
|
|
|
async function drain() {
|
|
try {
|
|
while (pending.length) {
|
|
if (inFlight >= maxInFlight) {
|
|
await new Promise((resolve) => (slotFreed = resolve));
|
|
continue;
|
|
}
|
|
refill();
|
|
const at = now();
|
|
while (dispatchedAt.length && dispatchedAt[0] <= at - windowMs)
|
|
dispatchedAt.shift();
|
|
const tokenWait = tokens < 1 ? (1 - tokens) * intervalMs : 0;
|
|
const windowWait =
|
|
windowMs > 0 && dispatchedAt.length >= INAT_MAX_PER_WINDOW
|
|
? dispatchedAt[0] + windowMs - at
|
|
: 0;
|
|
if (tokenWait > 0 || windowWait > 0) {
|
|
await wait(Math.ceil(Math.max(tokenWait, windowWait)));
|
|
continue;
|
|
}
|
|
// Stable sorting preserves FIFO order within each priority.
|
|
pending.sort((a, b) => a.priority - b.priority);
|
|
const job = pending.shift();
|
|
job.signal?.removeEventListener('abort', job.abort);
|
|
if (job.signal?.aborted) {
|
|
job.reject(job.signal.reason);
|
|
continue;
|
|
}
|
|
tokens -= 1;
|
|
if (windowMs > 0) dispatchedAt.push(at);
|
|
inFlight += 1;
|
|
// A rejected task releases its slot without stopping the queue.
|
|
void new Promise((resolve) => resolve(job.task()))
|
|
.then(job.resolve, job.reject)
|
|
.finally(() => {
|
|
inFlight -= 1;
|
|
const wake = slotFreed;
|
|
slotFreed = null;
|
|
wake?.();
|
|
});
|
|
}
|
|
} finally {
|
|
running = false;
|
|
}
|
|
}
|
|
|
|
return function enqueue(task, { signal, priority = 1 } = {}) {
|
|
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
return new Promise((resolve, reject) => {
|
|
const job = { task, signal, priority, resolve, reject };
|
|
job.abort = () => {
|
|
const index = pending.indexOf(job);
|
|
if (index >= 0) pending.splice(index, 1);
|
|
signal.removeEventListener('abort', job.abort);
|
|
reject(signal.reason);
|
|
};
|
|
signal?.addEventListener('abort', job.abort, { once: true });
|
|
pending.push(job);
|
|
schedule();
|
|
});
|
|
};
|
|
}
|