SPB Git forge

spb/ka-maps

Public
6commits 1branches 0releases
448.0 KBsize
maindefault branch
29 days agolast push
TypeScript 87.7% CSS 12.3%
5.9 KB · 203 lines typescript
Raw Blame History
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