TypeScript 87.7%
CSS 12.3%
1/**2 * Author: Simon-Pierre Boucher3 * Contact: contact@spboucher.ai4 * Project: Groupe Ka / Ka Maps5 *6 * Viewport → data pipeline shared by every app:7 * debounce panning, abort stale requests, cache identical queries,8 * never let an old response overwrite a newer one.9 */1011import type {12 BBox,13 BoundsQueryResult,14 KaDataAdapter,15} from "../types/index.js";16import { bboxToString } from "../utils/geo.js";1718export interface BoundsQueryOptions {19 /** Milliseconds to wait after the last moveend before querying. */20 debounceMs?: number;21 /** Max cached query results (LRU). */22 cacheSize?: number;23 /** Cache time-to-live in ms; expired entries refetch. */24 cacheTtlMs?: number;25}2627export interface BoundsRequest {28 bbox: BBox;29 zoom: number;30 filters?: Record<string, unknown>;31}3233type Listener = (result: BoundsQueryResult, request: BoundsRequest) => void;34type ErrorListener = (error: unknown, request: BoundsRequest) => void;35type LoadingListener = (loading: boolean) => void;3637interface CacheEntry {38 result: BoundsQueryResult;39 at: number;40}4142/**43 * One instance per map. `request()` may be called on every moveend; the44 * scheduler collapses bursts, cancels in-flight fetches and guarantees45 * monotonic delivery (a response for request N never fires after N+1's).46 */47export class BoundsQueryScheduler {48 private adapter: KaDataAdapter;49 private debounceMs: number;50 private cacheSize: number;51 private cacheTtlMs: number;5253 private timer: ReturnType<typeof setTimeout> | null = null;54 private controller: AbortController | null = null;55 private seq = 0;56 private delivered = 0;57 private cache = new Map<string, CacheEntry>();5859 private listeners = new Set<Listener>();60 private errorListeners = new Set<ErrorListener>();61 private loadingListeners = new Set<LoadingListener>();6263 constructor(adapter: KaDataAdapter, options: BoundsQueryOptions = {}) {64 this.adapter = adapter;65 this.debounceMs = options.debounceMs ?? 250;66 this.cacheSize = options.cacheSize ?? 40;67 this.cacheTtlMs = options.cacheTtlMs ?? 60_000;68 }6970 onResult(fn: Listener): () => void {71 this.listeners.add(fn);72 return () => this.listeners.delete(fn);73 }7475 onError(fn: ErrorListener): () => void {76 this.errorListeners.add(fn);77 return () => this.errorListeners.delete(fn);78 }7980 onLoading(fn: LoadingListener): () => void {81 this.loadingListeners.add(fn);82 return () => this.loadingListeners.delete(fn);83 }8485 /** Debounced entry point — call freely on moveend. */86 request(req: BoundsRequest): void {87 if (this.timer !== null) clearTimeout(this.timer);88 this.timer = setTimeout(() => {89 this.timer = null;90 void this.execute(req);91 }, this.debounceMs);92 }9394 /** Immediate entry point — "Search this area" button, initial load. */95 requestNow(req: BoundsRequest): void {96 if (this.timer !== null) {97 clearTimeout(this.timer);98 this.timer = null;99 }100 void this.execute(req);101 }102103 /** Drop pending work and abort any in-flight request. */104 cancel(): void {105 if (this.timer !== null) {106 clearTimeout(this.timer);107 this.timer = null;108 }109 this.controller?.abort();110 this.controller = null;111 this.setLoading(false);112 }113114 /** Clear the query cache (call when filters change server-side data). */115 invalidate(): void {116 this.cache.clear();117 }118119 destroy(): void {120 this.cancel();121 this.listeners.clear();122 this.errorListeners.clear();123 this.loadingListeners.clear();124 this.cache.clear();125 }126127 private cacheKey(req: BoundsRequest): string {128 const filterKey = req.filters ? stableStringify(req.filters) : "";129 // Zoom bucketed to integer: sub-integer zoom changes rarely change data.130 return `${bboxToString(req.bbox, 4)}|z${Math.round(req.zoom)}|${filterKey}`;131 }132133 private async execute(req: BoundsRequest): Promise<void> {134 const key = this.cacheKey(req);135 const cached = this.cache.get(key);136 const mySeq = ++this.seq;137138 if (cached && Date.now() - cached.at < this.cacheTtlMs) {139 // LRU refresh.140 this.cache.delete(key);141 this.cache.set(key, cached);142 this.delivered = mySeq;143 this.emit(cached.result, req);144 return;145 }146147 this.controller?.abort();148 const controller = new AbortController();149 this.controller = controller;150 this.setLoading(true);151152 try {153 const result = await this.adapter.fetchInBounds({154 bbox: req.bbox,155 zoom: req.zoom,156 filters: req.filters,157 signal: controller.signal,158 });159 if (mySeq <= this.delivered || controller.signal.aborted) return;160 this.delivered = mySeq;161162 this.cache.set(key, { result, at: Date.now() });163 while (this.cache.size > this.cacheSize) {164 const oldest = this.cache.keys().next().value;165 if (oldest === undefined) break;166 this.cache.delete(oldest);167 }168 this.emit(result, req);169 } catch (error) {170 if (controller.signal.aborted) return; // stale by design, stay silent171 if (mySeq <= this.delivered) return;172 for (const fn of this.errorListeners) fn(error, req);173 } finally {174 if (this.controller === controller) {175 this.controller = null;176 this.setLoading(false);177 }178 }179 }180181 private emit(result: BoundsQueryResult, req: BoundsRequest): void {182 for (const fn of this.listeners) fn(result, req);183 }184185 private setLoading(loading: boolean): void {186 for (const fn of this.loadingListeners) fn(loading);187 }188}189190/** JSON.stringify with sorted keys so filter objects hash consistently. */191export function stableStringify(value: unknown): string {192 return JSON.stringify(value, (_k, v: unknown) => {193 if (v && typeof v === "object" && !Array.isArray(v)) {194 const sorted: Record<string, unknown> = {};195 for (const key of Object.keys(v as Record<string, unknown>).sort()) {196 sorted[key] = (v as Record<string, unknown>)[key];197 }198 return sorted;199 }200 return v;201 });202}203