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

434 lines
13 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.

/**
* PolygonSplitInteraction
*
* A two-phase OpenLayers interaction for splitting polygons:
* Phase 1 SELECT: hover to highlight, click to select a polygon
* Phase 2 DRAW: draw a cutting line, double-click to finish
*
* After a successful split the original feature is removed and two new
* coloured features are added. The interaction fires `beforesplit` and
* `aftersplit` events compatible with ol-ext's UndoRedo.
*/
import ol_interaction_Interaction from 'ol/interaction/Interaction';
import ol_interaction_Draw from 'ol/interaction/Draw';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import Feature from 'ol/Feature';
import { Style, Stroke, Fill, Circle as CircleStyle } from 'ol/style';
import { LineString } from 'ol/geom';
import { Polygon as PolygonGeom } from 'ol/geom';
import { splitPolygonByLine } from '../geom/polygonSplit.js';
import { showToast } from '../toast.js';
// Marker colours for the two split pieces
const SPLIT_COLORS = [
{ stroke: '#ef4444', fill: 'rgba(239,68,68,0.25)' }, // red
{ stroke: '#3b82f6', fill: 'rgba(59,130,246,0.25)' }, // blue
];
// Highlight style for the selected polygon (phase 1)
const HIGHLIGHT_STYLE = new Style({
stroke: new Stroke({ color: '#0ea5e9', width: 3 }),
fill: new Fill({ color: 'rgba(14,165,233,0.15)' }),
});
// Style for the cutting-line sketch (phase 2)
const SKETCH_STYLE = new Style({
stroke: new Stroke({ color: '#f43f5e', width: 2, lineDash: [8, 6] }),
image: new CircleStyle({
radius: 5,
fill: new Fill({ color: '#f43f5e' }),
stroke: new Stroke({ color: '#fff', width: 1.5 }),
}),
});
export class PolygonSplitInteraction extends ol_interaction_Interaction {
/**
* @param {Object} options
* @param {VectorSource|VectorSource[]} [options.sources] Sources containing
* polygons to split. If omitted the interaction searches all visible
* vector layers on the map.
* @param {number} [options.snapDistance=25] Pixel distance for hover highlight.
*/
constructor(options = {}) {
super({
handleEvent: (e) => this._handleEvent(e),
});
this.snapDistance_ = options.snapDistance || 25;
this._sources = options.sources
? (Array.isArray(options.sources) ? options.sources : [options.sources])
: null;
// Phase: 'select' | 'draw' | 'pick'
this._phase = 'select';
this._selectedFeature = null;
this._selectedSource = null;
this._drawInteraction = null;
this._splitFeatures = null; // the two pieces (for pick phase)
// Overlay layer for highlighting the polygon under the cursor / selected
this._overlaySource = new VectorSource({ useSpatialIndex: false });
this._overlayLayer = new VectorLayer({
source: this._overlaySource,
displayInLayerSwitcher: false,
style: HIGHLIGHT_STYLE,
});
}
/* ------------------------------------------------------------------ */
/* Map lifecycle */
/* ------------------------------------------------------------------ */
setMap(map) {
if (this.getMap()) {
this.getMap().removeLayer(this._overlayLayer);
this._removeDrawInteraction();
}
super.setMap(map);
if (map) {
this._overlayLayer.setMap(map);
}
}
setActive(active) {
super.setActive(active);
if (!active) {
this._reset();
}
}
/* ------------------------------------------------------------------ */
/* Source helpers */
/* ------------------------------------------------------------------ */
_getSources() {
if (this._sources) return this._sources;
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;
if (this._phase === 'select') {
if (e.type === 'pointermove') return this._onSelectMove(e);
if (e.type === 'singleclick') return this._onSelectClick(e);
}
// In 'draw' phase the Draw interaction handles events directly;
// we only intercept Escape to cancel.
if (this._phase === 'draw') {
if (e.type === 'keydown' && e.originalEvent?.key === 'Escape') {
this._cancelDraw();
return false;
}
}
// In 'pick' phase the user selects which split piece keeps the UPN
if (this._phase === 'pick') {
if (e.type === 'pointermove') return this._onPickMove(e);
if (e.type === 'singleclick') return this._onPickClick(e);
if (e.type === 'keydown' && e.originalEvent?.key === 'Escape') {
this._reset();
return false;
}
}
return true;
}
/* ------------------------------------------------------------------ */
/* Phase 1: SELECT */
/* ------------------------------------------------------------------ */
_onSelectMove(e) {
const map = this.getMap();
if (!map) return true;
this._overlaySource.clear();
const hit = this._closestPolygon(e);
if (hit) {
// Show highlight copy
const clone = hit.feature.clone();
this._overlaySource.addFeature(clone);
map.getTargetElement().style.cursor = 'pointer';
} else {
map.getTargetElement().style.cursor = '';
}
return true;
}
_onSelectClick(e) {
const hit = this._closestPolygon(e);
if (!hit) return true;
this._selectedFeature = hit.feature;
this._selectedSource = hit.source;
// Keep highlight visible during draw phase
this._overlaySource.clear();
const clone = hit.feature.clone();
this._overlaySource.addFeature(clone);
this._startDrawPhase();
return false; // consume the click
}
/**
* Find the closest polygon feature within snap distance.
*/
_closestPolygon(e) {
let best = null;
let bestDist = this.snapDistance_ + 1;
for (const source of this._getSources()) {
const feat = source.getClosestFeatureToCoordinate(e.coordinate);
if (!feat) 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 2: DRAW cutting line */
/* ------------------------------------------------------------------ */
_startDrawPhase() {
this._phase = 'draw';
const map = this.getMap();
if (!map) return;
map.getTargetElement().style.cursor = 'crosshair';
this._drawInteraction = new ol_interaction_Draw({
type: 'LineString',
style: SKETCH_STYLE,
});
this._drawInteraction.on('drawend', (evt) => {
const cuttingLine = evt.feature.getGeometry().getCoordinates();
this._performSplit(cuttingLine);
});
map.addInteraction(this._drawInteraction);
}
_removeDrawInteraction() {
if (this._drawInteraction && this.getMap()) {
this.getMap().removeInteraction(this._drawInteraction);
}
this._drawInteraction = null;
}
_cancelDraw() {
this._removeDrawInteraction();
this._reset();
}
/* ------------------------------------------------------------------ */
/* Split logic */
/* ------------------------------------------------------------------ */
_performSplit(cuttingLineCoords) {
const feature = this._selectedFeature;
const source = this._selectedSource;
const geom = feature.getGeometry();
let polygonCoords;
if (geom.getType() === 'Polygon') {
polygonCoords = geom.getCoordinates();
} else if (geom.getType() === 'MultiPolygon') {
// For MultiPolygon, try to split each sub-polygon and use the
// first one that produces a valid result.
// For now, use the first polygon ring.
polygonCoords = geom.getCoordinates()[0];
}
const result = splitPolygonByLine(polygonCoords, cuttingLineCoords);
if (!result) {
console.warn('[PolygonSplit] Split failed — line must cross the polygon boundary at exactly 2 points.');
// Stay in draw phase so user can retry
this._removeDrawInteraction();
this._startDrawPhase();
return;
}
const [coordsA, coordsB] = result;
// Create two new features from the split result
const featureA = feature.clone();
featureA.setGeometry(new PolygonGeom(coordsA));
featureA.setStyle(new Style({
stroke: new Stroke({ color: SPLIT_COLORS[0].stroke, width: 2.5 }),
fill: new Fill({ color: SPLIT_COLORS[0].fill }),
}));
const featureB = feature.clone();
featureB.setGeometry(new PolygonGeom(coordsB));
featureB.setStyle(new Style({
stroke: new Stroke({ color: SPLIT_COLORS[1].stroke, width: 2.5 }),
fill: new Fill({ color: SPLIT_COLORS[1].fill }),
}));
// Dispatch beforesplit (compatible with ol-ext UndoRedo)
const splitFeatures = [featureA, featureB];
this.dispatchEvent({
type: 'beforesplit',
original: feature,
features: splitFeatures,
});
source.dispatchEvent({
type: 'beforesplit',
original: feature,
features: splitFeatures,
});
// Replace the original feature
source.removeFeature(feature);
source.addFeature(featureA);
source.addFeature(featureB);
// Dispatch aftersplit
this.dispatchEvent({
type: 'aftersplit',
original: feature,
features: splitFeatures,
});
source.dispatchEvent({
type: 'aftersplit',
original: feature,
features: splitFeatures,
});
// Clean up draw interaction
this._removeDrawInteraction();
// If the original was a parcel, enter pick phase for UPN assignment
const isParcel = feature.get('_layerType') === 'parcel';
if (isParcel) {
this._splitFeatures = splitFeatures;
this._phase = 'pick';
this._overlaySource.clear();
const map = this.getMap();
if (map) map.getTargetElement().style.cursor = '';
showToast('Click the polygon that should keep the original identifier.', 'info', 5000);
this.dispatchEvent({
type: 'splitparcel',
features: splitFeatures,
originalProps: feature.getProperties(),
source,
});
} else {
this._reset();
}
}
/* ------------------------------------------------------------------ */
/* Phase 3: PICK — select which split piece keeps the UPN */
/* ------------------------------------------------------------------ */
_onPickMove(e) {
const map = this.getMap();
if (!map) return true;
this._overlaySource.clear();
const hit = this._closestSplitPiece(e);
if (hit) {
const clone = hit.clone();
this._overlaySource.addFeature(clone);
map.getTargetElement().style.cursor = 'pointer';
} else {
map.getTargetElement().style.cursor = '';
}
return true;
}
_onPickClick(e) {
const hit = this._closestSplitPiece(e);
if (!hit) return true;
this.dispatchEvent({
type: 'splitpick',
picked: hit,
features: this._splitFeatures,
});
this._reset();
return false;
}
/**
* Find the closest split piece to the cursor.
*/
_closestSplitPiece(e) {
if (!this._splitFeatures) return null;
let best = null;
let bestDist = this.snapDistance_ + 1;
for (const feat of this._splitFeatures) {
const geom = feat.getGeometry();
if (!geom) 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 = feat;
}
}
return best;
}
/* ------------------------------------------------------------------ */
/* Reset */
/* ------------------------------------------------------------------ */
_reset() {
this._phase = 'select';
this._selectedFeature = null;
this._selectedSource = null;
this._splitFeatures = null;
this._overlaySource.clear();
this._removeDrawInteraction();
const map = this.getMap();
if (map) {
map.getTargetElement().style.cursor = '';
}
}
}