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