From c06b117fa542d7d45c6d3667a759f93c85c19e2a Mon Sep 17 00:00:00 2001 From: real-venus Date: Fri, 26 Jun 2026 02:19:59 -0700 Subject: [PATCH] feat: offline tile prefetch scheduler with LRU eviction and predictive caching (#48) Anticipates the operator's viewport trajectory (GPS heading/velocity/zoom) and pre-warms the surrounding tile pyramid into IndexedDB with bounded LRU eviction, so pinch-zoom and connectivity loss no longer fall back to grey squares. - types/tile.ts: TileId/TileMeta/GeoSample/Viewport/BBox/PrefetchRequest and bounds (2500 cap / 2250 evict threshold, 3x3 x +/-2 zoom = 45-tile burst, >2 m/s prefetch, 30deg stale-heading cancel, 7d/48h TTL by zoom) - utils/tileMath.ts: slippy-map tile math (lng/lat<->tile, bounds, tilesInBBox), trajectory prediction (predictCenter/burstTiles/predictBBox), heading/velocity gating, and zoom-dependent TTL/staleness - utils/lruEviction.ts: doubly-linked LRU list; eviction prefers stale tiles then the lowest access_count/age ratio (LRU tie-break) - services/tileCache.ts: IndexedDB tile-blob + metadata stores, hit accounting, and an eviction check every 10 writes past the threshold - workers/tilePrefetch.worker.ts: expands bbox x zoom range to tiles, fetches off-thread, writes blobs to IndexedDB, skips fresh tiles, cancels superseded bursts - hooks/useGeoLocation.ts (1 Hz GPS) + useMapViewport.ts (Mapbox camera) - store/slices/tileCacheSlice.ts: hit/miss/eviction/byte/pending stats + hit ratio; components/dashboard/TileCacheOverlay.tsx debug overlay (feature-flagged) - tests for tile math, LRU eviction ordering/staleness, and the stats store --- src/components/dashboard/TileCacheOverlay.tsx | 54 +++++ src/hooks/useGeoLocation.ts | 64 ++++++ src/hooks/useMapViewport.ts | 49 +++++ src/services/tileCache.ts | 143 +++++++++++++ src/store/slices/tileCacheSlice.ts | 109 ++++++++++ src/types/tile.ts | 102 ++++++++++ src/utils/lruEviction.ts | 160 +++++++++++++++ src/utils/tileMath.ts | 189 ++++++++++++++++++ src/workers/tilePrefetch.worker.ts | 98 +++++++++ tests/unit/lruEviction.test.ts | 98 +++++++++ tests/unit/tileCacheSlice.test.ts | 50 +++++ tests/unit/tileMath.test.ts | 130 ++++++++++++ 12 files changed, 1246 insertions(+) create mode 100644 src/components/dashboard/TileCacheOverlay.tsx create mode 100644 src/hooks/useGeoLocation.ts create mode 100644 src/hooks/useMapViewport.ts create mode 100644 src/services/tileCache.ts create mode 100644 src/store/slices/tileCacheSlice.ts create mode 100644 src/types/tile.ts create mode 100644 src/utils/lruEviction.ts create mode 100644 src/utils/tileMath.ts create mode 100644 src/workers/tilePrefetch.worker.ts create mode 100644 tests/unit/lruEviction.test.ts create mode 100644 tests/unit/tileCacheSlice.test.ts create mode 100644 tests/unit/tileMath.test.ts diff --git a/src/components/dashboard/TileCacheOverlay.tsx b/src/components/dashboard/TileCacheOverlay.tsx new file mode 100644 index 0000000..26986a8 --- /dev/null +++ b/src/components/dashboard/TileCacheOverlay.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { + useTileCacheStats, + selectHitRatio, +} from "@/store/slices/tileCacheSlice"; +import { TILE_CACHE_CAPACITY } from "@/types/tile"; + +/** + * Debug overlay surfacing tile-cache health (hit ratio, byte usage, pending + * downloads). Gated behind a feature flag so it never ships to operators by + * default. + */ + +export interface TileCacheOverlayProps { + /** Render only when true (the feature flag). */ + enabled?: boolean; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function TileCacheOverlay({ enabled = false }: TileCacheOverlayProps) { + const stats = useTileCacheStats(); + if (!enabled) return null; + + const hitRatio = selectHitRatio(stats); + const fill = stats.count / TILE_CACHE_CAPACITY; + + return ( +
+
Tile cache
+
+ hit ratio + {(hitRatio * 100).toFixed(1)}% + tiles + + {stats.count}/{TILE_CACHE_CAPACITY} ({(fill * 100).toFixed(0)}%) + + bytes + {formatBytes(stats.bytes)} + evictions + {stats.evictions} + pending + {stats.pending} +
+
+ ); +} + +export default TileCacheOverlay; diff --git a/src/hooks/useGeoLocation.ts b/src/hooks/useGeoLocation.ts new file mode 100644 index 0000000..b41d2e8 --- /dev/null +++ b/src/hooks/useGeoLocation.ts @@ -0,0 +1,64 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { GeoSample } from "@/types/tile"; + +/** + * Watches `navigator.geolocation` (≈1 Hz) and exposes the latest GPS sample with + * heading and speed — the inputs the prefetch scheduler uses to predict the + * operator's viewport trajectory. + */ + +export interface UseGeoLocationOptions { + enabled?: boolean; + /** Injectable geolocation source (tests). */ + geolocation?: Pick; + positionOptions?: PositionOptions; +} + +export interface UseGeoLocationResult { + sample: GeoSample | null; + error: string | null; + supported: boolean; +} + +/** Map a browser GeolocationPosition into our GeoSample. */ +export function toGeoSample(position: GeolocationPosition): GeoSample { + const { coords, timestamp } = position; + return { + lng: coords.longitude, + lat: coords.latitude, + heading: Number.isFinite(coords.heading) ? coords.heading : null, + speed: Number.isFinite(coords.speed) ? coords.speed : null, + timestamp, + }; +} + +export function useGeoLocation( + options: UseGeoLocationOptions = {} +): UseGeoLocationResult { + const { enabled = true } = options; + const geo = + options.geolocation ?? + (typeof navigator !== "undefined" ? navigator.geolocation : undefined); + + const [sample, setSample] = useState(null); + const [error, setError] = useState(null); + const optsRef = useRef(options.positionOptions); + optsRef.current = options.positionOptions; + + useEffect(() => { + if (!enabled || !geo) return; + const watchId = geo.watchPosition( + (position) => { + setSample(toGeoSample(position)); + setError(null); + }, + (err) => setError(err.message), + optsRef.current ?? { enableHighAccuracy: true, maximumAge: 1000, timeout: 5000 } + ); + return () => geo.clearWatch(watchId); + }, [enabled, geo]); + + return { sample, error, supported: !!geo }; +} diff --git a/src/hooks/useMapViewport.ts b/src/hooks/useMapViewport.ts new file mode 100644 index 0000000..6c60950 --- /dev/null +++ b/src/hooks/useMapViewport.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Viewport } from "@/types/tile"; + +/** + * Exposes the Mapbox camera (center, zoom, bearing, pitch) as reactive state, + * updated on every `move`. The map is a minimal structural interface so the hook + * has no hard `mapbox-gl` dependency. + */ + +export interface MapViewportSource { + getCenter(): { lng: number; lat: number }; + getZoom(): number; + getBearing(): number; + getPitch(): number; + on(type: "move", listener: () => void): void; + off(type: "move", listener: () => void): void; +} + +export function readViewport(map: MapViewportSource): Viewport { + const center = map.getCenter(); + return { + lng: center.lng, + lat: center.lat, + zoom: map.getZoom(), + bearing: map.getBearing(), + pitch: map.getPitch(), + }; +} + +export function useMapViewport(map: MapViewportSource | null): Viewport | null { + const [viewport, setViewport] = useState( + map ? readViewport(map) : null + ); + + useEffect(() => { + if (!map) { + setViewport(null); + return; + } + const update = () => setViewport(readViewport(map)); + update(); + map.on("move", update); + return () => map.off("move", update); + }, [map]); + + return viewport; +} diff --git a/src/services/tileCache.ts b/src/services/tileCache.ts new file mode 100644 index 0000000..8fb71c0 --- /dev/null +++ b/src/services/tileCache.ts @@ -0,0 +1,143 @@ +"use client"; + +import { openDB, type IDBPDatabase } from "idb"; +import { LRUList } from "@/utils/lruEviction"; +import { + EVICTION_BATCH, + WRITE_CHECK_INTERVAL, + type TileMeta, +} from "@/types/tile"; + +/** + * IndexedDB-backed vector-tile cache. + * + * Tile blobs live in the `tiles` store keyed by `z/x/y`; lightweight metadata + * (size, fetched/last-access timestamps, access count) lives in `meta` and is + * mirrored into an in-memory {@link LRUList} for O(1) hit accounting and + * value-aware eviction. Eviction is checked every {@link WRITE_CHECK_INTERVAL} + * writes once the cache reaches its threshold. + */ + +const DB_NAME = "utility-tiles"; +const DB_VERSION = 1; +const TILE_STORE = "tiles"; +const META_STORE = "meta"; + +export interface TileBlobEntry { + key: string; + blob: Blob; +} + +export interface TileCacheStats { + count: number; + bytes: number; + evictions: number; +} + +export class TileCache { + private db: IDBPDatabase | null = null; + private readonly lru = new LRUList(); + private writeCounter = 0; + private evictions = 0; + + /** Open the database and rebuild the in-memory LRU index from metadata. */ + async open(): Promise { + if (this.db) return; + this.db = await openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(TILE_STORE)) { + db.createObjectStore(TILE_STORE, { keyPath: "key" }); + } + if (!db.objectStoreNames.contains(META_STORE)) { + db.createObjectStore(META_STORE, { keyPath: "key" }); + } + }, + }); + const allMeta = (await this.db.getAll(META_STORE)) as TileMeta[]; + // Re-seed oldest → newest so the most recently accessed end up at the head. + allMeta.sort((a, b) => a.lastAccess - b.lastAccess); + for (const meta of allMeta) this.lru.add(meta); + } + + /** True if the key is present in the in-memory index. */ + has(key: string): boolean { + return this.lru.has(key); + } + + /** Read a tile blob; records a cache hit and persists the updated metadata. */ + async get(key: string, now = Date.now()): Promise { + if (!this.db || !this.lru.has(key)) return null; + const entry = (await this.db.get(TILE_STORE, key)) as TileBlobEntry | undefined; + if (!entry) { + this.lru.remove(key); + return null; + } + const meta = this.lru.touch(key, now); + if (meta) await this.db.put(META_STORE, meta); + return entry.blob; + } + + /** Write a tile blob + metadata and run an eviction check if it is due. */ + async put( + key: string, + z: number, + blob: Blob, + now = Date.now() + ): Promise { + if (!this.db) return; + const meta: TileMeta = { + key, + z, + size: blob.size, + fetchedAt: now, + accessCount: 0, + lastAccess: now, + }; + await this.db.put(TILE_STORE, { key, blob } satisfies TileBlobEntry); + await this.db.put(META_STORE, meta); + this.lru.add(meta); + + this.writeCounter += 1; + if (this.writeCounter % WRITE_CHECK_INTERVAL === 0 && this.lru.shouldEvict()) { + await this.evictIfNeeded(now); + } + } + + async delete(key: string): Promise { + if (!this.db) return; + this.lru.remove(key); + await this.db.delete(TILE_STORE, key); + await this.db.delete(META_STORE, key); + } + + /** Evict down toward the threshold; returns the evicted keys. */ + async evictIfNeeded(now = Date.now()): Promise { + if (!this.db || !this.lru.shouldEvict()) return []; + const victims = this.lru.evict(now, EVICTION_BATCH); + const tx = this.db.transaction([TILE_STORE, META_STORE], "readwrite"); + for (const key of victims) { + void tx.objectStore(TILE_STORE).delete(key); + void tx.objectStore(META_STORE).delete(key); + } + await tx.done; + this.evictions += victims.length; + return victims; + } + + stats(): TileCacheStats { + return { count: this.lru.size, bytes: this.lru.byteSize, evictions: this.evictions }; + } + + close(): void { + this.db?.close(); + this.db = null; + } +} + +let singleton: TileCache | null = null; + +/** Shared tile cache instance. */ +export function getTileCache(): TileCache { + if (!singleton) singleton = new TileCache(); + return singleton; +} diff --git a/src/store/slices/tileCacheSlice.ts b/src/store/slices/tileCacheSlice.ts new file mode 100644 index 0000000..5615961 --- /dev/null +++ b/src/store/slices/tileCacheSlice.ts @@ -0,0 +1,109 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +/** + * Cache health metrics for the tile prefetch scheduler (hits, misses, + * evictions, byte usage, pending downloads). Surfaced in a debug overlay behind + * a feature flag. Custom singleton store, matching the codebase pattern. + */ + +export interface TileCacheStatsState { + hits: number; + misses: number; + evictions: number; + count: number; + bytes: number; + pending: number; +} + +export type TileCacheAction = + | { type: "CACHE_HIT" } + | { type: "CACHE_MISS" } + | { type: "TILE_STORED"; payload: { bytes: number } } + | { type: "TILES_EVICTED"; payload: { count: number; freedBytes: number } } + | { type: "PENDING_SET"; payload: { pending: number } } + | { type: "RESET" }; + +const initialState: TileCacheStatsState = { + hits: 0, + misses: 0, + evictions: 0, + count: 0, + bytes: 0, + pending: 0, +}; + +type Listener = (state: TileCacheStatsState) => void; + +class TileCacheStore { + private state: TileCacheStatsState = initialState; + private listeners = new Set(); + + getState = (): Readonly => this.state; + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + dispatch(action: TileCacheAction): void { + const next = this.reducer(this.state, action); + if (next !== this.state) { + this.state = next; + this.notify(); + } + } + + private reducer( + state: TileCacheStatsState, + action: TileCacheAction + ): TileCacheStatsState { + switch (action.type) { + case "CACHE_HIT": + return { ...state, hits: state.hits + 1 }; + case "CACHE_MISS": + return { ...state, misses: state.misses + 1 }; + case "TILE_STORED": + return { + ...state, + count: state.count + 1, + bytes: state.bytes + action.payload.bytes, + }; + case "TILES_EVICTED": + return { + ...state, + evictions: state.evictions + action.payload.count, + count: Math.max(0, state.count - action.payload.count), + bytes: Math.max(0, state.bytes - action.payload.freedBytes), + }; + case "PENDING_SET": + return { ...state, pending: Math.max(0, action.payload.pending) }; + case "RESET": + return initialState; + default: + return state; + } + } + + private notify(): void { + for (const listener of this.listeners) listener(this.state); + } +} + +/** Shared singleton tile-cache stats store. */ +export const tileCacheStore = new TileCacheStore(); + +/** Cache hit ratio in [0, 1]; 0 when there have been no lookups. */ +export function selectHitRatio(state: TileCacheStatsState): number { + const total = state.hits + state.misses; + return total === 0 ? 0 : state.hits / total; +} + +export function useTileCacheStats(): TileCacheStatsState { + return useSyncExternalStore( + tileCacheStore.subscribe, + tileCacheStore.getState, + tileCacheStore.getState + ); +} diff --git a/src/types/tile.ts b/src/types/tile.ts new file mode 100644 index 0000000..d118176 --- /dev/null +++ b/src/types/tile.ts @@ -0,0 +1,102 @@ +/** + * Types and invariants for the offline geospatial tile prefetch scheduler. + * + * The scheduler predicts the operator's viewport trajectory (GPS heading, + * velocity, zoom delta), bursts the surrounding tile pyramid into IndexedDB, and + * evicts under a bounded LRU policy that prefers the lowest + * `access_count / age` ratio. + */ + +/** A vector tile coordinate. */ +export interface TileId { + z: number; + x: number; + y: number; +} + +/** Cached tile metadata (the blob itself lives in a separate store). */ +export interface TileMeta { + /** `z/x/y` key. */ + key: string; + z: number; + /** Approximate blob size in bytes. */ + size: number; + /** When the tile was fetched (unix ms). */ + fetchedAt: number; + /** Number of cache hits. */ + accessCount: number; + /** Most recent access (unix ms). */ + lastAccess: number; +} + +/** A GPS sample (1 Hz). */ +export interface GeoSample { + lng: number; + lat: number; + /** Heading in degrees (0 = north), or null when unknown. */ + heading: number | null; + /** Speed in m/s, or null when unknown. */ + speed: number | null; + timestamp: number; +} + +/** Map viewport state. */ +export interface Viewport { + lng: number; + lat: number; + zoom: number; + bearing: number; + pitch: number; +} + +/** Geographic bounding box. */ +export interface BBox { + west: number; + south: number; + east: number; + north: number; +} + +/** A prefetch request emitted toward the worker. */ +export interface PrefetchRequest { + /** Predicted bounding box to cover. */ + bbox: BBox; + /** Zoom levels to burst (current ± lookahead). */ + zoomLevels: number[]; + /** Monotonic request id (lets the worker cancel superseded bursts). */ + requestId: number; +} + +// --- Invariants ------------------------------------------------------------- + +/** Hard cap on cached tile entries. */ +export const TILE_CACHE_CAPACITY = 2500; +/** Eviction kicks in at this fill level. */ +export const EVICTION_THRESHOLD = 2250; +/** Tiles evicted per eviction pass (down toward a comfortable margin). */ +export const EVICTION_BATCH = TILE_CACHE_CAPACITY - EVICTION_THRESHOLD; +/** Writes between eviction checks. */ +export const WRITE_CHECK_INTERVAL = 10; + +/** Predictive window: 3×3 grid at each of current ±2 zoom levels (45 tiles). */ +export const GRID_RADIUS = 1; // 3×3 +export const ZOOM_LOOKAHEAD = 2; // ±2 levels + +/** Velocity (m/s) above which prefetch is triggered. */ +export const VELOCITY_THRESHOLD = 2; +/** Heading change (degrees) that cancels pending requests. */ +export const STALE_HEADING_DEG = 30; +/** Seconds of lookahead used to project the predicted center. */ +export const LOOKAHEAD_SECONDS = 10; + +/** Stale-tile TTLs by zoom. */ +export const TTL_MS = { + /** zoom ≤ 14. */ + lowZoom: 7 * 24 * 60 * 60 * 1000, + /** zoom ≥ 15. */ + highZoom: 48 * 60 * 60 * 1000, +} as const; + +/** Mapbox max zoom for vector tiles. */ +export const MAX_ZOOM = 22; +export const MIN_ZOOM = 0; diff --git a/src/utils/lruEviction.ts b/src/utils/lruEviction.ts new file mode 100644 index 0000000..5b47aec --- /dev/null +++ b/src/utils/lruEviction.ts @@ -0,0 +1,160 @@ +/** + * LRU cache index for tiles. + * + * A doubly-linked list keeps entries in recency order (most-recently-used at the + * head). Eviction, however, is value-aware: it prefers stale tiles, then the + * lowest `access_count / age` ratio — a tile that has been hit rarely relative + * to how long it has sat in the cache is the cheapest to drop. + */ + +import { + EVICTION_BATCH, + EVICTION_THRESHOLD, + type TileMeta, +} from "@/types/tile"; +import { isStale } from "@/utils/tileMath"; + +interface Node { + meta: TileMeta; + prev: Node | null; + next: Node | null; +} + +export interface EvictionCandidate { + key: string; + score: number; + stale: boolean; +} + +export class LRUList { + private readonly map = new Map(); + private head: Node | null = null; // most-recently-used + private tail: Node | null = null; // least-recently-used + private bytes = 0; + + get size(): number { + return this.map.size; + } + get byteSize(): number { + return this.bytes; + } + has(key: string): boolean { + return this.map.has(key); + } + keys(): string[] { + return [...this.map.keys()]; + } + + /** Insert (or replace) an entry at the head. */ + add(meta: TileMeta): void { + const existing = this.map.get(meta.key); + if (existing) { + this.bytes += meta.size - existing.meta.size; + existing.meta = meta; + this.moveToHead(existing); + return; + } + const node: Node = { meta, prev: null, next: null }; + this.map.set(meta.key, node); + this.bytes += meta.size; + this.attachHead(node); + } + + /** Record a cache hit: bump access stats and promote to MRU. Returns meta. */ + touch(key: string, now: number): TileMeta | null { + const node = this.map.get(key); + if (!node) return null; + node.meta = { + ...node.meta, + accessCount: node.meta.accessCount + 1, + lastAccess: now, + }; + this.moveToHead(node); + return node.meta; + } + + get(key: string): TileMeta | null { + return this.map.get(key)?.meta ?? null; + } + + remove(key: string): boolean { + const node = this.map.get(key); + if (!node) return false; + this.detach(node); + this.map.delete(key); + this.bytes -= node.meta.size; + return true; + } + + /** True once the cache has grown to the eviction threshold. */ + shouldEvict(threshold: number = EVICTION_THRESHOLD): boolean { + return this.map.size >= threshold; + } + + /** value ratio: hits per ms of age (lower → better eviction candidate). */ + private static score(meta: TileMeta, now: number): number { + const age = Math.max(1, now - meta.fetchedAt); + return meta.accessCount / age; + } + + /** + * Choose up to `count` keys to evict: stale tiles first, then ascending + * `access_count / age`, with least-recently-used as the final tie-breaker. + */ + evictionCandidates(count: number, now: number): EvictionCandidate[] { + const all: (EvictionCandidate & { lastAccess: number })[] = []; + for (const node of this.map.values()) { + all.push({ + key: node.meta.key, + score: LRUList.score(node.meta, now), + stale: isStale(node.meta, now), + lastAccess: node.meta.lastAccess, + }); + } + all.sort((a, b) => { + if (a.stale !== b.stale) return a.stale ? -1 : 1; // stale first + if (a.score !== b.score) return a.score - b.score; // lowest ratio first + return a.lastAccess - b.lastAccess; // then LRU + }); + return all.slice(0, count).map(({ key, score, stale }) => ({ key, score, stale })); + } + + /** Evict up to `count` entries and return the removed keys. */ + evict(now: number, count: number = EVICTION_BATCH): string[] { + const victims = this.evictionCandidates(count, now).map((c) => c.key); + for (const key of victims) this.remove(key); + return victims; + } + + // --- doubly-linked list internals ---------------------------------------- + + private attachHead(node: Node): void { + node.prev = null; + node.next = this.head; + if (this.head) this.head.prev = node; + this.head = node; + if (!this.tail) this.tail = node; + } + + private detach(node: Node): void { + if (node.prev) node.prev.next = node.next; + else this.head = node.next; + if (node.next) node.next.prev = node.prev; + else this.tail = node.prev; + node.prev = null; + node.next = null; + } + + private moveToHead(node: Node): void { + if (this.head === node) return; + this.detach(node); + this.attachHead(node); + } + + /** Keys ordered MRU → LRU (for tests / inspection). */ + orderedKeys(): string[] { + const out: string[] = []; + for (let n = this.head; n; n = n.next) out.push(n.meta.key); + return out; + } +} diff --git a/src/utils/tileMath.ts b/src/utils/tileMath.ts new file mode 100644 index 0000000..b60f617 --- /dev/null +++ b/src/utils/tileMath.ts @@ -0,0 +1,189 @@ +/** + * Pure slippy-map tile math + viewport trajectory prediction for the prefetch + * scheduler. No DOM, IndexedDB or Mapbox dependencies, so it is fully testable. + */ + +import { + GRID_RADIUS, + LOOKAHEAD_SECONDS, + MAX_ZOOM, + MIN_ZOOM, + STALE_HEADING_DEG, + TTL_MS, + VELOCITY_THRESHOLD, + ZOOM_LOOKAHEAD, + type BBox, + type GeoSample, + type TileId, + type TileMeta, + type Viewport, +} from "@/types/tile"; + +const DEG2RAD = Math.PI / 180; +const M_PER_DEG_LAT = 111_320; + +export function tileKey(z: number, x: number, y: number): string { + return `${z}/${x}/${y}`; +} + +export function tileIdKey(t: TileId): string { + return tileKey(t.z, t.x, t.y); +} + +export function parseTileKey(key: string): TileId { + const [z, x, y] = key.split("/").map(Number); + return { z, x, y }; +} + +const clampLat = (lat: number) => Math.min(85.05112878, Math.max(-85.05112878, lat)); +const clampZoom = (z: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(z))); + +/** Convert lng/lat to the slippy-map tile containing it at zoom `z`. */ +export function lngLatToTile(lng: number, lat: number, z: number): TileId { + const zoom = clampZoom(z); + const n = 2 ** zoom; + const latRad = clampLat(lat) * DEG2RAD; + const x = Math.floor(((lng + 180) / 360) * n); + const y = Math.floor( + ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n + ); + const max = n - 1; + return { + z: zoom, + x: Math.min(max, Math.max(0, x)), + y: Math.min(max, Math.max(0, y)), + }; +} + +/** Geographic bounds of a tile. */ +export function tileBounds(t: TileId): BBox { + const n = 2 ** t.z; + const lngOf = (x: number) => (x / n) * 360 - 180; + const latOf = (y: number) => { + const r = Math.PI * (1 - (2 * y) / n); + return (Math.atan(Math.sinh(r)) * 180) / Math.PI; + }; + return { + west: lngOf(t.x), + east: lngOf(t.x + 1), + north: latOf(t.y), + south: latOf(t.y + 1), + }; +} + +/** Smallest angular difference between two headings (degrees, 0–180). */ +export function headingDelta(a: number, b: number): number { + const d = Math.abs(a - b) % 360; + return d > 180 ? 360 - d : d; +} + +/** Whether a heading change is large enough to cancel pending prefetches. */ +export function isStaleHeading(prev: number, next: number): boolean { + return headingDelta(prev, next) > STALE_HEADING_DEG; +} + +/** Whether the operator is moving fast enough to warrant prefetching. */ +export function shouldPrefetch(sample: GeoSample): boolean { + return sample.speed !== null && sample.speed > VELOCITY_THRESHOLD; +} + +/** + * Project the operator's position forward along their heading at current speed. + * Falls back to the current position when heading/speed are unknown. + */ +export function predictCenter( + sample: GeoSample, + lookaheadSeconds = LOOKAHEAD_SECONDS +): { lng: number; lat: number } { + if (sample.heading === null || sample.speed === null || sample.speed <= 0) { + return { lng: sample.lng, lat: sample.lat }; + } + const distance = sample.speed * lookaheadSeconds; // meters + const headingRad = sample.heading * DEG2RAD; + const dLat = (distance * Math.cos(headingRad)) / M_PER_DEG_LAT; + const cosLat = Math.cos(clampLat(sample.lat) * DEG2RAD) || 1e-9; + const dLng = (distance * Math.sin(headingRad)) / (M_PER_DEG_LAT * cosLat); + return { lng: sample.lng + dLng, lat: sample.lat + dLat }; +} + +/** Tile pyramid burst: 3×3 grid at current ± lookahead zoom levels. */ +export function burstTiles( + center: { lng: number; lat: number }, + zoom: number, + gridRadius = GRID_RADIUS, + zoomLookahead = ZOOM_LOOKAHEAD +): TileId[] { + const tiles: TileId[] = []; + const baseZoom = clampZoom(zoom); + for (let z = baseZoom - zoomLookahead; z <= baseZoom + zoomLookahead; z++) { + if (z < MIN_ZOOM || z > MAX_ZOOM) continue; + const c = lngLatToTile(center.lng, center.lat, z); + const max = 2 ** z - 1; + for (let dx = -gridRadius; dx <= gridRadius; dx++) { + for (let dy = -gridRadius; dy <= gridRadius; dy++) { + const x = c.x + dx; + const y = c.y + dy; + if (x < 0 || y < 0 || x > max || y > max) continue; + tiles.push({ z, x, y }); + } + } + } + return tiles; +} + +/** Predicted bounding box covering the burst grid around the projected center. */ +export function predictBBox(viewport: Viewport, sample: GeoSample): BBox { + const center = predictCenter(sample); + const tiles = burstTiles(center, viewport.zoom); + // Use the base-zoom tiles for the bbox (the densest LOD). + const baseZoom = clampZoom(viewport.zoom); + const baseTiles = tiles.filter((t) => t.z === baseZoom); + const source = baseTiles.length ? baseTiles : tiles; + let west = Infinity; + let south = Infinity; + let east = -Infinity; + let north = -Infinity; + for (const t of source) { + const b = tileBounds(t); + west = Math.min(west, b.west); + east = Math.max(east, b.east); + south = Math.min(south, b.south); + north = Math.max(north, b.north); + } + return { west, south, east, north }; +} + +/** Zoom levels to burst (current ± lookahead, clamped). */ +export function zoomLevelsFor(zoom: number, zoomLookahead = ZOOM_LOOKAHEAD): number[] { + const base = clampZoom(zoom); + const levels: number[] = []; + for (let z = base - zoomLookahead; z <= base + zoomLookahead; z++) { + if (z >= MIN_ZOOM && z <= MAX_ZOOM) levels.push(z); + } + return levels; +} + +/** Every tile covering `bbox` at zoom `z`. */ +export function tilesInBBox(bbox: BBox, z: number): TileId[] { + const topLeft = lngLatToTile(bbox.west, bbox.north, z); + const bottomRight = lngLatToTile(bbox.east, bbox.south, z); + const tiles: TileId[] = []; + const minX = Math.min(topLeft.x, bottomRight.x); + const maxX = Math.max(topLeft.x, bottomRight.x); + const minY = Math.min(topLeft.y, bottomRight.y); + const maxY = Math.max(topLeft.y, bottomRight.y); + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) tiles.push({ z, x, y }); + } + return tiles; +} + +/** TTL (ms) for a tile by its zoom. */ +export function ttlForZoom(z: number): number { + return z <= 14 ? TTL_MS.lowZoom : TTL_MS.highZoom; +} + +/** Whether a cached tile has exceeded its zoom-dependent TTL. */ +export function isStale(meta: TileMeta, now: number): boolean { + return now - meta.fetchedAt > ttlForZoom(meta.z); +} diff --git a/src/workers/tilePrefetch.worker.ts b/src/workers/tilePrefetch.worker.ts new file mode 100644 index 0000000..a1c77cb --- /dev/null +++ b/src/workers/tilePrefetch.worker.ts @@ -0,0 +1,98 @@ +/** + * Tile prefetch worker. Receives a predicted bounding box + zoom range, expands + * it into tile coordinates, fetches each tile off the main thread, and writes + * the blob into the shared IndexedDB cache. A new request supersedes the + * previous one (a sharp heading change cancels the in-flight burst). + */ + +import { TileCache } from "@/services/tileCache"; +import { tileKey, tilesInBBox } from "@/utils/tileMath"; +import type { PrefetchRequest } from "@/types/tile"; + +type ConfigMessage = { type: "config"; urlTemplate: string }; +type PrefetchMessage = { type: "prefetch"; request: PrefetchRequest }; +type CancelMessage = { type: "cancel" }; +type IncomingMessage = ConfigMessage | PrefetchMessage | CancelMessage; + +export type TilePrefetchEvent = + | { type: "progress"; requestId: number; fetched: number; total: number } + | { type: "done"; requestId: number; fetched: number; skipped: number } + | { type: "error"; message: string }; + +const worker = self as unknown as Worker; +const cache = new TileCache(); +let cacheReady: Promise | null = null; +let urlTemplate = ""; +let activeRequestId = 0; + +function buildUrl(z: number, x: number, y: number): string { + return urlTemplate + .replace("{z}", String(z)) + .replace("{x}", String(x)) + .replace("{y}", String(y)); +} + +async function processRequest(request: PrefetchRequest): Promise { + if (!cacheReady) cacheReady = cache.open(); + await cacheReady; + + const tiles = request.zoomLevels.flatMap((z) => tilesInBBox(request.bbox, z)); + let fetched = 0; + let skipped = 0; + + for (const tile of tiles) { + // A newer request superseded this burst — stop early. + if (request.requestId !== activeRequestId) return; + + const key = tileKey(tile.z, tile.x, tile.y); + if (cache.has(key)) { + skipped += 1; + continue; + } + try { + const res = await fetch(buildUrl(tile.z, tile.x, tile.y)); + if (!res.ok) continue; + const blob = await res.blob(); + await cache.put(key, tile.z, blob); + fetched += 1; + worker.postMessage({ + type: "progress", + requestId: request.requestId, + fetched, + total: tiles.length, + } satisfies TilePrefetchEvent); + } catch { + // Offline / transient error — skip this tile, keep prefetching the rest. + } + } + + if (request.requestId === activeRequestId) { + worker.postMessage({ + type: "done", + requestId: request.requestId, + fetched, + skipped, + } satisfies TilePrefetchEvent); + } +} + +worker.addEventListener("message", (event: MessageEvent) => { + const msg = event.data; + switch (msg.type) { + case "config": + urlTemplate = msg.urlTemplate; + break; + case "cancel": + activeRequestId += 1; // invalidate the in-flight burst + break; + case "prefetch": + activeRequestId = msg.request.requestId; + void processRequest(msg.request).catch((err) => + worker.postMessage({ + type: "error", + message: (err as Error).message, + } satisfies TilePrefetchEvent) + ); + break; + } +}); diff --git a/tests/unit/lruEviction.test.ts b/tests/unit/lruEviction.test.ts new file mode 100644 index 0000000..ae95027 --- /dev/null +++ b/tests/unit/lruEviction.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { LRUList } from "@/utils/lruEviction"; +import { TTL_MS, type TileMeta } from "@/types/tile"; + +function meta( + key: string, + over: Partial = {} +): TileMeta { + return { + key, + z: 14, + size: 42_000, + fetchedAt: 0, + accessCount: 0, + lastAccess: 0, + ...over, + }; +} + +describe("LRUList ordering", () => { + it("keeps newest at the head", () => { + const lru = new LRUList(); + lru.add(meta("a")); + lru.add(meta("b")); + lru.add(meta("c")); + expect(lru.orderedKeys()).toEqual(["c", "b", "a"]); + }); + + it("touch promotes to MRU and bumps access stats", () => { + const lru = new LRUList(); + lru.add(meta("a")); + lru.add(meta("b")); + const updated = lru.touch("a", 5000); + expect(lru.orderedKeys()).toEqual(["a", "b"]); + expect(updated?.accessCount).toBe(1); + expect(updated?.lastAccess).toBe(5000); + }); + + it("tracks size and byte usage", () => { + const lru = new LRUList(); + lru.add(meta("a", { size: 1000 })); + lru.add(meta("b", { size: 2000 })); + expect(lru.size).toBe(2); + expect(lru.byteSize).toBe(3000); + lru.remove("a"); + expect(lru.byteSize).toBe(2000); + }); + + it("replacing a key adjusts byte usage", () => { + const lru = new LRUList(); + lru.add(meta("a", { size: 1000 })); + lru.add(meta("a", { size: 4000 })); + expect(lru.size).toBe(1); + expect(lru.byteSize).toBe(4000); + }); +}); + +describe("LRUList eviction", () => { + it("evicts the lowest access_count / age ratio first", () => { + const now = 10_000; + const lru = new LRUList(); + lru.add(meta("hot", { fetchedAt: 0, accessCount: 100 })); // 0.01 + lru.add(meta("cold", { fetchedAt: 0, accessCount: 1 })); // 0.0001 + lru.add(meta("recent", { fetchedAt: 9000, accessCount: 1 })); // 0.001 + const order = lru.evictionCandidates(3, now).map((c) => c.key); + expect(order).toEqual(["cold", "recent", "hot"]); + }); + + it("evicts stale tiles before any fresh tile regardless of ratio", () => { + const now = TTL_MS.highZoom + 100_000; + const lru = new LRUList(); + lru.add(meta("fresh", { z: 14, fetchedAt: now - 1000, accessCount: 0 })); + lru.add( + meta("stale", { z: 16, fetchedAt: now - TTL_MS.highZoom - 1, accessCount: 999 }) + ); + expect(lru.evictionCandidates(1, now)[0].key).toBe("stale"); + }); + + it("evict removes entries and returns the keys", () => { + const now = 10_000; + const lru = new LRUList(); + lru.add(meta("a", { accessCount: 1, fetchedAt: 0 })); + lru.add(meta("b", { accessCount: 100, fetchedAt: 0 })); + const removed = lru.evict(now, 1); + expect(removed).toEqual(["a"]); + expect(lru.has("a")).toBe(false); + expect(lru.has("b")).toBe(true); + }); + + it("shouldEvict triggers at the threshold", () => { + const lru = new LRUList(); + lru.add(meta("a")); + lru.add(meta("b")); + expect(lru.shouldEvict(3)).toBe(false); + lru.add(meta("c")); + expect(lru.shouldEvict(3)).toBe(true); + }); +}); diff --git a/tests/unit/tileCacheSlice.test.ts b/tests/unit/tileCacheSlice.test.ts new file mode 100644 index 0000000..006eb0d --- /dev/null +++ b/tests/unit/tileCacheSlice.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + tileCacheStore, + selectHitRatio, +} from "@/store/slices/tileCacheSlice"; + +beforeEach(() => tileCacheStore.dispatch({ type: "RESET" })); + +describe("tileCacheStore", () => { + it("counts hits and misses and derives the hit ratio", () => { + tileCacheStore.dispatch({ type: "CACHE_HIT" }); + tileCacheStore.dispatch({ type: "CACHE_HIT" }); + tileCacheStore.dispatch({ type: "CACHE_MISS" }); + const s = tileCacheStore.getState(); + expect(s.hits).toBe(2); + expect(s.misses).toBe(1); + expect(selectHitRatio(s)).toBeCloseTo(2 / 3, 10); + }); + + it("hit ratio is 0 before any lookup", () => { + expect(selectHitRatio(tileCacheStore.getState())).toBe(0); + }); + + it("accumulates stored tiles and byte usage", () => { + tileCacheStore.dispatch({ type: "TILE_STORED", payload: { bytes: 42_000 } }); + tileCacheStore.dispatch({ type: "TILE_STORED", payload: { bytes: 18_000 } }); + const s = tileCacheStore.getState(); + expect(s.count).toBe(2); + expect(s.bytes).toBe(60_000); + }); + + it("subtracts evicted tiles and bytes (clamped at 0)", () => { + tileCacheStore.dispatch({ type: "TILE_STORED", payload: { bytes: 50_000 } }); + tileCacheStore.dispatch({ + type: "TILES_EVICTED", + payload: { count: 1, freedBytes: 50_000 }, + }); + const s = tileCacheStore.getState(); + expect(s.evictions).toBe(1); + expect(s.count).toBe(0); + expect(s.bytes).toBe(0); + }); + + it("tracks pending downloads", () => { + tileCacheStore.dispatch({ type: "PENDING_SET", payload: { pending: 45 } }); + expect(tileCacheStore.getState().pending).toBe(45); + tileCacheStore.dispatch({ type: "PENDING_SET", payload: { pending: -5 } }); + expect(tileCacheStore.getState().pending).toBe(0); + }); +}); diff --git a/tests/unit/tileMath.test.ts b/tests/unit/tileMath.test.ts new file mode 100644 index 0000000..d3be820 --- /dev/null +++ b/tests/unit/tileMath.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { + tileKey, + parseTileKey, + lngLatToTile, + tileBounds, + tilesInBBox, + headingDelta, + isStaleHeading, + shouldPrefetch, + predictCenter, + burstTiles, + zoomLevelsFor, + ttlForZoom, + isStale, +} from "@/utils/tileMath"; +import { TTL_MS, type GeoSample, type TileMeta } from "@/types/tile"; + +const sample = (over: Partial = {}): GeoSample => ({ + lng: 0, + lat: 0, + heading: 0, + speed: 5, + timestamp: 0, + ...over, +}); + +describe("tile keys & coordinates", () => { + it("round-trips keys", () => { + expect(tileKey(14, 100, 200)).toBe("14/100/200"); + expect(parseTileKey("14/100/200")).toEqual({ z: 14, x: 100, y: 200 }); + }); + + it("maps the origin to tile 0/0 and the prime meridian/equator near center", () => { + // 0,0 at zoom 1 → x = 1 (just east of meridian), y = 1 (just south of equator) + expect(lngLatToTile(0, 0, 1)).toEqual({ z: 1, x: 1, y: 1 }); + expect(lngLatToTile(-180, 85, 2)).toEqual({ z: 2, x: 0, y: 0 }); + }); + + it("tileBounds are consistent with the tile that contains their center", () => { + const t = { z: 12, x: 2048, y: 1362 }; + const b = tileBounds(t); + const midLng = (b.west + b.east) / 2; + const midLat = (b.north + b.south) / 2; + expect(lngLatToTile(midLng, midLat, 12)).toEqual(t); + }); +}); + +describe("tilesInBBox", () => { + it("covers a bbox spanning a few tiles", () => { + const bbox = { ...tileBounds({ z: 10, x: 100, y: 200 }) }; + const tiles = tilesInBBox(bbox, 10); + expect(tiles).toContainEqual({ z: 10, x: 100, y: 200 }); + }); +}); + +describe("heading & velocity gating", () => { + it("computes the smallest angular delta", () => { + expect(headingDelta(10, 350)).toBe(20); + expect(headingDelta(0, 180)).toBe(180); + }); + + it("flags a stale heading past 30°", () => { + expect(isStaleHeading(0, 31)).toBe(true); + expect(isStaleHeading(0, 29)).toBe(false); + }); + + it("prefetches only above the velocity threshold", () => { + expect(shouldPrefetch(sample({ speed: 3 }))).toBe(true); + expect(shouldPrefetch(sample({ speed: 1 }))).toBe(false); + expect(shouldPrefetch(sample({ speed: null }))).toBe(false); + }); +}); + +describe("predictCenter", () => { + it("projects north when heading 0", () => { + const c = predictCenter(sample({ heading: 0, speed: 10 }), 10); // 100 m north + expect(c.lat).toBeGreaterThan(0); + expect(c.lng).toBeCloseTo(0, 6); + }); + + it("projects east when heading 90", () => { + const c = predictCenter(sample({ heading: 90, speed: 10 }), 10); + expect(c.lng).toBeGreaterThan(0); + expect(c.lat).toBeCloseTo(0, 6); + }); + + it("returns the current position when stationary or heading unknown", () => { + expect(predictCenter(sample({ speed: 0 }))).toEqual({ lng: 0, lat: 0 }); + expect(predictCenter(sample({ heading: null }))).toEqual({ lng: 0, lat: 0 }); + }); +}); + +describe("burst pyramid", () => { + it("bursts a 3×3 grid across 5 zoom levels (≤45 tiles)", () => { + const tiles = burstTiles({ lng: 0, lat: 0 }, 14); + expect(zoomLevelsFor(14)).toEqual([12, 13, 14, 15, 16]); + // 5 zoom levels × up to 9 tiles each, minus any edge clamping. + expect(tiles.length).toBeLessThanOrEqual(45); + expect(tiles.length).toBeGreaterThanOrEqual(40); + expect(new Set(tiles.map((t) => `${t.z}/${t.x}/${t.y}`)).size).toBe(tiles.length); + }); + + it("clamps zoom levels at the edges of the range", () => { + expect(zoomLevelsFor(1)).toEqual([0, 1, 2, 3]); + }); +}); + +describe("TTL & staleness", () => { + const meta = (z: number, fetchedAt: number): TileMeta => ({ + key: `${z}/0/0`, + z, + size: 42_000, + fetchedAt, + accessCount: 0, + lastAccess: fetchedAt, + }); + + it("uses 7 days for low zoom and 48 h for high zoom", () => { + expect(ttlForZoom(14)).toBe(TTL_MS.lowZoom); + expect(ttlForZoom(15)).toBe(TTL_MS.highZoom); + }); + + it("detects stale tiles by zoom-dependent TTL", () => { + const now = TTL_MS.lowZoom + 1000; + expect(isStale(meta(14, 0), now)).toBe(true); // 7d+ old + expect(isStale(meta(14, now - 1000), now)).toBe(false); + expect(isStale(meta(16, now - TTL_MS.highZoom - 1), now)).toBe(true); + }); +});