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>
476 lines
15 KiB
JavaScript
476 lines
15 KiB
JavaScript
/**
|
||
* PolygonMergeInteraction
|
||
*
|
||
* A four-phase OpenLayers interaction for merging two adjacent polygons:
|
||
* Phase 1 – SELECT_A: hover to highlight, click to select polygon A
|
||
* Phase 2 – SELECT_B: hover to highlight, click to select polygon B
|
||
* Phase 3 – CLICK_EDGE_A: hover highlights edge, click to pick shared edge on A
|
||
* Phase 4 – CLICK_EDGE_B: hover highlights edge, click to pick shared edge on B → merge
|
||
*
|
||
* After a successful merge the two original features are removed and one
|
||
* merged feature (coloured green) is added. If both originals were parcels,
|
||
* a `mergedparcel` event is fired so external code can present a UPN chooser.
|
||
*/
|
||
|
||
import ol_interaction_Interaction from 'ol/interaction/Interaction';
|
||
import VectorSource from 'ol/source/Vector';
|
||
import VectorLayer from 'ol/layer/Vector';
|
||
import Feature from 'ol/Feature';
|
||
import { Style, Stroke, Fill, Text } from 'ol/style';
|
||
import { LineString, Polygon as PolygonGeom } from 'ol/geom';
|
||
import { mergePolygons } from '../geom/polygonMerge.js';
|
||
import { showToast } from '../toast.js';
|
||
|
||
// ── Styles ───────────────────────────────────────────────────────────────────
|
||
|
||
const HIGHLIGHT_A = new Style({
|
||
stroke: new Stroke({ color: '#0ea5e9', width: 3 }),
|
||
fill: new Fill({ color: 'rgba(14,165,233,0.15)' }),
|
||
});
|
||
|
||
const HIGHLIGHT_B = new Style({
|
||
stroke: new Stroke({ color: '#f59e0b', width: 3 }),
|
||
fill: new Fill({ color: 'rgba(245,158,11,0.15)' }),
|
||
});
|
||
|
||
// Labelled versions for permanent highlights (shown after selection)
|
||
const LABEL_A = new Style({
|
||
stroke: new Stroke({ color: '#0ea5e9', width: 3 }),
|
||
fill: new Fill({ color: 'rgba(14,165,233,0.15)' }),
|
||
text: new Text({
|
||
text: 'A',
|
||
font: 'bold 22px Exo, sans-serif',
|
||
fill: new Fill({ color: '#0ea5e9' }),
|
||
stroke: new Stroke({ color: '#fff', width: 4 }),
|
||
overflow: true,
|
||
}),
|
||
});
|
||
|
||
const LABEL_B = new Style({
|
||
stroke: new Stroke({ color: '#f59e0b', width: 3 }),
|
||
fill: new Fill({ color: 'rgba(245,158,11,0.15)' }),
|
||
text: new Text({
|
||
text: 'B',
|
||
font: 'bold 22px Exo, sans-serif',
|
||
fill: new Fill({ color: '#f59e0b' }),
|
||
stroke: new Stroke({ color: '#fff', width: 4 }),
|
||
overflow: true,
|
||
}),
|
||
});
|
||
|
||
const EDGE_STYLE = new Style({
|
||
stroke: new Stroke({ color: '#ec4899', width: 4, lineDash: [10, 6] }),
|
||
});
|
||
|
||
const MERGE_STYLE = new Style({
|
||
stroke: new Stroke({ color: '#10b981', width: 2.5 }),
|
||
fill: new Fill({ color: 'rgba(16,185,129,0.3)' }),
|
||
});
|
||
|
||
// ── Interaction ──────────────────────────────────────────────────────────────
|
||
|
||
export class PolygonMergeInteraction extends ol_interaction_Interaction {
|
||
/**
|
||
* @param {Object} [options]
|
||
* @param {number} [options.snapDistance=25] Pixel distance for hover detection.
|
||
* @param {number} [options.tolerance=5] Map-unit tolerance for shared-edge matching.
|
||
*/
|
||
constructor(options = {}) {
|
||
super({
|
||
handleEvent: (e) => this._handleEvent(e),
|
||
});
|
||
|
||
this.snapDistance_ = options.snapDistance || 25;
|
||
this.tolerance_ = options.tolerance || 5;
|
||
|
||
// Phase: 'select_a' | 'select_b' | 'click_edge_a' | 'click_edge_b'
|
||
this._phase = 'select_a';
|
||
|
||
// Selected features and their sources
|
||
this._featureA = null;
|
||
this._sourceA = null;
|
||
this._featureB = null;
|
||
this._sourceB = null;
|
||
|
||
// Clicked edge coordinates (map coords)
|
||
this._edgeClickA = null;
|
||
this._edgeClickB = null;
|
||
|
||
// Overlay for polygon highlights
|
||
this._highlightSource = new VectorSource({ useSpatialIndex: false });
|
||
this._highlightLayer = new VectorLayer({
|
||
source: this._highlightSource,
|
||
displayInLayerSwitcher: false,
|
||
style: (f) => f.get('_highlightStyle') || HIGHLIGHT_A,
|
||
});
|
||
|
||
// Overlay for edge highlights
|
||
this._edgeSource = new VectorSource({ useSpatialIndex: false });
|
||
this._edgeLayer = new VectorLayer({
|
||
source: this._edgeSource,
|
||
displayInLayerSwitcher: false,
|
||
style: EDGE_STYLE,
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Map lifecycle */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
setMap(map) {
|
||
if (this.getMap()) {
|
||
this.getMap().removeLayer(this._highlightLayer);
|
||
this.getMap().removeLayer(this._edgeLayer);
|
||
}
|
||
super.setMap(map);
|
||
if (map) {
|
||
this._highlightLayer.setMap(map);
|
||
this._edgeLayer.setMap(map);
|
||
}
|
||
}
|
||
|
||
setActive(active) {
|
||
super.setActive(active);
|
||
if (!active) this._reset();
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Source helpers */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
_getSources() {
|
||
if (!this.getMap()) return [];
|
||
const sources = [];
|
||
const collect = (layers) => {
|
||
layers.forEach((layer) => {
|
||
if (layer.getVisible()) {
|
||
if (layer.getSource && layer.getSource() instanceof VectorSource) {
|
||
sources.push(layer.getSource());
|
||
} else if (layer.getLayers) {
|
||
collect(layer.getLayers());
|
||
}
|
||
}
|
||
});
|
||
};
|
||
collect(this.getMap().getLayers());
|
||
return sources;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Event router */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
_handleEvent(e) {
|
||
if (!this.getActive()) return true;
|
||
|
||
// Escape cancels at any phase
|
||
if (e.type === 'keydown' && e.originalEvent?.key === 'Escape') {
|
||
this._reset();
|
||
return false;
|
||
}
|
||
|
||
switch (this._phase) {
|
||
case 'select_a':
|
||
if (e.type === 'pointermove') return this._onSelectMove(e, null);
|
||
if (e.type === 'singleclick') return this._onSelectAClick(e);
|
||
break;
|
||
case 'select_b':
|
||
if (e.type === 'pointermove') return this._onSelectMove(e, this._featureA);
|
||
if (e.type === 'singleclick') return this._onSelectBClick(e);
|
||
break;
|
||
case 'click_edge_a':
|
||
if (e.type === 'pointermove') return this._onEdgeMove(e, this._featureA);
|
||
if (e.type === 'singleclick') return this._onEdgeAClick(e);
|
||
break;
|
||
case 'click_edge_b':
|
||
if (e.type === 'pointermove') return this._onEdgeMove(e, this._featureB);
|
||
if (e.type === 'singleclick') return this._onEdgeBClick(e);
|
||
break;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Phase 1 & 2: SELECT polygons */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
_onSelectMove(e, skipFeature) {
|
||
const map = this.getMap();
|
||
if (!map) return true;
|
||
|
||
// Keep existing highlights for already-selected polygons
|
||
this._highlightSource.clear();
|
||
this._edgeSource.clear();
|
||
this._rebuildHighlights();
|
||
|
||
const hit = this._closestPolygon(e, skipFeature);
|
||
if (hit) {
|
||
const style = this._phase === 'select_a' ? HIGHLIGHT_A : HIGHLIGHT_B;
|
||
const clone = hit.feature.clone();
|
||
clone.set('_highlightStyle', style);
|
||
this._highlightSource.addFeature(clone);
|
||
map.getTargetElement().style.cursor = 'pointer';
|
||
} else {
|
||
map.getTargetElement().style.cursor = '';
|
||
}
|
||
return true;
|
||
}
|
||
|
||
_onSelectAClick(e) {
|
||
const hit = this._closestPolygon(e, null);
|
||
if (!hit) return true;
|
||
|
||
this._featureA = hit.feature;
|
||
this._sourceA = hit.source;
|
||
this._phase = 'select_b';
|
||
|
||
this._rebuildHighlights();
|
||
return false;
|
||
}
|
||
|
||
_onSelectBClick(e) {
|
||
const hit = this._closestPolygon(e, this._featureA);
|
||
if (!hit) return true;
|
||
|
||
this._featureB = hit.feature;
|
||
this._sourceB = hit.source;
|
||
this._phase = 'click_edge_a';
|
||
|
||
this._rebuildHighlights();
|
||
this.getMap().getTargetElement().style.cursor = 'crosshair';
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Find the closest polygon feature within snap distance.
|
||
* Optionally skip a feature (used in phase 2 to avoid re-selecting A).
|
||
*/
|
||
_closestPolygon(e, skipFeature) {
|
||
let best = null;
|
||
let bestDist = this.snapDistance_ + 1;
|
||
|
||
for (const source of this._getSources()) {
|
||
const feat = source.getClosestFeatureToCoordinate(e.coordinate);
|
||
if (!feat) continue;
|
||
if (skipFeature && feat === skipFeature) continue;
|
||
const geom = feat.getGeometry();
|
||
if (!geom) continue;
|
||
const type = geom.getType();
|
||
if (type !== 'Polygon' && type !== 'MultiPolygon') continue;
|
||
|
||
const closest = geom.getClosestPoint(e.coordinate);
|
||
const line = new LineString([e.coordinate, closest]);
|
||
const distPx = line.getLength() / e.frameState.viewState.resolution;
|
||
|
||
if (distPx < bestDist) {
|
||
bestDist = distPx;
|
||
best = { feature: feat, source, coord: closest };
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Phase 3 & 4: CLICK edges */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
_onEdgeMove(e, feature) {
|
||
const map = this.getMap();
|
||
if (!map) return true;
|
||
|
||
this._edgeSource.clear();
|
||
|
||
const edge = this._closestEdgeSegment(feature, e);
|
||
if (edge) {
|
||
const edgeFeat = new Feature(new LineString([edge.segStart, edge.segEnd]));
|
||
this._edgeSource.addFeature(edgeFeat);
|
||
map.getTargetElement().style.cursor = 'crosshair';
|
||
}
|
||
return true;
|
||
}
|
||
|
||
_onEdgeAClick(e) {
|
||
this._edgeClickA = e.coordinate;
|
||
this._phase = 'click_edge_b';
|
||
this._edgeSource.clear();
|
||
return false;
|
||
}
|
||
|
||
_onEdgeBClick(e) {
|
||
this._edgeClickB = e.coordinate;
|
||
this._performMerge();
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Find the closest edge segment of a polygon feature to the cursor.
|
||
*/
|
||
_closestEdgeSegment(feature, e) {
|
||
const geom = feature.getGeometry();
|
||
let ring;
|
||
if (geom.getType() === 'Polygon') {
|
||
ring = geom.getCoordinates()[0];
|
||
} else if (geom.getType() === 'MultiPolygon') {
|
||
ring = geom.getCoordinates()[0][0];
|
||
} else {
|
||
return null;
|
||
}
|
||
|
||
const resolution = e.frameState.viewState.resolution;
|
||
let bestDist = Infinity;
|
||
let bestSeg = null;
|
||
const n = ring.length - 1;
|
||
|
||
for (let i = 0; i < n; i++) {
|
||
const a = ring[i];
|
||
const b = ring[i + 1];
|
||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const lenSq = dx * dx + dy * dy;
|
||
if (lenSq < 1e-20) continue;
|
||
|
||
let t = ((e.coordinate[0] - a[0]) * dx + (e.coordinate[1] - a[1]) * dy) / lenSq;
|
||
t = Math.max(0, Math.min(1, t));
|
||
const projX = a[0] + t * dx, projY = a[1] + t * dy;
|
||
const distPx = Math.sqrt((e.coordinate[0] - projX) ** 2 + (e.coordinate[1] - projY) ** 2) / resolution;
|
||
|
||
if (distPx < bestDist) {
|
||
bestDist = distPx;
|
||
bestSeg = { segStart: a, segEnd: b };
|
||
}
|
||
}
|
||
return bestDist <= this.snapDistance_ ? bestSeg : null;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Merge logic */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
_performMerge() {
|
||
const featureA = this._featureA;
|
||
const featureB = this._featureB;
|
||
const sourceA = this._sourceA;
|
||
const sourceB = this._sourceB;
|
||
|
||
// Extract polygon coordinates
|
||
const geomA = featureA.getGeometry();
|
||
const geomB = featureB.getGeometry();
|
||
const coordsA = geomA.getType() === 'Polygon' ? geomA.getCoordinates() : geomA.getCoordinates()[0];
|
||
const coordsB = geomB.getType() === 'Polygon' ? geomB.getCoordinates() : geomB.getCoordinates()[0];
|
||
|
||
const result = mergePolygons(coordsA, coordsB, this._edgeClickA, this._edgeClickB, this.tolerance_);
|
||
|
||
if (!result.coords) {
|
||
showToast(result.error || 'Merge failed — try clicking on the shared boundary.', 'error', 5000);
|
||
// Return to edge click phase for retry
|
||
this._edgeClickA = null;
|
||
this._edgeClickB = null;
|
||
this._phase = 'click_edge_a';
|
||
this._edgeSource.clear();
|
||
return;
|
||
}
|
||
|
||
// Create merged feature (clone A for default properties)
|
||
const mergedFeature = featureA.clone();
|
||
mergedFeature.setGeometry(new PolygonGeom(result.coords));
|
||
mergedFeature.setStyle(MERGE_STYLE);
|
||
|
||
// Dispatch beforemerge events
|
||
const evtData = {
|
||
type: 'beforemerge',
|
||
original: [featureA, featureB],
|
||
merged: mergedFeature,
|
||
};
|
||
this.dispatchEvent(evtData);
|
||
sourceA.dispatchEvent({ ...evtData });
|
||
if (sourceB !== sourceA) {
|
||
sourceB.dispatchEvent({ ...evtData });
|
||
}
|
||
|
||
// Replace originals with merged
|
||
sourceA.removeFeature(featureA);
|
||
sourceB.removeFeature(featureB);
|
||
sourceA.addFeature(mergedFeature);
|
||
|
||
// Dispatch aftermerge events
|
||
const afterEvt = {
|
||
type: 'aftermerge',
|
||
original: [featureA, featureB],
|
||
merged: mergedFeature,
|
||
};
|
||
this.dispatchEvent(afterEvt);
|
||
sourceA.dispatchEvent({ ...afterEvt });
|
||
if (sourceB !== sourceA) {
|
||
sourceB.dispatchEvent({ ...afterEvt });
|
||
}
|
||
|
||
// If both features were parcels, fire mergedparcel so MapView can show the UPN chooser
|
||
const isParcelA = featureA.get('_layerType') === 'parcel';
|
||
const isParcelB = featureB.get('_layerType') === 'parcel';
|
||
if (isParcelA && isParcelB) {
|
||
this.dispatchEvent({
|
||
type: 'mergedparcel',
|
||
merged: mergedFeature,
|
||
propsA: featureA.getProperties(),
|
||
propsB: featureB.getProperties(),
|
||
coordinate: this._edgeClickA,
|
||
});
|
||
showToast('Polygons merged — choose which identifier to keep.', 'success');
|
||
} else {
|
||
showToast('Polygons merged successfully.', 'success');
|
||
}
|
||
|
||
// Clean up
|
||
this._reset();
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Highlight management */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* Rebuild the permanent highlights for already-selected polygons.
|
||
*/
|
||
_rebuildHighlights() {
|
||
// Remove previous non-hover highlights
|
||
const toRemove = [];
|
||
this._highlightSource.getFeatures().forEach((f) => {
|
||
if (f.get('_permanent')) toRemove.push(f);
|
||
});
|
||
toRemove.forEach((f) => this._highlightSource.removeFeature(f));
|
||
|
||
if (this._featureA) {
|
||
const cloneA = this._featureA.clone();
|
||
cloneA.set('_highlightStyle', LABEL_A);
|
||
cloneA.set('_permanent', true);
|
||
this._highlightSource.addFeature(cloneA);
|
||
}
|
||
if (this._featureB) {
|
||
const cloneB = this._featureB.clone();
|
||
cloneB.set('_highlightStyle', LABEL_B);
|
||
cloneB.set('_permanent', true);
|
||
this._highlightSource.addFeature(cloneB);
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Reset */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
_reset() {
|
||
this._phase = 'select_a';
|
||
this._featureA = null;
|
||
this._sourceA = null;
|
||
this._featureB = null;
|
||
this._sourceB = null;
|
||
this._edgeClickA = null;
|
||
this._edgeClickB = null;
|
||
this._highlightSource.clear();
|
||
this._edgeSource.clear();
|
||
|
||
const map = this.getMap();
|
||
if (map) {
|
||
map.getTargetElement().style.cursor = '';
|
||
}
|
||
}
|
||
}
|