/** * Offline Tile Downloader * * Pre-fetches map tiles for a given extent and zoom range so they are stored * in the Service Worker's per-host tile cache for offline use. * * The downloader simply issues `fetch()` calls; the existing SW intercepts * them and routes to the right cache bucket. No direct Cache API access is * needed here — the SW is the single source of truth for storage. * * Throttling defaults are conservative to respect tile-server usage policies: * • 2 concurrent requests * • 50 ms inter-batch delay * • Standard browser User-Agent / Referer headers * * Usage: * const downloader = new OfflineTileDownloader({ * baseMap: 'topo', * extent3857: [minX, minY, maxX, maxY], // EPSG:3857 * minZoom: 10, * maxZoom: 15, * onProgress: (s) => console.log(s), * }); * await downloader.start(); * downloader.cancel(); // any time */ // ============================================================================ // Base-map URL templates // ============================================================================ /** * Tile URL templates for base maps that may be downloaded for offline use. * * The SW recognises these hosts in `getTileCacheName()` and routes them to * the matching `tiles-*-vN` cache. If you add a new entry here, also add * the host to the SW's classifier or the tiles will not be cached. */ export const BASEMAP_TEMPLATES = { topo: { url: 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png', label: 'Topographic', maxZoom: 17, cacheKey: 'tiles-topo', }, osm: { url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', label: 'OpenStreetMap', maxZoom: 19, cacheKey: 'tiles-osm', }, }; // Approximate bytes per raster tile — used for storage estimates. export const AVG_TILE_BYTES = 30 * 1024; // ============================================================================ // Tile coordinate math (Web Mercator XYZ scheme) // ============================================================================ const ORIGIN_SHIFT = 2 * Math.PI * 6378137 / 2; // 20037508.342789244 /** Convert Web Mercator metres → (lon, lat) in degrees. */ function metersToLonLat(x, y) { const lon = (x / ORIGIN_SHIFT) * 180; let lat = (y / ORIGIN_SHIFT) * 180; lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180)) - Math.PI / 2); return [lon, lat]; } /** Tile (x, y) in XYZ scheme for a given lon/lat at zoom z. */ function lonLatToTile(lon, lat, z) { const n = Math.pow(2, z); const x = Math.floor((lon + 180) / 360 * n); const latRad = lat * Math.PI / 180; const y = Math.floor( (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n ); return { x, y }; } /** Tile range covering an EPSG:3857 extent at a given zoom level. */ export function tileRangeForExtent(extent3857, z) { const [minX, minY, maxX, maxY] = extent3857; const [minLon, minLat] = metersToLonLat(minX, minY); const [maxLon, maxLat] = metersToLonLat(maxX, maxY); const tl = lonLatToTile(minLon, maxLat, z); // top-left in XYZ (NW) const br = lonLatToTile(maxLon, minLat, z); // bottom-right (SE) const n = Math.pow(2, z); const minTileX = Math.max(0, Math.min(tl.x, br.x)); const maxTileX = Math.min(n - 1, Math.max(tl.x, br.x)); const minTileY = Math.max(0, Math.min(tl.y, br.y)); const maxTileY = Math.min(n - 1, Math.max(tl.y, br.y)); return { z, minX: minTileX, maxX: maxTileX, minY: minTileY, maxY: maxTileY, count: (maxTileX - minTileX + 1) * (maxTileY - minTileY + 1), }; } /** Total tile count for an extent across a zoom range (inclusive). */ export function countTiles(extent3857, minZ, maxZ) { let total = 0; for (let z = minZ; z <= maxZ; z++) { total += tileRangeForExtent(extent3857, z).count; } return total; } /** * Enumerate every tile in an extent across a zoom range. * Returns an array of { z, x, y } objects. For very large ranges this can be * large — the caller is expected to validate the count first. */ export function enumerateTiles(extent3857, minZ, maxZ) { const out = []; for (let z = minZ; z <= maxZ; z++) { const r = tileRangeForExtent(extent3857, z); for (let x = r.minX; x <= r.maxX; x++) { for (let y = r.minY; y <= r.maxY; y++) { out.push({ z, x, y }); } } } return out; } /** * Format a tile URL for a given coordinate using a {z}/{x}/{y} template. */ export function formatTileUrl(template, { z, x, y }) { return template .replace('{z}', z) .replace('{x}', x) .replace('{y}', y); } // ============================================================================ // OfflineTileDownloader // ============================================================================ /** * Concurrent, throttled tile downloader. Issues `fetch()` per tile; the * service worker handles caching transparently. * * Events via `onProgress` callback: * { phase: 'running' | 'done' | 'cancelled' | 'error', * done, total, ok, failed, cached, * elapsedMs, etaMs } */ export class OfflineTileDownloader { constructor({ baseMap, // 'topo' | 'osm' extent3857, // [minX, minY, maxX, maxY] minZoom, maxZoom, concurrency = 2, // OSM ToS-friendly default interBatchDelayMs = 50, onProgress = () => {}, }) { const tpl = BASEMAP_TEMPLATES[baseMap]; if (!tpl) throw new Error(`Unknown base map: ${baseMap}`); if (maxZoom > tpl.maxZoom) { console.warn(`[OfflineTiles] ${baseMap}: maxZoom ${maxZoom} > supported ${tpl.maxZoom}; clamping`); maxZoom = tpl.maxZoom; } this.baseMap = baseMap; this.template = tpl.url; this.extent = extent3857; this.minZoom = minZoom; this.maxZoom = maxZoom; this.concurrency = Math.max(1, Math.min(concurrency, 6)); this.interBatchDelayMs = interBatchDelayMs; this.onProgress = onProgress; this._abortCtrl = null; this._cancelled = false; } /** * Begin downloading. Returns a Promise that resolves with the final stats * when complete, or when cancelled. */ async start() { if (this._abortCtrl) throw new Error('Downloader already started'); this._abortCtrl = new AbortController(); this._cancelled = false; const tiles = enumerateTiles(this.extent, this.minZoom, this.maxZoom); const total = tiles.length; const startedAt = Date.now(); let done = 0, ok = 0, failed = 0, cached = 0; const emit = (phase) => { const elapsedMs = Date.now() - startedAt; const etaMs = done > 0 ? Math.round((elapsedMs / done) * (total - done)) : null; this.onProgress({ phase, done, total, ok, failed, cached, elapsedMs, etaMs }); }; emit('running'); // Process in chunks of `concurrency` for (let i = 0; i < tiles.length; i += this.concurrency) { if (this._cancelled) break; const batch = tiles.slice(i, i + this.concurrency); await Promise.all(batch.map(async (t) => { if (this._cancelled) return; const url = formatTileUrl(this.template, t); try { const res = await fetch(url, { signal: this._abortCtrl.signal, // Hint the SW that this is a passive prefetch cache: 'default', }); if (res.ok) { ok++; // Detect "served from SW cache" via headers — not reliable across // implementations, so we just count all 200s as ok. Reading the body // (or cancelling it) lets the browser GC the response promptly. if (res.body) res.body.cancel().catch(() => {}); } else if (res.status === 408) { // Our SW returns 408 when offline AND nothing cached. Treat as failed. failed++; } else { failed++; } } catch (err) { if (err.name === 'AbortError') { // Cancellation — don't count } else { failed++; } } done++; })); emit('running'); if (this.interBatchDelayMs > 0 && i + this.concurrency < tiles.length) { await new Promise((r) => setTimeout(r, this.interBatchDelayMs)); } } emit(this._cancelled ? 'cancelled' : 'done'); return { phase: this._cancelled ? 'cancelled' : 'done', done, total, ok, failed, cached, elapsedMs: Date.now() - startedAt, }; } /** * Cancel an in-flight download. Resolves on the next batch boundary. */ cancel() { this._cancelled = true; if (this._abortCtrl) this._abortCtrl.abort(); } } // ============================================================================ // Predefined extents // ============================================================================ /** * Whole-of-Ghana bounding box in EPSG:3857. * Approximate: -3.3°W → 1.2°E, 4.5°N → 11.2°N. */ export const GHANA_EXTENT_3857 = (() => { const lonLatToMeters = (lon, lat) => { const x = lon * ORIGIN_SHIFT / 180; const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180); return [x, y * ORIGIN_SHIFT / 180]; }; const sw = lonLatToMeters(-3.3, 4.5); const ne = lonLatToMeters(1.2, 11.2); return [sw[0], sw[1], ne[0], ne[1]]; })(); // Useful for size estimates export function estimatedSizeBytes(tileCount) { return tileCount * AVG_TILE_BYTES; }