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>
434 lines
13 KiB
JavaScript
434 lines
13 KiB
JavaScript
/**
|
||
* 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 = '';
|
||
}
|
||
}
|
||
}
|