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>
440 lines
14 KiB
JavaScript
440 lines
14 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. 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 = '';
|
||
}
|
||
}
|
||
}
|