modules/edit-modes/src/lib/geojson-edit-mode.ts (234 lines of code) (raw):

import turfUnion from '@turf/union'; import turfDifference from '@turf/difference'; import turfIntersect from '@turf/intersect'; import rewind from '@turf/rewind'; import { EditAction, ClickEvent, PointerMoveEvent, StartDraggingEvent, StopDraggingEvent, DraggingEvent, Pick, Tooltip, ModeProps, GuideFeatureCollection, TentativeFeature, } from '../types'; import { FeatureCollection, Feature, Polygon, Geometry, Position } from '../geojson-types'; import { getPickedEditHandles, getNonGuidePicks } from '../utils'; import { EditMode } from './edit-mode'; import { ImmutableFeatureCollection } from './immutable-feature-collection'; export type GeoJsonEditAction = EditAction<FeatureCollection>; const DEFAULT_GUIDES: GuideFeatureCollection = { type: 'FeatureCollection', features: [], }; const DEFAULT_TOOLTIPS: Tooltip[] = []; // Main interface for `EditMode`s that edit GeoJSON export type GeoJsonEditModeType = EditMode<FeatureCollection, FeatureCollection>; export interface GeoJsonEditModeConstructor { new (): GeoJsonEditModeType; } export class GeoJsonEditMode implements EditMode<FeatureCollection, GuideFeatureCollection> { _clickSequence: Position[] = []; getGuides(props: ModeProps<FeatureCollection>): GuideFeatureCollection { return DEFAULT_GUIDES; } getTooltips(props: ModeProps<FeatureCollection>): Tooltip[] { return DEFAULT_TOOLTIPS; } getSelectedFeature(props: ModeProps<FeatureCollection>): Feature | null | undefined { if (props.selectedIndexes.length === 1) { return props.data.features[props.selectedIndexes[0]]; } return null; } getSelectedGeometry(props: ModeProps<FeatureCollection>): Geometry | null | undefined { const feature = this.getSelectedFeature(props); if (feature) { return feature.geometry; } return null; } getSelectedFeaturesAsFeatureCollection(props: ModeProps<FeatureCollection>): FeatureCollection { const { features } = props.data; const selectedFeatures = props.selectedIndexes.map((selectedIndex) => features[selectedIndex]); return { type: 'FeatureCollection', features: selectedFeatures, }; } getClickSequence(): Position[] { return this._clickSequence; } addClickSequence({ mapCoords }: ClickEvent): void { this._clickSequence.push(mapCoords); } resetClickSequence(): void { this._clickSequence = []; } getTentativeGuide(props: ModeProps<FeatureCollection>): TentativeFeature | null | undefined { const guides = this.getGuides(props); return guides.features.find( (f) => f.properties && f.properties.guideType === 'tentative' ) as TentativeFeature; } isSelectionPicked(picks: Pick[], props: ModeProps<FeatureCollection>): boolean { if (!picks.length) return false; const pickedFeatures = getNonGuidePicks(picks).map(({ index }) => index); const pickedHandles = getPickedEditHandles(picks).map( ({ properties }) => properties.featureIndex ); const pickedIndexes = new Set([...pickedFeatures, ...pickedHandles]); return props.selectedIndexes.some((index) => pickedIndexes.has(index)); } rewindPolygon(feature: Feature): Feature { const { geometry } = feature; const isPolygonal = geometry.type === 'Polygon' || geometry.type === 'MultiPolygon'; if (isPolygonal) { // @ts-expect-error turf type too wide return rewind(feature); } return feature; } getAddFeatureAction( featureOrGeometry: Geometry | Feature, features: FeatureCollection ): GeoJsonEditAction { // Unsure why flow can't deal with Geometry type, but there I fixed it const featureOrGeometryAsAny: any = featureOrGeometry; const feature: any = featureOrGeometryAsAny.type === 'Feature' ? featureOrGeometryAsAny : { type: 'Feature', properties: {}, geometry: featureOrGeometryAsAny, }; const rewindFeature = this.rewindPolygon(feature); const updatedData = new ImmutableFeatureCollection(features) .addFeature(rewindFeature) .getObject(); return { updatedData, editType: 'addFeature', editContext: { featureIndexes: [updatedData.features.length - 1], }, }; } getAddManyFeaturesAction( { features: featuresToAdd }: FeatureCollection, features: FeatureCollection ): GeoJsonEditAction { let updatedData = new ImmutableFeatureCollection(features); const initialIndex = updatedData.getObject().features.length; const updatedIndexes = []; for (const feature of featuresToAdd) { const { properties, geometry } = feature; const geometryAsAny: any = geometry; updatedData = updatedData.addFeature({ type: 'Feature', properties, geometry: geometryAsAny, }); updatedIndexes.push(initialIndex + updatedIndexes.length); } return { updatedData: updatedData.getObject(), editType: 'addFeature', editContext: { featureIndexes: updatedIndexes, }, }; } getAddFeatureOrBooleanPolygonAction( featureOrGeometry: Polygon | Feature, props: ModeProps<FeatureCollection> ): GeoJsonEditAction | null | undefined { const featureOrGeometryAsAny: any = featureOrGeometry; const selectedFeature = this.getSelectedFeature(props); const { modeConfig } = props; if (modeConfig && modeConfig.booleanOperation) { if ( !selectedFeature || (selectedFeature.geometry.type !== 'Polygon' && selectedFeature.geometry.type !== 'MultiPolygon') ) { // eslint-disable-next-line no-console,no-undef console.warn( 'booleanOperation only supported for single Polygon or MultiPolygon selection' ); return null; } const feature = featureOrGeometryAsAny.type === 'Feature' ? featureOrGeometryAsAny : { type: 'Feature', geometry: featureOrGeometryAsAny, }; let updatedGeometry; if (modeConfig.booleanOperation === 'union') { // @ts-expect-error selectedFeature type too wide updatedGeometry = turfUnion(selectedFeature, feature); } else if (modeConfig.booleanOperation === 'difference') { // @ts-expect-error selectedFeature type too wide updatedGeometry = turfDifference(selectedFeature, feature); } else if (modeConfig.booleanOperation === 'intersection') { // @ts-expect-error selectedFeature type too wide updatedGeometry = turfIntersect(selectedFeature, feature); } else { // eslint-disable-next-line no-console,no-undef console.warn(`Invalid booleanOperation ${modeConfig.booleanOperation}`); return null; } if (!updatedGeometry) { // eslint-disable-next-line no-console,no-undef console.warn('Canceling edit. Boolean operation erased entire polygon.'); return null; } const featureIndex = props.selectedIndexes[0]; const updatedData = new ImmutableFeatureCollection(props.data) .replaceGeometry(featureIndex, updatedGeometry.geometry) .getObject(); const editAction: GeoJsonEditAction = { updatedData, editType: 'unionGeometry', editContext: { featureIndexes: [featureIndex], }, }; return editAction; } return this.getAddFeatureAction(featureOrGeometry, props.data); } createTentativeFeature(props: ModeProps<FeatureCollection>): TentativeFeature { return null; } handleClick(event: ClickEvent, props: ModeProps<FeatureCollection>): void {} handlePointerMove(event: PointerMoveEvent, props: ModeProps<FeatureCollection>): void { const tentativeFeature = this.createTentativeFeature(props); if (tentativeFeature) { props.onEdit({ updatedData: props.data, editType: 'updateTentativeFeature', editContext: { feature: tentativeFeature, }, }); } } handleStartDragging(event: StartDraggingEvent, props: ModeProps<FeatureCollection>): void {} handleStopDragging(event: StopDraggingEvent, props: ModeProps<FeatureCollection>): void {} handleDragging(event: DraggingEvent, props: ModeProps<FeatureCollection>): void {} handleKeyUp(event: KeyboardEvent, props: ModeProps<FeatureCollection>): void { if (event.key === 'Escape') { this.resetClickSequence(); props.onEdit({ // Because the new drawing feature is dropped, so the data will keep as the same. updatedData: props.data, editType: 'cancelFeature', editContext: {}, }); } } } export function getIntermediatePosition(position1: Position, position2: Position): Position { const intermediatePosition: Position = [ (position1[0] + position2[0]) / 2.0, (position1[1] + position2[1]) / 2.0, ]; return intermediatePosition; }