SkateSpots/src/lib/cachedTileLayer.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

62 lines
1.9 KiB
TypeScript

import L from 'leaflet';
import { getTile, putTile } from '@/lib/tileCache';
/**
* A Leaflet tile layer that reads through an on-device IndexedDB cache.
*
* Behaviour per tile:
* 1. cached blob → draw it immediately (works fully offline);
* 2. else fetch → cache the bytes → draw them;
* 3. offline and uncached → fall back to the plain tile URL, so Leaflet shows
* its normal missing-tile state instead of hanging forever.
*
* Works on web and in the Capacitor WebView alike, unlike a service worker.
*/
export class CachedTileLayer extends L.TileLayer {
protected createTile(coords: L.Coords, done: L.DoneCallback): HTMLElement {
const tile = document.createElement('img');
tile.alt = '';
tile.setAttribute('role', 'presentation');
const key = `${coords.z}/${coords.x}/${coords.y}`;
const url = this.getTileUrl(coords);
const useSrc = (src: string, revoke: boolean) => {
tile.onload = () => {
if (revoke) URL.revokeObjectURL(src);
done(undefined, tile);
};
tile.onerror = (e) => done(e as unknown as Error, tile);
tile.src = src;
};
void (async () => {
const cached = await getTile(key);
if (cached) {
useSrc(URL.createObjectURL(cached), true);
return;
}
try {
const res = await fetch(url, { mode: 'cors' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
if (blob.size > 0) void putTile(key, blob);
useSrc(URL.createObjectURL(blob), true);
} catch {
// No cache and no network: let the browser try the URL directly.
useSrc(url, false);
}
})();
return tile;
}
}
export function createCachedTileLayer(
urlTemplate: string,
options?: L.TileLayerOptions,
): CachedTileLayer {
return new CachedTileLayer(urlTemplate, options);
}