Skip to content

[Feat] (editable-layers) GeoJSON-compliant DrawPolygonMode #242

@charlieforward9

Description

@charlieforward9

Target Use Case

Allow developers to draw polygons in accordance to the GeoJSON spec, preventing overlapping polygons, and minimizing user errors in drawing.

Proposal

DrawSmartPolygonMode.mp4
import {
  ClickEvent,
  FeatureCollection,
  FeatureWithProps,
  GeoJsonEditMode,
  Geometry,
  GuideFeatureCollection,
  ImmutableFeatureCollection,
  ModeProps,
  Point,
  PointerMoveEvent,
  Position,
} from "@deck.gl-community/editable-layers";
import { polygon as turfPolygon } from "@turf/helpers";
import centroid from "@turf/centroid";
import booleanWithin from "@turf/boolean-within";
import lineIntersect from "@turf/line-intersect";

export type EditHandleType =
  | "existing"
  | "intermediate"
  | "snap-source"
  | "snap-target"
  | "scale"
  | "rotate";

export type EditHandleFeature = FeatureWithProps<
  Point,
  {
    guideType: "editHandle";
    editHandleType: EditHandleType;
    featureIndex: number;
    positionIndexes?: number[];
    shape?: string;
  }
>;

export type TentativeFeature = FeatureWithProps<
  Geometry,
  {
    guideType: "tentative";
    shape?: string;
  }
>;

export type GuideFeature = EditHandleFeature | TentativeFeature;

//TODO: consolidate this with the DrawPolygonMode and add props
export class DrawSmartPolygonMode extends GeoJsonEditMode {
  holeSequence: Position[] = [];
  isDrawingHole = false;

  private handleNewPolygon(
    polygon: Position[],
    props: ModeProps<FeatureCollection>,
  ): void {
    const newPolygon = turfPolygon([polygon]);
    const newFeature: GeoJSON.Feature<GeoJSON.Geometry> = {
      type: "Feature",
      geometry: newPolygon.geometry,
      properties: {},
    };
    const center = centroid(newFeature);

    // Check if the polygon intersects itself (excluding shared start/end point)
    const selfIntersection = lineIntersect(
      newPolygon,
      newPolygon,
    ).features.filter(
      (intersection) =>
        !newPolygon.geometry.coordinates[0].some(
          (coord) =>
            coord[0] === intersection.geometry.coordinates[0] &&
            coord[1] === intersection.geometry.coordinates[1],
        ),
    );

    if (selfIntersection.length > 0) {
      // ❌ Invalid polygon: self-intersects
      props.onEdit({
        updatedData: props.data,
        editType: "invalidPolygon",
        editContext: { reason: "self-intersects" },
      });
      return;
    }

    for (const [featureIndex, feature] of props.data.features.entries()) {
      if (feature.geometry.type === "Polygon") {
        const outer = turfPolygon(feature.geometry.coordinates);

        // Check if the new hole intersects or contains another hole first
        for (let i = 1; i < feature.geometry.coordinates.length; i++) {
          const hole = turfPolygon([feature.geometry.coordinates[i]]);
          const intersection = lineIntersect(hole, newPolygon);
          if (intersection.features.length > 0) {
            // ❌ Invalid hole: intersects existing hole
            props.onEdit({
              updatedData: props.data,
              editType: "invalidHole",
              editContext: { reason: "intersects-existing-hole" },
            });
            return;
          }

          if (
            booleanWithin(hole, newPolygon) ||
            booleanWithin(newPolygon, hole)
          ) {
            // ❌ Invalid hole: contains or is contained by existing hole
            props.onEdit({
              updatedData: props.data,
              editType: "invalidHole",
              editContext: {
                reason: "contains-or-contained-by-existing-hole",
              },
            });
            return;
          }
        }

        // Check if the new polygon intersects or contains the outer polygon
        const intersectionWithOuter = lineIntersect(outer, newPolygon);
        if (intersectionWithOuter.features.length > 0) {
          // ❌ Invalid polygon: intersects existing polygon
          props.onEdit({
            updatedData: props.data,
            editType: "invalidPolygon",
            editContext: { reason: "intersects-existing-polygon" },
          });
          return;
        }

        if (booleanWithin(outer, newPolygon)) {
          // ❌ Invalid polygon: contains existing polygon
          props.onEdit({
            updatedData: props.data,
            editType: "invalidPolygon",
            editContext: {
              reason: "contains-existing-polygon",
            },
          });
          return;
        }

        // Now check if the center of the new hole is within the outer polygon
        if (booleanWithin(center, outer)) {
          // ✅ Valid hole
          const updatedCoords = [...feature.geometry.coordinates, polygon];
          const updatedGeometry = {
            ...feature.geometry,
            coordinates: updatedCoords,
          };

          const updatedData = new ImmutableFeatureCollection(props.data)
            .replaceGeometry(featureIndex, updatedGeometry)
            .getObject();

          props.onEdit({
            updatedData,
            editType: "addHole",
            editContext: { hole: newPolygon.geometry },
          });

          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);
    return;
  }

  createTentativeFeature(
    props: ModeProps<FeatureCollection>,
  ): 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,
    };
  }

  handleClick(event: ClickEvent, props: ModeProps<FeatureCollection>) {
    const clickSequence = this.getClickSequence();
    const coords = event.mapCoords;

    if (!this.isDrawingHole && clickSequence.length > 2) {
      if (isNearFirstPoint(coords, clickSequence[0])) {
        const polygon = [...clickSequence, clickSequence[0]];
        this.handleNewPolygon(polygon, 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;
    }

    this.addClickSequence(event);
  }

  handleKeyUp(event: KeyboardEvent, props: ModeProps<FeatureCollection>) {
    const clickSequence = this.getClickSequence();

    if (event.key === "Enter") {
      const polygon = [...clickSequence, clickSequence[0]];
      this.handleNewPolygon(polygon, 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<FeatureCollection>,
  ) {
    props.onUpdateCursor("cell");
    super.handlePointerMove(event, props);
  }

  getGuides(props: ModeProps<FeatureCollection>): 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;
  }
}

// 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;
}

Details

I would like to consolidate this with the original DrawPolygonMode and just add the extra fields as props, but I'd also want to hear the input of the vis.gl GOATS (@chrisgervang, @ibgreen, @felixpalmer) before I went ahead and started modifying the existing modes.

In the long run, I'm convinced that the editable-layers module should split from deck.gl-community back into its own repo - with someone like myself as the core maintainer - but I want to get more familiar with expectations and the patterns that contributors should abide by when contributing into this module, so for now, I'll just leave this new mode here as a call for discussion too deepen my understanding.

CC @ManuelCortes23

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions