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);
+ });
+});