-
Notifications
You must be signed in to change notification settings - Fork 47
Description
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.