/** * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Project: Groupe Ka / Ka Maps * * Viewport → data pipeline shared by every app: * debounce panning, abort stale requests, cache identical queries, * never let an old response overwrite a newer one. */ import type { BBox, BoundsQueryResult, KaDataAdapter, } from "../types/index.js"; import { bboxToString } from "../utils/geo.js"; export interface BoundsQueryOptions { /** Milliseconds to wait after the last moveend before querying. */ debounceMs?: number; /** Max cached query results (LRU). */ cacheSize?: number; /** Cache time-to-live in ms; expired entries refetch. */ cacheTtlMs?: number; } export interface BoundsRequest { bbox: BBox; zoom: number; filters?: Record; } type Listener = (result: BoundsQueryResult, request: BoundsRequest) => void; type ErrorListener = (error: unknown, request: BoundsRequest) => void; type LoadingListener = (loading: boolean) => void; interface CacheEntry { result: BoundsQueryResult; at: number; } /** * One instance per map. `request()` may be called on every moveend; the * scheduler collapses bursts, cancels in-flight fetches and guarantees * monotonic delivery (a response for request N never fires after N+1's). */ export class BoundsQueryScheduler { private adapter: KaDataAdapter; private debounceMs: number; private cacheSize: number; private cacheTtlMs: number; private timer: ReturnType | null = null; private controller: AbortController | null = null; private seq = 0; private delivered = 0; private cache = new Map(); private listeners = new Set(); private errorListeners = new Set(); private loadingListeners = new Set(); constructor(adapter: KaDataAdapter, options: BoundsQueryOptions = {}) { this.adapter = adapter; this.debounceMs = options.debounceMs ?? 250; this.cacheSize = options.cacheSize ?? 40; this.cacheTtlMs = options.cacheTtlMs ?? 60_000; } onResult(fn: Listener): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } onError(fn: ErrorListener): () => void { this.errorListeners.add(fn); return () => this.errorListeners.delete(fn); } onLoading(fn: LoadingListener): () => void { this.loadingListeners.add(fn); return () => this.loadingListeners.delete(fn); } /** Debounced entry point — call freely on moveend. */ request(req: BoundsRequest): void { if (this.timer !== null) clearTimeout(this.timer); this.timer = setTimeout(() => { this.timer = null; void this.execute(req); }, this.debounceMs); } /** Immediate entry point — "Search this area" button, initial load. */ requestNow(req: BoundsRequest): void { if (this.timer !== null) { clearTimeout(this.timer); this.timer = null; } void this.execute(req); } /** Drop pending work and abort any in-flight request. */ cancel(): void { if (this.timer !== null) { clearTimeout(this.timer); this.timer = null; } this.controller?.abort(); this.controller = null; this.setLoading(false); } /** Clear the query cache (call when filters change server-side data). */ invalidate(): void { this.cache.clear(); } destroy(): void { this.cancel(); this.listeners.clear(); this.errorListeners.clear(); this.loadingListeners.clear(); this.cache.clear(); } private cacheKey(req: BoundsRequest): string { const filterKey = req.filters ? stableStringify(req.filters) : ""; // Zoom bucketed to integer: sub-integer zoom changes rarely change data. return `${bboxToString(req.bbox, 4)}|z${Math.round(req.zoom)}|${filterKey}`; } private async execute(req: BoundsRequest): Promise { const key = this.cacheKey(req); const cached = this.cache.get(key); const mySeq = ++this.seq; if (cached && Date.now() - cached.at < this.cacheTtlMs) { // LRU refresh. this.cache.delete(key); this.cache.set(key, cached); this.delivered = mySeq; this.emit(cached.result, req); return; } this.controller?.abort(); const controller = new AbortController(); this.controller = controller; this.setLoading(true); try { const result = await this.adapter.fetchInBounds({ bbox: req.bbox, zoom: req.zoom, filters: req.filters, signal: controller.signal, }); if (mySeq <= this.delivered || controller.signal.aborted) return; this.delivered = mySeq; this.cache.set(key, { result, at: Date.now() }); while (this.cache.size > this.cacheSize) { const oldest = this.cache.keys().next().value; if (oldest === undefined) break; this.cache.delete(oldest); } this.emit(result, req); } catch (error) { if (controller.signal.aborted) return; // stale by design, stay silent if (mySeq <= this.delivered) return; for (const fn of this.errorListeners) fn(error, req); } finally { if (this.controller === controller) { this.controller = null; this.setLoading(false); } } } private emit(result: BoundsQueryResult, req: BoundsRequest): void { for (const fn of this.listeners) fn(result, req); } private setLoading(loading: boolean): void { for (const fn of this.loadingListeners) fn(loading); } } /** JSON.stringify with sorted keys so filter objects hash consistently. */ export function stableStringify(value: unknown): string { return JSON.stringify(value, (_k, v: unknown) => { if (v && typeof v === "object" && !Array.isArray(v)) { const sorted: Record = {}; for (const key of Object.keys(v as Record).sort()) { sorted[key] = (v as Record)[key]; } return sorted; } return v; }); }