pwaLUPMIS2/src/interactions/PolygonMergeInteraction.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

476 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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