/** * PolygonMergeInteraction * * A four-phase OpenLayers interaction for merging two adjacent polygons: * Phase 1 – SELECT_A: hover to highlight, click to select polygon A * Phase 2 – SELECT_B: hover to highlight, click to select polygon B * Phase 3 – CLICK_EDGE_A: hover highlights edge, click to pick shared edge on A * Phase 4 – CLICK_EDGE_B: hover highlights edge, click to pick shared edge on B → merge * * After a successful merge the two original features are removed and one * merged feature (coloured green) is added. If both originals were parcels, * a `mergedparcel` event is fired so external code can present a UPN chooser. */ import ol_interaction_Interaction from 'ol/interaction/Interaction'; import VectorSource from 'ol/source/Vector'; import VectorLayer from 'ol/layer/Vector'; import Feature from 'ol/Feature'; import { Style, Stroke, Fill, Text } from 'ol/style'; import { LineString, Polygon as PolygonGeom } from 'ol/geom'; import { mergePolygons } from '../geom/polygonMerge.js'; import { showToast } from '../toast.js'; // ── Styles ─────────────────────────────────────────────────────────────────── const HIGHLIGHT_A = new Style({ stroke: new Stroke({ color: '#0ea5e9', width: 3 }), fill: new Fill({ color: 'rgba(14,165,233,0.15)' }), }); const HIGHLIGHT_B = new Style({ stroke: new Stroke({ color: '#f59e0b', width: 3 }), fill: new Fill({ color: 'rgba(245,158,11,0.15)' }), }); // Labelled versions for permanent highlights (shown after selection) const LABEL_A = new Style({ stroke: new Stroke({ color: '#0ea5e9', width: 3 }), fill: new Fill({ color: 'rgba(14,165,233,0.15)' }), text: new Text({ text: 'A', font: 'bold 22px Exo, sans-serif', fill: new Fill({ color: '#0ea5e9' }), stroke: new Stroke({ color: '#fff', width: 4 }), overflow: true, }), }); const LABEL_B = new Style({ stroke: new Stroke({ color: '#f59e0b', width: 3 }), fill: new Fill({ color: 'rgba(245,158,11,0.15)' }), text: new Text({ text: 'B', font: 'bold 22px Exo, sans-serif', fill: new Fill({ color: '#f59e0b' }), stroke: new Stroke({ color: '#fff', width: 4 }), overflow: true, }), }); const EDGE_STYLE = new Style({ stroke: new Stroke({ color: '#ec4899', width: 4, lineDash: [10, 6] }), }); const MERGE_STYLE = new Style({ stroke: new Stroke({ color: '#10b981', width: 2.5 }), fill: new Fill({ color: 'rgba(16,185,129,0.3)' }), }); // ── Interaction ────────────────────────────────────────────────────────────── export class PolygonMergeInteraction extends ol_interaction_Interaction { /** * @param {Object} [options] * @param {number} [options.snapDistance=25] Pixel distance for hover detection. * @param {number} [options.tolerance=5] Map-unit tolerance for shared-edge matching. */ constructor(options = {}) { super({ handleEvent: (e) => this._handleEvent(e), }); this.snapDistance_ = options.snapDistance || 25; this.tolerance_ = options.tolerance || 5; // Phase: 'select_a' | 'select_b' | 'click_edge_a' | 'click_edge_b' this._phase = 'select_a'; // Selected features and their sources this._featureA = null; this._sourceA = null; this._featureB = null; this._sourceB = null; // Clicked edge coordinates (map coords) this._edgeClickA = null; this._edgeClickB = null; // Overlay for polygon highlights this._highlightSource = new VectorSource({ useSpatialIndex: false }); this._highlightLayer = new VectorLayer({ source: this._highlightSource, displayInLayerSwitcher: false, style: (f) => f.get('_highlightStyle') || HIGHLIGHT_A, }); // Overlay for edge highlights this._edgeSource = new VectorSource({ useSpatialIndex: false }); this._edgeLayer = new VectorLayer({ source: this._edgeSource, displayInLayerSwitcher: false, style: EDGE_STYLE, }); } /* ------------------------------------------------------------------ */ /* Map lifecycle */ /* ------------------------------------------------------------------ */ setMap(map) { if (this.getMap()) { this.getMap().removeLayer(this._highlightLayer); this.getMap().removeLayer(this._edgeLayer); } super.setMap(map); if (map) { this._highlightLayer.setMap(map); this._edgeLayer.setMap(map); } } setActive(active) { super.setActive(active); if (!active) this._reset(); } /* ------------------------------------------------------------------ */ /* Source helpers */ /* ------------------------------------------------------------------ */ _getSources() { 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; // Escape cancels at any phase if (e.type === 'keydown' && e.originalEvent?.key === 'Escape') { this._reset(); return false; } switch (this._phase) { case 'select_a': if (e.type === 'pointermove') return this._onSelectMove(e, null); if (e.type === 'singleclick') return this._onSelectAClick(e); break; case 'select_b': if (e.type === 'pointermove') return this._onSelectMove(e, this._featureA); if (e.type === 'singleclick') return this._onSelectBClick(e); break; case 'click_edge_a': if (e.type === 'pointermove') return this._onEdgeMove(e, this._featureA); if (e.type === 'singleclick') return this._onEdgeAClick(e); break; case 'click_edge_b': if (e.type === 'pointermove') return this._onEdgeMove(e, this._featureB); if (e.type === 'singleclick') return this._onEdgeBClick(e); break; } return true; } /* ------------------------------------------------------------------ */ /* Phase 1 & 2: SELECT polygons */ /* ------------------------------------------------------------------ */ _onSelectMove(e, skipFeature) { const map = this.getMap(); if (!map) return true; // Keep existing highlights for already-selected polygons this._highlightSource.clear(); this._edgeSource.clear(); this._rebuildHighlights(); const hit = this._closestPolygon(e, skipFeature); if (hit) { const style = this._phase === 'select_a' ? HIGHLIGHT_A : HIGHLIGHT_B; const clone = hit.feature.clone(); clone.set('_highlightStyle', style); this._highlightSource.addFeature(clone); map.getTargetElement().style.cursor = 'pointer'; } else { map.getTargetElement().style.cursor = ''; } return true; } _onSelectAClick(e) { const hit = this._closestPolygon(e, null); if (!hit) return true; this._featureA = hit.feature; this._sourceA = hit.source; this._phase = 'select_b'; this._rebuildHighlights(); return false; } _onSelectBClick(e) { const hit = this._closestPolygon(e, this._featureA); if (!hit) return true; this._featureB = hit.feature; this._sourceB = hit.source; this._phase = 'click_edge_a'; this._rebuildHighlights(); this.getMap().getTargetElement().style.cursor = 'crosshair'; return false; } /** * Find the closest polygon feature within snap distance. * Optionally skip a feature (used in phase 2 to avoid re-selecting A). */ _closestPolygon(e, skipFeature) { let best = null; let bestDist = this.snapDistance_ + 1; for (const source of this._getSources()) { const feat = source.getClosestFeatureToCoordinate(e.coordinate); if (!feat) continue; if (skipFeature && feat === skipFeature) 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 3 & 4: CLICK edges */ /* ------------------------------------------------------------------ */ _onEdgeMove(e, feature) { const map = this.getMap(); if (!map) return true; this._edgeSource.clear(); const edge = this._closestEdgeSegment(feature, e); if (edge) { const edgeFeat = new Feature(new LineString([edge.segStart, edge.segEnd])); this._edgeSource.addFeature(edgeFeat); map.getTargetElement().style.cursor = 'crosshair'; } return true; } _onEdgeAClick(e) { this._edgeClickA = e.coordinate; this._phase = 'click_edge_b'; this._edgeSource.clear(); return false; } _onEdgeBClick(e) { this._edgeClickB = e.coordinate; this._performMerge(); return false; } /** * Find the closest edge segment of a polygon feature to the cursor. */ _closestEdgeSegment(feature, e) { const geom = feature.getGeometry(); let ring; if (geom.getType() === 'Polygon') { ring = geom.getCoordinates()[0]; } else if (geom.getType() === 'MultiPolygon') { ring = geom.getCoordinates()[0][0]; } else { return null; } const resolution = e.frameState.viewState.resolution; let bestDist = Infinity; let bestSeg = null; const n = ring.length - 1; for (let i = 0; i < n; i++) { const a = ring[i]; const b = ring[i + 1]; const dx = b[0] - a[0], dy = b[1] - a[1]; const lenSq = dx * dx + dy * dy; if (lenSq < 1e-20) continue; let t = ((e.coordinate[0] - a[0]) * dx + (e.coordinate[1] - a[1]) * dy) / lenSq; t = Math.max(0, Math.min(1, t)); const projX = a[0] + t * dx, projY = a[1] + t * dy; const distPx = Math.sqrt((e.coordinate[0] - projX) ** 2 + (e.coordinate[1] - projY) ** 2) / resolution; if (distPx < bestDist) { bestDist = distPx; bestSeg = { segStart: a, segEnd: b }; } } return bestDist <= this.snapDistance_ ? bestSeg : null; } /* ------------------------------------------------------------------ */ /* Merge logic */ /* ------------------------------------------------------------------ */ _performMerge() { const featureA = this._featureA; const featureB = this._featureB; const sourceA = this._sourceA; const sourceB = this._sourceB; // Extract polygon coordinates const geomA = featureA.getGeometry(); const geomB = featureB.getGeometry(); const coordsA = geomA.getType() === 'Polygon' ? geomA.getCoordinates() : geomA.getCoordinates()[0]; const coordsB = geomB.getType() === 'Polygon' ? geomB.getCoordinates() : geomB.getCoordinates()[0]; const result = mergePolygons(coordsA, coordsB, this._edgeClickA, this._edgeClickB, this.tolerance_); if (!result.coords) { showToast(result.error || 'Merge failed — try clicking on the shared boundary.', 'error', 5000); // Return to edge click phase for retry this._edgeClickA = null; this._edgeClickB = null; this._phase = 'click_edge_a'; this._edgeSource.clear(); return; } // Create merged feature (clone A for default properties) const mergedFeature = featureA.clone(); mergedFeature.setGeometry(new PolygonGeom(result.coords)); mergedFeature.setStyle(MERGE_STYLE); // Dispatch beforemerge events const evtData = { type: 'beforemerge', original: [featureA, featureB], merged: mergedFeature, }; this.dispatchEvent(evtData); sourceA.dispatchEvent({ ...evtData }); if (sourceB !== sourceA) { sourceB.dispatchEvent({ ...evtData }); } // Replace originals with merged sourceA.removeFeature(featureA); sourceB.removeFeature(featureB); sourceA.addFeature(mergedFeature); // Dispatch aftermerge events const afterEvt = { type: 'aftermerge', original: [featureA, featureB], merged: mergedFeature, }; this.dispatchEvent(afterEvt); sourceA.dispatchEvent({ ...afterEvt }); if (sourceB !== sourceA) { sourceB.dispatchEvent({ ...afterEvt }); } // If both features were parcels, fire mergedparcel so MapView can show the UPN chooser const isParcelA = featureA.get('_layerType') === 'parcel'; const isParcelB = featureB.get('_layerType') === 'parcel'; if (isParcelA && isParcelB) { this.dispatchEvent({ type: 'mergedparcel', merged: mergedFeature, propsA: featureA.getProperties(), propsB: featureB.getProperties(), coordinate: this._edgeClickA, }); showToast('Polygons merged — choose which identifier to keep.', 'success'); } else { showToast('Polygons merged successfully.', 'success'); } // Clean up this._reset(); } /* ------------------------------------------------------------------ */ /* Highlight management */ /* ------------------------------------------------------------------ */ /** * Rebuild the permanent highlights for already-selected polygons. */ _rebuildHighlights() { // Remove previous non-hover highlights const toRemove = []; this._highlightSource.getFeatures().forEach((f) => { if (f.get('_permanent')) toRemove.push(f); }); toRemove.forEach((f) => this._highlightSource.removeFeature(f)); if (this._featureA) { const cloneA = this._featureA.clone(); cloneA.set('_highlightStyle', LABEL_A); cloneA.set('_permanent', true); this._highlightSource.addFeature(cloneA); } if (this._featureB) { const cloneB = this._featureB.clone(); cloneB.set('_highlightStyle', LABEL_B); cloneB.set('_permanent', true); this._highlightSource.addFeature(cloneB); } } /* ------------------------------------------------------------------ */ /* Reset */ /* ------------------------------------------------------------------ */ _reset() { this._phase = 'select_a'; this._featureA = null; this._sourceA = null; this._featureB = null; this._sourceB = null; this._edgeClickA = null; this._edgeClickB = null; this._highlightSource.clear(); this._edgeSource.clear(); const map = this.getMap(); if (map) { map.getTargetElement().style.cursor = ''; } } }