SkateSpots/src/lib/tileCache.ts
SkateSpots d9ad9e9b29 feat(offline): cache map tiles on-device + photo mirror fallback
Map tiles: new lib/tileCache.ts (Dexie SkateSpotsTilesDB, bounded to ~3000
tiles) and lib/cachedTileLayer.ts, a Leaflet TileLayer that reads through it
(cached -> fetch+cache -> plain URL fallback). Wired into both maps (SpotMap,
RouteDialog). Chosen over a service worker so it works in the Capacitor
WebView too, where a SW never sees tile requests.

Photos: useUploadFile drops from three Blossom hosts to two and records
['fallback', '<mirror>/<sha256>']. BlossomUploader signs once and Promise.any
PUTs to every server in parallel, so the old three-host list was triple-
uploading every photo (the 'tries them in order' comment was wrong). Read
side: lib/imageFallback.ts derives same-hash mirrors and ImageWithFallback
retries them before showing the placeholder - one choke point for ~20 photo
call sites, so a dead host no longer blanks a spot.
2026-09-28 13:01:27 +07:00

106 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Dexie, { type Table } from 'dexie';
/**
* On-device cache of map tiles, so the basemap survives a dead network.
*
* Why not a service worker? A SW only helps the web build; the Android app runs
* the bundle inside Capacitor's WebView, where a SW never sees tile requests. An
* IndexedDB-backed Leaflet layer caches tiles identically on web and native, and
* keeps the failure contained to the map (no "stale app shell" class of bug).
*
* Growth is bounded: tiles are pruned oldest-used-first once the count exceeds
* MAX_TILES, and oversized responses are skipped.
*/
interface CachedTile {
/** `${z}/${x}/${y}` */
key: string;
blob: Blob;
cachedAt: number;
lastUsed: number;
}
/** Skip caching anything bigger than this (a 256px PNG is normally 5–30 KB). */
export const MAX_TILE_BYTES = 500 * 1024;
/** Roughly a large city's worth of tiles; pruning keeps us under this. */
const MAX_TILES = 3000;
class TileDB extends Dexie {
tiles!: Table<CachedTile, string>;
constructor() {
super('SkateSpotsTilesDB');
this.version(1).stores({ tiles: 'key, lastUsed, cachedAt' });
}
}
let db: TileDB | null = null;
function getDB(): TileDB {
if (!db) db = new TileDB();
return db;
}
export async function getTile(key: string): Promise<Blob | null> {
try {
const row = await getDB().tiles.get(key);
if (!row) return null;
// Touch lastUsed so pruning keeps tiles you actually look at.
void getDB().tiles.update(key, { lastUsed: Date.now() });
return row.blob;
} catch {
return null;
}
}
export async function putTile(key: string, blob: Blob): Promise<void> {
if (blob.size === 0 || blob.size > MAX_TILE_BYTES) return;
try {
const now = Date.now();
await getDB().tiles.put({ key, blob, cachedAt: now, lastUsed: now });
void prune();
} catch {
// Quota / private mode — caching is best-effort, never fatal.
}
}
let pruning = false;
async function prune(): Promise<void> {
if (pruning) return;
pruning = true;
try {
const count = await getDB().tiles.count();
if (count <= MAX_TILES) return;
const oldest = await getDB()
.tiles.orderBy('lastUsed')
.limit(count - MAX_TILES)
.primaryKeys();
await getDB().tiles.bulkDelete(oldest);
} catch {
// ignore
} finally {
pruning = false;
}
}
export async function getTileCacheStats(): Promise<{ tiles: number; bytes: number }> {
try {
let bytes = 0;
const tiles = await getDB().tiles.count();
await getDB().tiles.each((t) => {
bytes += t.blob?.size ?? 0;
});
return { tiles, bytes };
} catch {
return { tiles: 0, bytes: 0 };
}
}
export async function clearTiles(): Promise<void> {
try {
await getDB().tiles.clear();
} catch {
// ignore
}
}