// deck.gl-community // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import lineIntersect from '@turf/line-intersect'; import {polygon as turfPolygon} from '@turf/helpers'; import booleanWithin from '@turf/boolean-within'; import type {Feature, Geometry, Polygon} from 'geojson'; import kinks from '@turf/kinks'; import { ClickEvent, PointerMoveEvent, ModeProps, GuideFeatureCollection, TentativeFeature, GuideFeature, DoubleClickEvent } from './types'; import {Position, FeatureCollection, SimpleFeatureCollection} from '../utils/geojson-types'; import {getPickedEditHandle} from './utils'; import {GeoJsonEditMode} from './geojson-edit-mode'; import {ImmutableFeatureCollection} from './immutable-feature-collection'; import { cartesianCoordinateSystem, CartesianCoordinateSystem, EditModeCoordinateSystem } from './coordinate-system'; import {polygonEdgesIntersect, polygonWithinPolygon} from './cartesian-utils'; export class DrawPolygonMode extends GeoJsonEditMode { holeSequence: Position[] = []; isDrawingHole = false; createTentativeFeature(props: ModeProps): TentativeFeature { const {lastPointerMoveEvent} = props; const clickSequence = this.getClickSequence(); const holeSequence = this.holeSequence; const lastCoords = lastPointerMoveEvent ? [lastPointerMoveEvent.mapCoords] : []; let geometry: Geometry; if (this.isDrawingHole && holeSequence.length > 1) { geometry = { type: 'Polygon', coordinates: [ [...clickSequence, clickSequence[0]], [...holeSequence, ...lastCoords, holeSequence[0]] ] }; } else if (clickSequence.length > 2) { geometry = { type: 'Polygon', coordinates: [[...clickSequence, ...lastCoords, clickSequence[0]]] }; } else { geometry = { type: 'LineString', coordinates: [...clickSequence, ...lastCoords] }; } return { type: 'Feature', properties: { guideType: 'tentative' }, geometry }; } getGuides(props: ModeProps): GuideFeatureCollection { const guides: GuideFeatureCollection = { type: 'FeatureCollection', features: [] }; const tentative = this.createTentativeFeature(props); if (tentative) guides.features.push(tentative); const sequence = this.isDrawingHole ? this.holeSequence : this.getClickSequence(); const handles: GuideFeature[] = sequence.map((coord, index) => ({ type: 'Feature', properties: { guideType: 'editHandle', editHandleType: 'existing', featureIndex: -1, positionIndexes: [index] }, geometry: { type: 'Point', coordinates: coord } })); guides.features.push(...handles); return guides; } // eslint-disable-next-line complexity, max-statements handleClick(event: ClickEvent, props: ModeProps) { const {picks} = event; const clickedEditHandle = getPickedEditHandle(picks); const clickSequence = this.getClickSequence(); const coords = event.mapCoords; // Check if they clicked on an edit handle to complete the polygon if ( !this.isDrawingHole && clickSequence.length > 2 && clickedEditHandle && Array.isArray(clickedEditHandle.properties.positionIndexes) && (clickedEditHandle.properties.positionIndexes[0] === 0 || clickedEditHandle.properties.positionIndexes[0] === clickSequence.length - 1) ) { // They clicked the first or last point, so complete the polygon this.finishDrawing(props); return; } // Check if they clicked near the first point to complete the polygon if (!this.isDrawingHole && clickSequence.length > 2) { if (isNearFirstPoint(coords, clickSequence[0])) { this.finishDrawing(props); this.resetClickSequence(); return; } } if (this.isDrawingHole) { const current = this.holeSequence; current.push(coords); if (current.length > 2) { const poly: Geometry = { type: 'Polygon', coordinates: [ [...clickSequence, clickSequence[0]], [...current, current[0]] ] }; this.resetClickSequence(); this.holeSequence = []; this.isDrawingHole = false; const editAction = this.getAddFeatureOrBooleanPolygonAction(poly, props); if (editAction) props.onEdit(editAction); } return; } // Add the click if we didn't click on a handle let positionAdded = false; if (!clickedEditHandle) { this.addClickSequence(event); positionAdded = true; } if (positionAdded) { // new tentative point props.onEdit({ // data is the same updatedData: props.data, editType: 'addTentativePosition', editContext: { position: event.mapCoords } }); } } handleDoubleClick(_event: DoubleClickEvent, props: ModeProps) { this.finishDrawing(props); this.resetClickSequence(); } handleKeyUp(event: KeyboardEvent, props: ModeProps) { if (event.key === 'Enter') { this.finishDrawing(props); this.resetClickSequence(); } else if (event.key === 'Escape') { this.resetClickSequence(); this.holeSequence = []; this.isDrawingHole = false; props.onEdit({ updatedData: props.data, editType: 'cancelFeature', editContext: {} }); } } handlePointerMove(event: PointerMoveEvent, props: ModeProps) { props.onUpdateCursor('cell'); super.handlePointerMove(event, props); } // eslint-disable-next-line max-statements, complexity finishDrawing(props: ModeProps) { const clickSequence = this.getClickSequence(); const polygon = [...clickSequence, clickSequence[0]]; const newPolygon = getPolygonFeature([polygon], props); const canAddHole = canAddHoleToPolygon(props); const canOverlap = canPolygonOverlap(props); // Check if the polygon intersects itself (excluding shared start/end point) if (!canOverlap) { const overlapping = kinks(newPolygon).features; if (overlapping.length > 0) { // ❌ Invalid polygon: overlaps props.onEdit({ updatedData: props.data, editType: 'invalidPolygon', editContext: {reason: 'overlaps'} }); this.resetClickSequence(); return; } } if (canAddHole) { const holeResult = this.tryAddHoleToExistingPolygon(newPolygon, polygon, props); if (holeResult.handled) { this.resetClickSequence(); return; } } // If no valid hole was found, add the polygon as a new feature const editAction = this.getAddFeatureOrBooleanPolygonAction( { type: 'Polygon', coordinates: [[...this.getClickSequence(), this.getClickSequence()[0]]] }, props ); if (editAction) props.onEdit(editAction); this.resetClickSequence(); return; } private tryAddHoleToExistingPolygon( newPolygon: Feature, polygon: Position[], props: ModeProps ): {handled: boolean} { for (const [featureIndex, feature] of props.data.features.entries()) { if (feature.geometry.type === 'Polygon') { const result = this.validateAndCreateHole( feature as Feature, featureIndex, newPolygon, polygon, props ); if (result.handled) { return result; } } } return {handled: false}; } // eslint-disable-next-line max-statements, complexity private validateAndCreateHole( feature: Feature, featureIndex: number, newPolygon: Feature, polygon: Position[], props: ModeProps ): {handled: boolean} { const outer = getPolygonFeature(feature.geometry.coordinates, props); // Check existing holes for conflicts for (let i = 1; i < feature.geometry.coordinates.length; i++) { const hole = getPolygonFeature([feature.geometry.coordinates[i]], props); const intersection = isPolygonIntersecting(hole, newPolygon, props.coordinateSystem); if (intersection) { props.onEdit({ updatedData: props.data, editType: 'invalidHole', editContext: {reason: 'intersects-existing-hole'} }); return {handled: true}; } const containsOrContained = isContainingOrContained(hole, newPolygon, props.coordinateSystem); if (containsOrContained) { props.onEdit({ updatedData: props.data, editType: 'invalidHole', editContext: {reason: 'contains-or-contained-by-existing-hole'} }); return {handled: true}; } } // Check outer polygon conflicts after iteration const intersectionWithOuter = isPolygonIntersecting(outer, newPolygon, props.coordinateSystem); if (intersectionWithOuter) { props.onEdit({ updatedData: props.data, editType: 'invalidPolygon', editContext: {reason: 'intersects-existing-polygon'} }); return {handled: true}; } const outerWithin = isPolygonWithin(outer, newPolygon, props.coordinateSystem); if (outerWithin) { props.onEdit({ updatedData: props.data, editType: 'invalidPolygon', editContext: {reason: 'contains-existing-polygon'} }); return {handled: true}; } // Check if new polygon is within outer polygon (valid hole) const newWithinOuter = isPolygonWithin(newPolygon, outer, props.coordinateSystem); if (newWithinOuter) { const updatedData = new ImmutableFeatureCollection(props.data) .replaceGeometry(featureIndex, { ...feature.geometry, coordinates: [...feature.geometry.coordinates, polygon] }) .getObject(); props.onEdit({ updatedData, editType: 'addHole', editContext: {hole: newPolygon.geometry} }); return {handled: true}; } return {handled: false}; } } // Helper function to check if a point is near the first point in the sequence function isNearFirstPoint(click: Position, first: Position, threshold = 1e-4): boolean { const dx = click[0] - first[0]; const dy = click[1] - first[1]; return dx * dx + dy * dy < threshold * threshold; } // Helper function to determine if a hole can be added to a polygon function canAddHoleToPolygon(props: ModeProps): boolean { // For simplicity, always return true in this example. // Implement your own logic based on application requirements. return props.modeConfig?.allowHoles ?? false; } // Helper function to determine if a polygon can intersect itself function canPolygonOverlap(props: ModeProps): boolean { // Return the value of allowSelfIntersection (defaults to false for safety) return props.modeConfig?.allowSelfIntersection ?? false; } function getPolygonFeature( polygonGeometry: Position[][], props: ModeProps ): Feature { return props.coordinateSystem instanceof CartesianCoordinateSystem ? {type: 'Feature', properties: {}, geometry: {type: 'Polygon', coordinates: polygonGeometry}} : turfPolygon(polygonGeometry); } // Dispatch function to call function based on coord system, defaults to geo mode function forCoordSystem( coordSystem: EditModeCoordinateSystem, geoFn: () => T, cartFn: () => T ): T { return coordSystem === cartesianCoordinateSystem ? cartFn() : geoFn(); } function isContainingOrContained( poly1: Feature, poly2: Feature, coordSystem: EditModeCoordinateSystem ): boolean { return forCoordSystem( coordSystem, () => booleanWithin(poly1, poly2) || booleanWithin(poly2, poly1), () => polygonWithinPolygon(poly1, poly2) || polygonWithinPolygon(poly2, poly1) ); } function isPolygonIntersecting( poly1: Feature, poly2: Feature, coordSystem: EditModeCoordinateSystem ): boolean { return forCoordSystem( coordSystem, () => lineIntersect(poly1, poly2).features.length > 0, () => polygonEdgesIntersect(poly1, poly2) ); } function isPolygonWithin( poly1: Feature, poly2: Feature, coordSystem: EditModeCoordinateSystem ): boolean { return forCoordSystem( coordSystem, () => booleanWithin(poly1, poly2), () => polygonWithinPolygon(poly1, poly2) ); }