Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/components/dashboard/TileCacheOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="pointer-events-none fixed bottom-2 left-2 z-50 rounded-lg border border-border bg-background/90 p-2 font-mono text-[11px] leading-tight shadow-lg backdrop-blur-sm">
<div className="mb-1 font-semibold">Tile cache</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
<span className="text-muted-foreground">hit ratio</span>
<span className="text-right tabular-nums">{(hitRatio * 100).toFixed(1)}%</span>
<span className="text-muted-foreground">tiles</span>
<span className="text-right tabular-nums">
{stats.count}/{TILE_CACHE_CAPACITY} ({(fill * 100).toFixed(0)}%)
</span>
<span className="text-muted-foreground">bytes</span>
<span className="text-right tabular-nums">{formatBytes(stats.bytes)}</span>
<span className="text-muted-foreground">evictions</span>
<span className="text-right tabular-nums">{stats.evictions}</span>
<span className="text-muted-foreground">pending</span>
<span className="text-right tabular-nums">{stats.pending}</span>
</div>
</div>
);
}

export default TileCacheOverlay;
64 changes: 64 additions & 0 deletions src/hooks/useGeoLocation.ts
Original file line number Diff line number Diff line change
@@ -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<Geolocation, "watchPosition" | "clearWatch">;
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<GeoSample | null>(null);
const [error, setError] = useState<string | null>(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 };
}
49 changes: 49 additions & 0 deletions src/hooks/useMapViewport.ts
Original file line number Diff line number Diff line change
@@ -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<Viewport | null>(
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;
}
143 changes: 143 additions & 0 deletions src/services/tileCache.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Blob | null> {
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<void> {
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<void> {
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<string[]> {
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;
}
Loading
Loading