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.
106 lines
2.7 KiB
TypeScript
106 lines
2.7 KiB
TypeScript
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
|
||
}
|
||
}
|