Major: - GPS trail recording: reusable, dependency-free engine in src/geotracker/ (GeoTracker + geo-utils) with pluggable storage/sync adapters; LUPMIS wiring in src/geotracker-lupmis.js. Expandable My Location control (Locate Me + Record Trail), live navbar GPS readout, on-map trail/position rendering, gps_trails/gps_trail_points SQLocal tables, and store-and-forward sync via pushGpsTrail() -> save_gps_trail.php (server side documented, not yet built). - SSO authentication: public/index.php entry point validates the LUSPA SSO cookie and injects window.LUPMIS_SESSION; remotedb district_id is now a session-resolved getter. Adds public/.htaccess (DirectoryIndex). - Account menu offcanvas (navbar burger) with sign-in/out states. UI / fixes: - LayerSwitcher modernisation; base-map "None" option in picker + settings. - Mobile drawing toolbar wraps to two rows below 576px and shows only in Draw mode; second row right-aligned and clears the Select option bar. - Safari bottom-dock clipping fixed (app-container 100dvh -> 100svh). - Rename public/icons -> app-icons to dodge Apache's default /icons/ alias. - Service Worker bumped to v8 (network-first HTML, per-provider tile clear). Docs: reusable-mapping and OSM-3D-buildings concept notes; ignore Office lock files (~$*). Rebuilt dist/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
600 lines
20 KiB
JavaScript
600 lines
20 KiB
JavaScript
/**
|
||
* Service Worker
|
||
*
|
||
* Handles caching of:
|
||
* - App shell (HTML, CSS, JS)
|
||
* - Map tiles (passive runtime caching, per-host buckets)
|
||
* - API responses (network-first)
|
||
*
|
||
* Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
|
||
* NOT by this service worker. They serve different purposes:
|
||
* - Service Worker: Caching, offline asset serving, push notifications
|
||
* - SharedWorker: Shared database connection across tabs
|
||
*/
|
||
|
||
// v3: lower per-cache limits (5000 → 1500) and counter-based eviction to
|
||
// prevent Safari memory-pressure reloads.
|
||
// v4: raise OSM and Topographic limits to 8000 to support active offline
|
||
// downloads (Phase 2). Other providers stay at 1500.
|
||
// v5: switch LayerSwitcher icon path to base-URL-aware; force shell refresh.
|
||
// v6: rename /icons/ → /app-icons/ to dodge Apache's default mod_alias
|
||
// mapping (Alias /icons/ /usr/share/apache2/icons/) which intercepts
|
||
// the path server-side. Force shell refresh so deployed clients
|
||
// re-fetch the new HTML/manifest with the new path.
|
||
// v7: HTML pages now use network-first (was cache-first) so new deploys
|
||
// are picked up immediately without needing another SW version bump.
|
||
// Hashed JS / CSS / WASM stay cache-first (they're immutable per build).
|
||
// v8: GPS trail recording feature (reusable src/geotracker/ engine, expandable
|
||
// My Location control, navbar GPS readout, gps_trails SQLocal tables) plus
|
||
// mobile drawing-toolbar wrap, base-map "None" option, and the Safari
|
||
// 100svh dock fix. New hashed bundle + updated shell — bump to evict the
|
||
// old module/shell caches.
|
||
const CACHE_VERSION = 'v8';
|
||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||
const API_CACHE = `api-${CACHE_VERSION}`;
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// Tile caches — one per provider so users can clear them independently.
|
||
// Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile,
|
||
// which covers a Ghana district at zoom 10–15 (typical field-work range).
|
||
// ----------------------------------------------------------------------------
|
||
const TILES_OSM = `tiles-osm-${CACHE_VERSION}`;
|
||
const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`;
|
||
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
|
||
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
|
||
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
|
||
|
||
// Per-provider tile limits.
|
||
// • OSM and Topographic are the providers offered for active offline
|
||
// download (Phase 2 dialog), so they get a higher cap (~240 MB each at
|
||
// ~30 KB/tile) — enough for a typical Ghana district at zoom 10–15.
|
||
// • The other providers serve passive caching only (whatever the user has
|
||
// already viewed), so 1 500 tiles ≈ 45 MB is plenty.
|
||
//
|
||
// Total max ≈ 5 × ~150 MB = ~750 MB on disk in the worst case, but only the
|
||
// two downloadable buckets are likely to fill. Eviction sweeps run every 100
|
||
// inserts (see EVICTION_CHECK_INTERVAL) so memory pressure stays bounded.
|
||
const TILE_LIMITS = {
|
||
[TILES_OSM]: 8000,
|
||
[TILES_TOPO]: 8000,
|
||
[TILES_SATELLITE]: 1500,
|
||
[TILES_CARTO_LIGHT]: 1500,
|
||
[TILES_CARTO_DARK]: 1500,
|
||
};
|
||
|
||
// Per-cache running insert counter, in memory. Avoids calling cache.keys()
|
||
// (which materialises every Request object in the cache) on every put — that
|
||
// was the cause of the Safari "reloaded due to memory pressure" failures.
|
||
//
|
||
// We only run a real eviction sweep every EVICTION_CHECK_INTERVAL inserts.
|
||
const _tileInsertCounters = new Map(); // cacheName → number of inserts since last eviction
|
||
const EVICTION_CHECK_INTERVAL = 100;
|
||
|
||
// Friendly name shown in the UI (matches Settings card labels)
|
||
const TILE_CACHE_LABELS = {
|
||
[TILES_OSM]: 'OpenStreetMap',
|
||
[TILES_TOPO]: 'Topographic',
|
||
[TILES_SATELLITE]: 'Satellite',
|
||
[TILES_CARTO_LIGHT]: 'Carto Light',
|
||
[TILES_CARTO_DARK]: 'Carto Dark',
|
||
};
|
||
|
||
const ALL_TILE_CACHES = Object.keys(TILE_LIMITS);
|
||
|
||
// Approximate average tile size — used for storage estimation.
|
||
// Real measurements: PNG tiles range 5–80 KB; 30 KB is a good middle ground.
|
||
const AVG_TILE_BYTES = 30 * 1024;
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// App shell assets — precached on install.
|
||
// ----------------------------------------------------------------------------
|
||
const SHELL_ASSETS = [
|
||
'/',
|
||
'/index.html',
|
||
'/offline.html',
|
||
'/manifest.json'
|
||
];
|
||
|
||
// ============================================================================
|
||
// INSTALL EVENT
|
||
// ============================================================================
|
||
|
||
self.addEventListener('install', (event) => {
|
||
console.log('[SW] Installing...');
|
||
|
||
event.waitUntil(
|
||
caches.open(SHELL_CACHE)
|
||
.then((cache) => {
|
||
console.log('[SW] Precaching app shell');
|
||
return cache.addAll(SHELL_ASSETS);
|
||
})
|
||
.then(() => self.skipWaiting())
|
||
);
|
||
});
|
||
|
||
// ============================================================================
|
||
// ACTIVATE EVENT
|
||
// ============================================================================
|
||
|
||
self.addEventListener('activate', (event) => {
|
||
console.log('[SW] Activating...');
|
||
|
||
event.waitUntil(
|
||
caches.keys()
|
||
.then((cacheNames) => {
|
||
// Build the set of caches that should remain
|
||
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
|
||
|
||
return Promise.all(
|
||
cacheNames
|
||
// Delete anything that:
|
||
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
|
||
// • but is NOT in the current keep set
|
||
// This includes the legacy "tiles-v1" single bucket.
|
||
.filter((name) => {
|
||
const isOurs =
|
||
name.startsWith('shell-') ||
|
||
name.startsWith('tiles-') ||
|
||
name.startsWith('modules-') ||
|
||
name.startsWith('api-');
|
||
return isOurs && !keep.has(name);
|
||
})
|
||
.map((name) => {
|
||
console.log('[SW] Deleting old cache:', name);
|
||
return caches.delete(name);
|
||
})
|
||
);
|
||
})
|
||
.then(() => self.clients.claim())
|
||
);
|
||
});
|
||
|
||
// ============================================================================
|
||
// FETCH EVENT
|
||
// ============================================================================
|
||
|
||
self.addEventListener('fetch', (event) => {
|
||
const request = event.request;
|
||
const url = new URL(request.url);
|
||
|
||
// Only handle GET requests
|
||
if (request.method !== 'GET') return;
|
||
|
||
// Skip chrome-extension and other non-http(s) requests
|
||
if (!url.protocol.startsWith('http')) return;
|
||
|
||
// Skip worker files and Vite dev-server node_modules requests —
|
||
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
|
||
if (url.pathname.includes('node_modules') ||
|
||
url.search.includes('worker_file') ||
|
||
request.destination === 'worker') return;
|
||
|
||
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
|
||
const tileCache = getTileCacheName(url);
|
||
if (tileCache) {
|
||
event.respondWith(tileCacheThenNetwork(request, tileCache));
|
||
return;
|
||
}
|
||
|
||
// ----- OTHER ROUTES -----
|
||
if (isApiRequest(url)) {
|
||
event.respondWith(networkFirst(request, API_CACHE));
|
||
} else if (isModuleAsset(url)) {
|
||
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
|
||
} else if (isHtmlAsset(url)) {
|
||
// HTML uses network-first so a fresh deploy is picked up immediately.
|
||
// Falls back to the cached copy when offline (so the app still loads).
|
||
event.respondWith(networkFirst(request, SHELL_CACHE));
|
||
} else if (isAppAsset(url)) {
|
||
// Hashed JS / CSS / WASM / icons are immutable per build — cache-first
|
||
// is the right strategy here.
|
||
event.respondWith(cacheFirst(request, SHELL_CACHE));
|
||
}
|
||
// Let other requests pass through to network
|
||
});
|
||
|
||
// ============================================================================
|
||
// URL CLASSIFICATION
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Classify a URL into the appropriate tile cache.
|
||
* Returns `null` for non-tile requests, or for tile providers we deliberately
|
||
* do NOT cache (e.g. Google — caching is forbidden by their ToS).
|
||
*/
|
||
function getTileCacheName(url) {
|
||
const host = url.hostname;
|
||
|
||
// OpenStreetMap — tile.openstreetmap.org and a/b/c subdomains
|
||
if (host.endsWith('tile.openstreetmap.org')) return TILES_OSM;
|
||
|
||
// OpenTopoMap — a/b/c.tile.opentopomap.org
|
||
if (host.endsWith('tile.opentopomap.org') || host.endsWith('opentopomap.org')) return TILES_TOPO;
|
||
|
||
// Carto Basemaps — light_all / dark_all distinguished by path
|
||
if (host.endsWith('basemaps.cartocdn.com')) {
|
||
if (url.pathname.includes('/light_all/')) return TILES_CARTO_LIGHT;
|
||
if (url.pathname.includes('/dark_all/')) return TILES_CARTO_DARK;
|
||
return null; // unknown Carto style
|
||
}
|
||
|
||
// Esri — server.arcgisonline.com
|
||
if (host.endsWith('arcgisonline.com')) return TILES_SATELLITE;
|
||
|
||
// Google — caching forbidden by ToS, do not store
|
||
if (host.endsWith('google.com') || host.endsWith('googleapis.com')) return null;
|
||
|
||
// Other tile providers (WMS endpoints, OWS, custom) — not cached at this layer
|
||
// (the user's "online only" toast handles those).
|
||
return null;
|
||
}
|
||
|
||
function isApiRequest(url) {
|
||
return url.pathname.startsWith('/api/') ||
|
||
url.pathname.endsWith('.php');
|
||
}
|
||
|
||
function isModuleAsset(url) {
|
||
return url.pathname.startsWith('/modules/');
|
||
}
|
||
|
||
/**
|
||
* HTML pages (and the bare PWA root) — fetched network-first so new deploys
|
||
* roll out immediately. We never want a stale shell pointing at hashed
|
||
* asset URLs that no longer exist on the server.
|
||
*/
|
||
function isHtmlAsset(url) {
|
||
if (url.origin !== self.location.origin) return false;
|
||
if (url.pathname.endsWith('.html')) return true;
|
||
// Treat the bare site root and any trailing-slash path as HTML too,
|
||
// since they map to index.html / index.php server-side.
|
||
if (url.pathname === '/' || url.pathname.endsWith('/')) return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Immutable per-build static assets (hashed JS / CSS / WASM / images) —
|
||
* safe to cache-first; each new build produces new URLs so there's no
|
||
* stale-content risk.
|
||
*/
|
||
function isAppAsset(url) {
|
||
if (url.origin !== self.location.origin) return false;
|
||
if (isHtmlAsset(url)) return false; // HTML handled separately
|
||
return (
|
||
url.pathname.endsWith('.css') ||
|
||
url.pathname.endsWith('.js') ||
|
||
url.pathname.endsWith('.wasm') ||
|
||
url.pathname.endsWith('.json') ||
|
||
!!url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|otf)$/)
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// CACHING STRATEGIES
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Cache First — Use cache, fallback to network.
|
||
* Best for: App shell, static assets.
|
||
*/
|
||
async function cacheFirst(request, cacheName) {
|
||
const cached = await caches.match(request);
|
||
if (cached) return cached;
|
||
|
||
try {
|
||
const response = await fetch(request);
|
||
if (response.ok) {
|
||
const cache = await caches.open(cacheName);
|
||
cache.put(request, response.clone());
|
||
}
|
||
return response;
|
||
} catch (error) {
|
||
if (request.mode === 'navigate') {
|
||
return caches.match('/offline.html');
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Network First — Try network, fallback to cache.
|
||
* Best for: API requests, dynamic content.
|
||
*/
|
||
async function networkFirst(request, cacheName) {
|
||
try {
|
||
const response = await fetch(request);
|
||
if (response.ok) {
|
||
const cache = await caches.open(cacheName);
|
||
cache.put(request, response.clone());
|
||
}
|
||
return response;
|
||
} catch (error) {
|
||
const cached = await caches.match(request);
|
||
if (cached) return cached;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stale While Revalidate — Return cache immediately, update in background.
|
||
* Best for: Module assets, frequently updated content.
|
||
*/
|
||
async function staleWhileRevalidate(request, cacheName) {
|
||
const cache = await caches.open(cacheName);
|
||
const cached = await cache.match(request);
|
||
|
||
const fetchPromise = fetch(request).then((response) => {
|
||
if (response.ok) {
|
||
cache.put(request, response.clone());
|
||
}
|
||
return response;
|
||
}).catch(() => cached);
|
||
|
||
return cached || fetchPromise;
|
||
}
|
||
|
||
/**
|
||
* Tile Cache then Network — Per-host bucket with size limit.
|
||
* Cache first; on miss, fetch from network and store.
|
||
*
|
||
* Memory-conservative eviction:
|
||
* • Increments an in-memory counter on every successful insert
|
||
* • Only calls cache.keys() (which materialises all Request objects) every
|
||
* EVICTION_CHECK_INTERVAL inserts — so the cost is amortised
|
||
* • Eviction drops the oldest 10 % when over the per-host limit
|
||
*
|
||
* On network failure (offline), serves a 408 so the map renders a blank tile
|
||
* rather than throwing.
|
||
*/
|
||
async function tileCacheThenNetwork(request, cacheName) {
|
||
const cache = await caches.open(cacheName);
|
||
const cached = await cache.match(request);
|
||
if (cached) return cached;
|
||
|
||
try {
|
||
const response = await fetch(request);
|
||
|
||
if (response.ok) {
|
||
// Bump the counter; periodically run a real eviction sweep
|
||
const count = (_tileInsertCounters.get(cacheName) || 0) + 1;
|
||
_tileInsertCounters.set(cacheName, count);
|
||
|
||
if (count % EVICTION_CHECK_INTERVAL === 0) {
|
||
// Reset the counter — next sweep is another EVICTION_CHECK_INTERVAL away
|
||
_tileInsertCounters.set(cacheName, 0);
|
||
await maybeEvict(cache, cacheName);
|
||
}
|
||
|
||
// Don't await put() — it can run after we return the response, keeping
|
||
// the fetch hot path lightweight.
|
||
cache.put(request, response.clone()).catch((err) => {
|
||
// QuotaExceededError → run an immediate eviction sweep and retry once
|
||
if (err && err.name === 'QuotaExceededError') {
|
||
maybeEvict(cache, cacheName, /* force */ true).catch(() => {});
|
||
}
|
||
});
|
||
}
|
||
|
||
return response;
|
||
} catch (error) {
|
||
// Offline — let the map renderer show a blank tile
|
||
return new Response('', { status: 408, statusText: 'Offline' });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Run an eviction sweep on a cache, dropping the oldest 10 % of entries
|
||
* when over the per-cache limit. Heavy: only call periodically.
|
||
*/
|
||
async function maybeEvict(cache, cacheName, force = false) {
|
||
try {
|
||
const limit = TILE_LIMITS[cacheName] || 1500;
|
||
const keys = await cache.keys();
|
||
if (force || keys.length >= limit) {
|
||
const drop = Math.max(1, Math.ceil(limit * 0.1));
|
||
const toDelete = keys.slice(0, drop);
|
||
await Promise.all(toDelete.map((k) => cache.delete(k)));
|
||
}
|
||
} catch (err) {
|
||
console.warn('[SW] eviction sweep failed for', cacheName, err);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// MESSAGE HANDLING
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Reply to a message, preferring the transferred MessageChannel port (the
|
||
* window's pwa.js sends a port for request/response correlation), and
|
||
* falling back to the originating WindowClient if no port was supplied.
|
||
*/
|
||
function replyTo(event, message) {
|
||
if (event.ports && event.ports[0]) {
|
||
try { event.ports[0].postMessage(message); return; } catch {}
|
||
}
|
||
if (event.source && typeof event.source.postMessage === 'function') {
|
||
event.source.postMessage(message);
|
||
}
|
||
}
|
||
|
||
self.addEventListener('message', (event) => {
|
||
const { type, payload, cacheName } = event.data || {};
|
||
|
||
switch (type) {
|
||
case 'SKIP_WAITING':
|
||
self.skipWaiting();
|
||
break;
|
||
|
||
case 'CACHE_MODULES':
|
||
cacheModules(payload.modules);
|
||
break;
|
||
|
||
case 'CLEAR_USER_CACHE':
|
||
clearUserCaches();
|
||
break;
|
||
|
||
case 'GET_CACHE_STATUS':
|
||
getCacheStatus().then((status) => replyTo(event, { type: 'CACHE_STATUS', status }));
|
||
break;
|
||
|
||
// ----- Tile-cache management (Phase 1 offline maps) -----
|
||
case 'GET_TILE_STATS':
|
||
getTileStats().then((stats) => replyTo(event, { type: 'TILE_STATS', stats }));
|
||
break;
|
||
|
||
case 'CLEAR_TILE_CACHES':
|
||
clearTileCaches().then(() => replyTo(event, { type: 'TILE_CACHES_CLEARED' }));
|
||
break;
|
||
|
||
// Clear a single provider's tile cache (Phase 3 — per-provider Clear).
|
||
// Validates the requested name against the known ALL_TILE_CACHES list so
|
||
// a misbehaving caller can't drop unrelated caches.
|
||
case 'CLEAR_TILE_CACHE':
|
||
if (typeof cacheName === 'string' && ALL_TILE_CACHES.includes(cacheName)) {
|
||
caches.delete(cacheName).then((deleted) => {
|
||
_tileInsertCounters.delete(cacheName);
|
||
_cachedStats = null; _cachedStatsAt = 0; // invalidate stats cache
|
||
replyTo(event, { type: 'TILE_CACHE_CLEARED', cacheName, deleted });
|
||
});
|
||
} else {
|
||
replyTo(event, { type: 'TILE_CACHE_CLEARED', cacheName, deleted: false, error: 'Unknown or invalid cache name' });
|
||
}
|
||
break;
|
||
}
|
||
});
|
||
|
||
// ============================================================================
|
||
// HELPER FUNCTIONS
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Cache specific modules on demand.
|
||
*/
|
||
async function cacheModules(moduleNames) {
|
||
const cache = await caches.open(MODULES_CACHE);
|
||
|
||
for (const moduleName of moduleNames) {
|
||
try {
|
||
const moduleAssets = [
|
||
`/modules/${moduleName}/index.js`,
|
||
`/modules/${moduleName}/index.css`,
|
||
`/modules/${moduleName}/index.html`
|
||
];
|
||
|
||
await cache.addAll(moduleAssets.filter(async (url) => {
|
||
try {
|
||
const response = await fetch(url, { method: 'HEAD' });
|
||
return response.ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}));
|
||
|
||
console.log('[SW] Cached module:', moduleName);
|
||
} catch (error) {
|
||
console.warn('[SW] Failed to cache module:', moduleName, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear user-specific caches (call on logout).
|
||
* Tile caches are NOT cleared here — those belong to the device, not the user.
|
||
*/
|
||
async function clearUserCaches() {
|
||
await caches.delete(API_CACHE);
|
||
await caches.delete(MODULES_CACHE);
|
||
console.log('[SW] Cleared user caches');
|
||
}
|
||
|
||
/**
|
||
* Get summary status of all caches (count of entries in each).
|
||
*/
|
||
async function getCacheStatus() {
|
||
const cacheNames = await caches.keys();
|
||
const status = {};
|
||
|
||
for (const name of cacheNames) {
|
||
const cache = await caches.open(name);
|
||
const keys = await cache.keys();
|
||
status[name] = keys.length;
|
||
}
|
||
|
||
return status;
|
||
}
|
||
|
||
/**
|
||
* Get per-provider tile cache statistics.
|
||
*
|
||
* Returns shape:
|
||
* {
|
||
* totals: { count, estBytes },
|
||
* byProvider: [{ key, label, count, limit, estBytes }, ...]
|
||
* }
|
||
*
|
||
* estBytes is an approximation (count × AVG_TILE_BYTES). For an exact size,
|
||
* the caller can use navigator.storage.estimate() on the page side.
|
||
*
|
||
* Result is cached for STATS_TTL_MS so rapid re-queries (e.g. multiple
|
||
* Settings opens) don't re-enumerate every cache.
|
||
*/
|
||
const STATS_TTL_MS = 10 * 1000;
|
||
let _cachedStats = null;
|
||
let _cachedStatsAt = 0;
|
||
|
||
async function getTileStats({ force = false } = {}) {
|
||
const now = Date.now();
|
||
if (!force && _cachedStats && (now - _cachedStatsAt) < STATS_TTL_MS) {
|
||
return _cachedStats;
|
||
}
|
||
|
||
const byProvider = [];
|
||
let totalCount = 0;
|
||
|
||
for (const cacheName of ALL_TILE_CACHES) {
|
||
let count = 0;
|
||
if (await caches.has(cacheName)) {
|
||
const cache = await caches.open(cacheName);
|
||
// matchAll returns a smaller payload than keys() on Safari, but neither
|
||
// is free. Done at most once per STATS_TTL_MS thanks to the cache above.
|
||
const keys = await cache.keys();
|
||
count = keys.length;
|
||
}
|
||
byProvider.push({
|
||
key: cacheName,
|
||
label: TILE_CACHE_LABELS[cacheName] || cacheName,
|
||
count,
|
||
limit: TILE_LIMITS[cacheName] || 0,
|
||
estBytes: count * AVG_TILE_BYTES,
|
||
});
|
||
totalCount += count;
|
||
}
|
||
|
||
_cachedStats = {
|
||
totals: {
|
||
count: totalCount,
|
||
estBytes: totalCount * AVG_TILE_BYTES,
|
||
},
|
||
byProvider,
|
||
};
|
||
_cachedStatsAt = now;
|
||
return _cachedStats;
|
||
}
|
||
|
||
/**
|
||
* Delete every tile cache. Frees the device storage used by cached map tiles.
|
||
* Does not affect app-shell, modules, or API caches.
|
||
*/
|
||
async function clearTileCaches() {
|
||
const results = await Promise.all(
|
||
ALL_TILE_CACHES.map((name) => caches.delete(name))
|
||
);
|
||
// Reset counters and invalidate stats cache
|
||
_tileInsertCounters.clear();
|
||
_cachedStats = null;
|
||
_cachedStatsAt = 0;
|
||
console.log('[SW] Cleared tile caches:', ALL_TILE_CACHES.filter((_, i) => results[i]));
|
||
}
|