pwaLUPMIS2/src/interactions/PolygonSplitInteraction.js
ekke 26c941b09a Analytical tools Stage 1 + drawing-tool fixes
Stage 1 of the GIS Analytical Tools concept — client-side spatial analysis:

- src/analysis/overlay.js: vector overlays (intersect / clip / difference /
  union-dissolve) on Turf.js, reprojecting to WGS84 and merging attributes
  (intersect keeps both layers', clip keeps only A's).
- src/analysis/zonal.js: vector-in-vector zonal statistics — count / sum /
  mean / min / max / total area per zone, with centroid-in-zone (default) or
  any-overlap membership.
- Bounding-box pre-filtering in both: only genuinely overlapping pairs reach
  the expensive geometry test. 43 zones x 25,004 parcels now completes in
  ~106 ms; previously it was refused as too large.
- src/analysis-modal.js + markup: Analysis panel with "Apply to" scoping —
  whole layer, current map view, selected features, or the catch of a drawn
  Circle/Area. Reached from a new "Analyse" dock button.
- MapView.addCOGLayer() for Cloud-Optimized GeoTIFF display (WebGLTile +
  GeoTIFF source, imported lazily); listVectorLayers(); getSelectedFeatures().
- Circle/Area analysis popup: one "Export" button (PDF folded into the export
  modal as a fourth format, field-rename table hidden for it) plus an
  "Analyse" button that opens the panel pre-scoped to the intersecting
  features.
- vite.config.js: code-split turf, geotiff and pako so the eager bundle is
  unchanged (~283 kB). Giving pako its own chunk also fixes a circular chunk
  between jspdf and geotiff, which share it via fast-png.

Drawing-tool fixes carried in the same working tree:

- Delete/Backspace key deletes the selection via the EditBar's Delete
  interaction (same undoable block as the button).
- Multi-select: shift-click toggles, Ctrl/Cmd-drag box-selects; helper layers
  (vertex overlay, GPS) excluded.
- Undo: split/merge/divide wrapped in undo blocks so one press reverses the
  whole operation; vertex-overlay churn no longer pollutes the undo stack;
  sources are re-scanned and the stack cleared when edit mode is entered, so
  deletes on sub-grouped layers are undoable. NOTE: the undo behaviour is not
  yet confirmed on-device.
- ol-ext TouchCursor gated to touch-only devices, so hybrid touchscreen
  laptops keep the normal cursor.

Service worker v12 -> v13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:09:06 +02:00

440 lines
14 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. Group the remove + two adds into a single
// UndoRedo block (via the map-level undoblockstart/undoblockend events the
// ol-ext UndoRedo interaction listens for) so the whole split is reversed
// by ONE undo press instead of three.
const splitMap = this.getMap();
splitMap?.dispatchEvent('undoblockstart');
source.removeFeature(feature);
source.addFeature(featureA);
source.addFeature(featureB);
splitMap?.dispatchEvent('undoblockend');
// 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 = '';
}
}
}