// deck.gl-community // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; import turfDifference from '@turf/difference'; import turfBuffer from '@turf/buffer'; import lineIntersect from '@turf/line-intersect'; import type {Point} from 'geojson'; import {feature as turfFeature, featureCollection, lineString} from '@turf/helpers'; import turfBearing from '@turf/bearing'; import turfDistance from '@turf/distance'; import turfDestination from '@turf/destination'; import turfPolygonToLine from '@turf/polygon-to-line'; import nearestPointOnLine from '@turf/nearest-point-on-line'; import {generatePointsParallelToLinePoints} from './utils'; import {FeatureCollection, PolygonGeometry, SimpleFeatureCollection} from '../utils/geojson-types'; import { ClickEvent, PointerMoveEvent, ModeProps, GuideFeatureCollection, TentativeFeature } from './types'; import {GeoJsonEditMode, GeoJsonEditAction} from './geojson-edit-mode'; import {ImmutableFeatureCollection} from './immutable-feature-collection'; export class SplitPolygonMode extends GeoJsonEditMode { calculateMapCoords( clickSequence: any, mapCoords: any, props: ModeProps ) { const modeConfig = props.modeConfig; if (!modeConfig || !modeConfig.lock90Degree || !clickSequence.length) { return mapCoords; } if (clickSequence.length === 1) { // if first point is clicked, then find closest polygon point and build ~90deg vector const firstPoint = clickSequence[0]; const selectedGeometry = this.getSelectedGeometry(props); const feature = turfPolygonToLine(selectedGeometry as PolygonGeometry); const lines = feature.type === 'FeatureCollection' ? feature.features : [feature]; let minDistance = Number.MAX_SAFE_INTEGER; let closestPoint: ReturnType | null = null; // If Multipolygon, then we should find nearest polygon line and stick split to it. lines.forEach((line) => { const snapPoint = nearestPointOnLine(line, firstPoint); const distanceFromOrigin = turfDistance(snapPoint, firstPoint); if (minDistance > distanceFromOrigin) { minDistance = distanceFromOrigin; closestPoint = snapPoint; } }); if (closestPoint) { // closest point is used as 90degree entry to the polygon const lastBearing = turfBearing(firstPoint, closestPoint); const currentDistance = turfDistance(firstPoint, mapCoords, {units: 'meters'}); return turfDestination(firstPoint, currentDistance, lastBearing, { units: 'meters' }).geometry.coordinates; } return mapCoords; } // Allow only 90 degree turns const lastPoint = clickSequence[clickSequence.length - 1]; const [approximatePoint] = generatePointsParallelToLinePoints( clickSequence[clickSequence.length - 2], lastPoint, mapCoords ); // align point with current ground const nearestPt = nearestPointOnLine(lineString([lastPoint, approximatePoint]), mapCoords) .geometry.coordinates; return nearestPt; } getGuides(props: ModeProps): GuideFeatureCollection { const clickSequence = this.getClickSequence(); const guides: GuideFeatureCollection = { type: 'FeatureCollection', features: [] }; if (clickSequence.length === 0 || !props.lastPointerMoveEvent) { // nothing to do yet return guides; } const {mapCoords} = props.lastPointerMoveEvent; guides.features.push({ type: 'Feature', properties: { guideType: 'tentative' }, geometry: { type: 'LineString', coordinates: [...clickSequence, this.calculateMapCoords(clickSequence, mapCoords, props)] } }); return guides; } handleClick(event: ClickEvent, props: ModeProps) { const tentativeFeature = this.getTentativeGuide(props); const selectedGeometry = this.getSelectedGeometry(props); if (!selectedGeometry) { // eslint-disable-next-line no-console,no-undef console.warn('A polygon must be selected for splitting'); return; } const clickSequence = this.getClickSequence(); if (tentativeFeature && tentativeFeature.geometry.type === 'LineString') { clickSequence.push( tentativeFeature.geometry.coordinates[tentativeFeature.geometry.coordinates.length - 1] ); } else { this.addClickSequence(event); } const pt: Point = { type: 'Point', coordinates: clickSequence[clickSequence.length - 1] }; const isPointInPolygon = booleanPointInPolygon(pt, selectedGeometry as PolygonGeometry); if (clickSequence.length > 1 && tentativeFeature && !isPointInPolygon) { this.resetClickSequence(); // @ts-expect-error narrow type const isLineInterectingWithPolygon = lineIntersect(tentativeFeature, selectedGeometry); if (isLineInterectingWithPolygon.features.length === 0) { return; } const editAction = this.splitPolygon(tentativeFeature, props); if (editAction) { props.onEdit(editAction); } } } handlePointerMove(event: PointerMoveEvent, props: ModeProps) { props.onUpdateCursor('cell'); } splitPolygon(tentativeFeature: TentativeFeature, props: ModeProps) { const selectedGeometry = this.getSelectedGeometry(props); const featureIndex = props.selectedIndexes[0]; const modeConfig = props.modeConfig || {}; // Default gap in between the polygon let {gap = 0.1, units = 'centimeters'} = modeConfig; if (gap === 0) { gap = 0.1; units = 'centimeters'; } const buffer = turfBuffer(tentativeFeature, gap, {units}); const updatedGeometry = turfDifference( featureCollection([turfFeature(selectedGeometry as PolygonGeometry), buffer]) ); if (!updatedGeometry) { // eslint-disable-next-line no-console,no-undef console.warn('Canceling edit. Split Polygon erased'); return null; } const {type, coordinates} = updatedGeometry.geometry; let updatedCoordinates: any[] = []; // TODO if (type === 'Polygon') { // Update the coordinates as per Multipolygon updatedCoordinates = coordinates.map((c) => [c]); } else { // Handle Case when Multipolygon has holes updatedCoordinates = coordinates.reduce((agg, prev) => { prev.forEach((p) => { // @ts-expect-error revisit coordinates type here agg.push([p]); }); return agg; }, []); } // Update the type to Mulitpolygon const updatedData = new ImmutableFeatureCollection(props.data).replaceGeometry(featureIndex, { type: 'MultiPolygon', coordinates: updatedCoordinates }); const editAction: GeoJsonEditAction = { updatedData: updatedData.getObject(), editType: 'split', editContext: { featureIndexes: [featureIndex] } }; return editAction; } }