tilfluktsrom/pwa/src/cache/map-cache-manager.ts
Ole-Morten Duesund e8428de775 Add progressive web app companion for cross-platform access
Vite + TypeScript PWA that mirrors the Android app's core features:
- Pre-processed shelter data (build-time UTM33N→WGS84 conversion)
- Leaflet map with shelter markers, user location, and offline tiles
- Canvas compass arrow (ported from DirectionArrowView.kt)
- IndexedDB shelter cache with 7-day staleness check
- Service worker with CacheFirst tiles and precached app shell
- i18n for en, nb, nn (ported from Android strings.xml)
- iOS/Android compass handling with low-pass filter
- Respects user map interaction (no auto-snap on pan/zoom)
- Build revision cache-breaker for reliable SW updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:41:38 +01:00

112 lines
2.8 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.

/**
* Map tile cache manager.
* Seeds tiles by programmatically panning a Leaflet map across a grid.
* Tiles are cached by the service worker (CacheFirst strategy on OSM URLs).
*
* Ported from MapCacheManager.kt — same 3×3 grid × 4 zoom levels approach.
*/
import type L from 'leaflet';
const CACHE_RADIUS_DEGREES = 0.15; // ~15km
const CACHE_ZOOM_LEVELS = [10, 12, 14, 16];
const GRID_SIZE = 3;
const PAN_DELAY_MS = 400; // Slightly longer than Android (300ms) for web
const STORAGE_KEY = 'mapCache';
interface CacheMeta {
lat: number;
lon: number;
radius: number;
complete: boolean;
}
function getCacheMeta(): CacheMeta | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function saveCacheMeta(meta: CacheMeta): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(meta));
}
/** Check if we have a tile cache covering the given location. */
export function hasCacheForLocation(lat: number, lon: number): boolean {
const meta = getCacheMeta();
if (!meta?.complete) return false;
const margin = meta.radius * 0.3;
return (
Math.abs(lat - meta.lat) < meta.radius - margin &&
Math.abs(lon - meta.lon) < meta.radius - margin
);
}
/**
* Seed the tile cache by panning the map across the surrounding area.
* The service worker's CacheFirst strategy on OSM tile URLs handles storage.
*
* Reports progress via callback (0 to 1).
*/
export async function cacheMapArea(
map: L.Map,
lat: number,
lon: number,
onProgress?: (progress: number) => void,
): Promise<boolean> {
try {
const totalSteps = CACHE_ZOOM_LEVELS.length * GRID_SIZE * GRID_SIZE;
let step = 0;
const originalZoom = map.getZoom();
const originalCenter = map.getCenter();
for (const zoom of CACHE_ZOOM_LEVELS) {
map.setZoom(zoom);
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
const panLat =
lat -
CACHE_RADIUS_DEGREES +
(2 * CACHE_RADIUS_DEGREES * row) / (GRID_SIZE - 1);
const panLon =
lon -
CACHE_RADIUS_DEGREES +
(2 * CACHE_RADIUS_DEGREES * col) / (GRID_SIZE - 1);
map.setView([panLat, panLon], zoom, { animate: false });
map.invalidateSize();
step++;
onProgress?.(step / totalSteps);
await sleep(PAN_DELAY_MS);
}
}
}
// Restore original view
map.setView(originalCenter, originalZoom, { animate: false });
saveCacheMeta({
lat,
lon,
radius: CACHE_RADIUS_DEGREES,
complete: true,
});
return true;
} catch {
return false;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}