pwaLUPMIS2/src/offlineTiles.js
ekke ef12e4477b Offline tile cache, polygon Divide, topographic layer integrations
Major feature batch covering drawing-tool improvements, layer additions,
and offline-first capabilities. Largest changes in MapView.js (+1700),
main.js (+1500), public/sw.js (+367), and new modules under src/.

Drawing & editing toolkit
  * Polygon Divide tool — sub-button under Split, divides a polygon into
    N equal-area pieces via binary search; user picks the cutting edge
  * UPN pick phase after Split and Divide — non-picked pieces have their
    identifier fields cleared automatically
  * Improved Merge algorithm — vertex-to-edge proximity (5 m tol.) with
    hybrid lockstep extension; bold A/B labels on selected polygons
  * Persistent vertex highlights — all vertices of the selected polygon
    rendered as dots while edit mode is on, without subclassing ol-ext
  * Toast notifications for merge/split/divide outcomes
  * Shapefile import — addGeoJSONLayer now includes an image style so
    Point features render (previously invisible)

Background & overlay layers
  * DEAfrica Coastlines v0.4 (WMS) in Biophysical Environment
  * DEAfrica Slope (SRTM 30m, style_slope) — semi-transparent background
  * Contours hillshade — get_contours_hillshade.php → local SQLite cache
  * OSM_roads — get_osm_roads.php → local SQLite cache, casing-stroke
    style (black 3.5 px outer, #F0F1F0 1.5 px inner)
  * External Source dialog — green + button in LayerSwitcher lets users
    add WMS / WFS / XYZ layers at runtime
  * Generic addWMSLayer / addXYZLayer with style, opacity, zIndex,
    legendUrl, onlineOnly options
  * TileWMS replaces ImageWMS (fixes 'Width exceeds 512' WMS errors)
  * Legend panel — bottom-right, auto-shown for visible layers that
    register a legendUrl
  * Default base map setting in Settings, persisted in localStorage;
    setBaseMap() on MapView

Offline tile cache (Phase 1 + 2)
  * Service worker: per-host tile caches (osm / topo / satellite /
    carto-light / carto-dark), counter-based eviction to prevent
    iOS Safari memory-pressure reloads, GET_TILE_STATS /
    CLEAR_TILE_CACHES message API
  * pwa.js helpers: getActiveServiceWorker, onServiceWorkerControllerChange,
    getTileCacheStats, clearTileCaches, getStorageEstimate
  * Settings: Offline Map Tiles card with per-provider stats + clear
  * Phase 2 download dialog: form to pick base map, area (current view /
    district / Ghana), zoom range; live tile-count + size estimate;
    progress bar with cancel; OfflineTileDownloader class with
    concurrency + throttling

Local database management
  * osm_roads table + saveOSMRoads / getLocalOSMRoads helpers
  * CACHED_LAYER_TABLES allow-list with clearTable / clearAllCachedLayers
  * Local Database Tables card: per-row Clear button (cached layers
    only) + 'Refresh cached layers' header button with reload prompt

Build & infrastructure
  * Shpjs lazy-loaded via dynamic import (saves ~140 kB from initial JS)
  * chunkSizeWarningLimit raised to 900 kB (openlayers + sqlite3.wasm
    can't be split further)
  * Toast notification module (src/toast.js)
  * Units module (src/units.js) for metric / imperial conversions
  * PDF export module (src/pdf-export.js)

Documentation & SQL
  * Topographic_Background_Layers_for_LUPMIS2.docx — research report
  * OpenTopography_Workflow.svg/.png — ETL pipeline diagram
  * LUPMIS2_Development_Status_Report.docx — April update section
  * sql/create_landuse_parcels.sql — PostgreSQL schema for the LUSPA
    land-use parcel specification (Feb 2026, revised), with PostGIS
    geometry column and standard indices

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:55:30 +02:00

294 lines
9.3 KiB
JavaScript

/**
* 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;
}