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>
294 lines
9.3 KiB
JavaScript
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;
|
|
}
|