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>
This commit is contained in:
Ole-Morten Duesund 2026-03-08 17:41:38 +01:00
commit e8428de775
12051 changed files with 1799735 additions and 0 deletions

112
pwa/src/cache/map-cache-manager.ts vendored Normal file
View file

@ -0,0 +1,112 @@
/**
* 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));
}