@deck.gl-community/editable-layers 9.2.0-beta.5 → 9.2.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@deck.gl-community/editable-layers",
3
3
  "description": "A suite of 3D-enabled data editing overlays, suitable for deck.gl",
4
4
  "license": "MIT",
5
- "version": "9.2.0-beta.5",
5
+ "version": "9.2.0-beta.6",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -42,6 +42,7 @@
42
42
  "@turf/bbox-polygon": "^6.5.0",
43
43
  "@turf/bearing": "^6.5.0",
44
44
  "@turf/boolean-point-in-polygon": "^6.5.0",
45
+ "@turf/boolean-within": "^6.5.0",
45
46
  "@turf/buffer": "^6.5.0",
46
47
  "@turf/center": "^6.5.0",
47
48
  "@turf/centroid": "^6.5.0",
@@ -86,5 +87,5 @@
86
87
  "@luma.gl/engine": "~9.2.0",
87
88
  "@math.gl/core": ">=4.0.1"
88
89
  },
89
- "gitHead": "7c3c27d9df35a4d089d183696a06c643ea77fcd1"
90
+ "gitHead": "c72a43fa7f8edfb9456b37656b095d036946b3a4"
90
91
  }
@@ -3,7 +3,10 @@
3
3
  // Copyright (c) vis.gl contributors
4
4
 
5
5
  import lineIntersect from '@turf/line-intersect';
6
- import {lineString as turfLineString} from '@turf/helpers';
6
+ import { polygon as turfPolygon} from '@turf/helpers';
7
+ import booleanWithin from "@turf/boolean-within";
8
+
9
+
7
10
  import {
8
11
  ClickEvent,
9
12
  PointerMoveEvent,
@@ -13,129 +16,151 @@ import {
13
16
  GuideFeature,
14
17
  DoubleClickEvent
15
18
  } from './types';
16
- import {Polygon, FeatureCollection} from '../utils/geojson-types';
19
+ import {Position, FeatureCollection, Geometry} from '../utils/geojson-types';
17
20
  import {getPickedEditHandle} from './utils';
18
21
  import {GeoJsonEditMode} from './geojson-edit-mode';
22
+ import { ImmutableFeatureCollection } from './immutable-feature-collection';
23
+
19
24
 
20
25
  export class DrawPolygonMode extends GeoJsonEditMode {
26
+
27
+ holeSequence: Position[] = [];
28
+ isDrawingHole = false;
29
+
21
30
  createTentativeFeature(props: ModeProps<FeatureCollection>): TentativeFeature {
22
- const {lastPointerMoveEvent} = props;
31
+ const { lastPointerMoveEvent } = props;
23
32
  const clickSequence = this.getClickSequence();
33
+ const holeSequence = this.holeSequence;
34
+ const lastCoords = lastPointerMoveEvent
35
+ ? [lastPointerMoveEvent.mapCoords]
36
+ : [];
24
37
 
25
- const lastCoords = lastPointerMoveEvent ? [lastPointerMoveEvent.mapCoords] : [];
26
-
27
- let tentativeFeature;
28
- if (clickSequence.length === 1 || clickSequence.length === 2) {
29
- tentativeFeature = {
30
- type: 'Feature',
31
- properties: {
32
- guideType: 'tentative'
33
- },
34
- geometry: {
35
- type: 'LineString',
36
- coordinates: [...clickSequence, ...lastCoords]
37
- }
38
+ let geometry: Geometry;
39
+
40
+ if (this.isDrawingHole && holeSequence.length > 1) {
41
+ geometry = {
42
+ type: "Polygon",
43
+ coordinates: [
44
+ [...clickSequence, clickSequence[0]],
45
+ [...holeSequence, ...lastCoords, holeSequence[0]],
46
+ ],
38
47
  };
39
48
  } else if (clickSequence.length > 2) {
40
- tentativeFeature = {
41
- type: 'Feature',
42
- properties: {
43
- guideType: 'tentative'
44
- },
45
- geometry: {
46
- type: 'Polygon',
47
- coordinates: [[...clickSequence, ...lastCoords, clickSequence[0]]]
48
- }
49
+ geometry = {
50
+ type: "Polygon",
51
+ coordinates: [[...clickSequence, ...lastCoords, clickSequence[0]]],
52
+ };
53
+ } else {
54
+ geometry = {
55
+ type: "LineString",
56
+ coordinates: [...clickSequence, ...lastCoords],
49
57
  };
50
58
  }
51
59
 
52
- return tentativeFeature;
53
- }
54
-
55
- getGuides(props: ModeProps<FeatureCollection>): GuideFeatureCollection {
56
- const clickSequence = this.getClickSequence();
57
-
58
- const guides: GuideFeatureCollection = {
59
- type: 'FeatureCollection',
60
- features: []
61
- };
62
-
63
- const tentativeFeature = this.createTentativeFeature(props);
64
- if (tentativeFeature) {
65
- guides.features.push(tentativeFeature);
66
- }
67
-
68
- const editHandles: GuideFeature[] = clickSequence.map((clickedCoord, index) => ({
69
- type: 'Feature',
60
+ return {
61
+ type: "Feature",
70
62
  properties: {
71
- guideType: 'editHandle',
72
- editHandleType: 'existing',
73
- featureIndex: -1,
74
- positionIndexes: [index]
63
+ guideType: "tentative",
75
64
  },
76
- geometry: {
77
- type: 'Point',
78
- coordinates: clickedCoord
79
- }
80
- }));
81
-
82
- guides.features.push(...editHandles);
83
-
84
- return guides;
65
+ geometry,
66
+ };
85
67
  }
86
68
 
87
- finishDrawing(props: ModeProps<FeatureCollection>) {
88
- const clickSequence = this.getClickSequence();
89
- if (clickSequence.length > 2) {
90
- const polygonToAdd: Polygon = {
91
- type: 'Polygon',
92
- coordinates: [[...clickSequence, clickSequence[0]]]
93
- };
94
-
95
- this.resetClickSequence();
96
- const editAction = this.getAddFeatureOrBooleanPolygonAction(polygonToAdd, props);
97
- if (editAction) {
98
- props.onEdit(editAction);
99
- }
100
- }
69
+ getGuides(props: ModeProps<FeatureCollection>): GuideFeatureCollection {
70
+ const guides: GuideFeatureCollection = {
71
+ type: "FeatureCollection",
72
+ features: [],
73
+ };
74
+
75
+ const tentative = this.createTentativeFeature(props);
76
+ if (tentative) guides.features.push(tentative);
77
+
78
+ const sequence = this.isDrawingHole
79
+ ? this.holeSequence
80
+ : this.getClickSequence();
81
+
82
+ const handles: GuideFeature[] = sequence.map((coord, index) => ({
83
+ type: "Feature",
84
+ properties: {
85
+ guideType: "editHandle",
86
+ editHandleType: "existing",
87
+ featureIndex: -1,
88
+ positionIndexes: [index],
89
+ },
90
+ geometry: {
91
+ type: "Point",
92
+ coordinates: coord,
93
+ },
94
+ }));
95
+
96
+ guides.features.push(...handles);
97
+ return guides;
101
98
  }
102
99
 
103
- // eslint-disable-next-line complexity
100
+ // eslint-disable-next-line complexity, max-statements
104
101
  handleClick(event: ClickEvent, props: ModeProps<FeatureCollection>) {
105
102
  const {picks} = event;
106
103
  const clickedEditHandle = getPickedEditHandle(picks);
107
104
  const clickSequence = this.getClickSequence();
105
+ const coords = event.mapCoords;
106
+
107
+ // Check if they clicked on an edit handle to complete the polygon
108
+ if (
109
+ !this.isDrawingHole &&
110
+ clickSequence.length > 2 &&
111
+ clickedEditHandle &&
112
+ Array.isArray(clickedEditHandle.properties.positionIndexes) &&
113
+ (clickedEditHandle.properties.positionIndexes[0] === 0 ||
114
+ clickedEditHandle.properties.positionIndexes[0] === clickSequence.length - 1)
115
+ ) {
116
+ // They clicked the first or last point, so complete the polygon
117
+ this.finishDrawing(props);
118
+ return;
119
+ }
120
+
121
+ // Check if they clicked near the first point to complete the polygon
122
+ if (!this.isDrawingHole && clickSequence.length > 2) {
123
+ if (isNearFirstPoint(coords, clickSequence[0])) {
124
+ this.finishDrawing(props);
125
+ this.resetClickSequence();
126
+ return;
127
+ }
128
+ }
129
+
130
+ if (this.isDrawingHole) {
131
+ const current = this.holeSequence;
132
+ current.push(coords);
133
+
134
+ if (current.length > 2) {
135
+ const poly: Geometry = {
136
+ type: "Polygon",
137
+ coordinates: [
138
+ [...clickSequence, clickSequence[0]],
139
+ [...current, current[0]],
140
+ ],
141
+ };
142
+
143
+ this.resetClickSequence();
144
+ this.holeSequence = [];
145
+ this.isDrawingHole = false;
108
146
 
109
- let overlappingLines = false;
110
- if (clickSequence.length > 2 && props.modeConfig && props.modeConfig.preventOverlappingLines) {
111
- const currentLine = turfLineString([
112
- clickSequence[clickSequence.length - 1],
113
- event.mapCoords
114
- ]);
115
- const otherLines = turfLineString([...clickSequence.slice(0, clickSequence.length - 1)]);
116
- const intersectingPoints = lineIntersect(currentLine, otherLines);
117
- if (intersectingPoints.features.length > 0) {
118
- overlappingLines = true;
147
+ const editAction = this.getAddFeatureOrBooleanPolygonAction(
148
+ poly,
149
+ props,
150
+ );
151
+ if (editAction) props.onEdit(editAction);
119
152
  }
153
+ return;
120
154
  }
121
155
 
156
+ // Add the click if we didn't click on a handle
122
157
  let positionAdded = false;
123
- if (!clickedEditHandle && !overlappingLines) {
124
- // Don't add another point right next to an existing one
158
+ if (!clickedEditHandle) {
125
159
  this.addClickSequence(event);
126
160
  positionAdded = true;
127
161
  }
128
162
 
129
- if (
130
- clickSequence.length > 2 &&
131
- clickedEditHandle &&
132
- Array.isArray(clickedEditHandle.properties.positionIndexes) &&
133
- (clickedEditHandle.properties.positionIndexes[0] === 0 ||
134
- clickedEditHandle.properties.positionIndexes[0] === clickSequence.length - 1)
135
- ) {
136
- // They clicked the first or last point (or double-clicked), so complete the polygon
137
- this.finishDrawing(props);
138
- } else if (positionAdded) {
163
+ if (positionAdded) {
139
164
  // new tentative point
140
165
  props.onEdit({
141
166
  // data is the same
@@ -148,20 +173,24 @@ export class DrawPolygonMode extends GeoJsonEditMode {
148
173
  }
149
174
  }
150
175
 
151
- handleDoubleClick(event: DoubleClickEvent, props: ModeProps<FeatureCollection>) {
176
+ handleDoubleClick(_event: DoubleClickEvent, props: ModeProps<FeatureCollection>) {
152
177
  this.finishDrawing(props);
178
+ this.resetClickSequence();
153
179
  }
154
180
 
155
181
  handleKeyUp(event: KeyboardEvent, props: ModeProps<FeatureCollection>) {
156
- if (event.key === 'Enter') {
182
+ if (event.key === "Enter") {
157
183
  this.finishDrawing(props);
158
- } else if (event.key === 'Escape') {
159
184
  this.resetClickSequence();
185
+ } else if (event.key === "Escape") {
186
+ this.resetClickSequence();
187
+ this.holeSequence = [];
188
+ this.isDrawingHole = false;
189
+
160
190
  props.onEdit({
161
- // Because the new drawing feature is dropped, so the data will keep as the same.
162
191
  updatedData: props.data,
163
- editType: 'cancelFeature',
164
- editContext: {}
192
+ editType: "cancelFeature",
193
+ editContext: {},
165
194
  });
166
195
  }
167
196
  }
@@ -170,4 +199,179 @@ export class DrawPolygonMode extends GeoJsonEditMode {
170
199
  props.onUpdateCursor('cell');
171
200
  super.handlePointerMove(event, props);
172
201
  }
202
+
203
+ // eslint-disable-next-line max-statements, complexity
204
+ finishDrawing(props: ModeProps<FeatureCollection>) {
205
+ const clickSequence = this.getClickSequence();
206
+ const polygon = [...clickSequence, clickSequence[0]];
207
+
208
+ const newPolygon = turfPolygon([polygon]);
209
+
210
+ const canAddHole = canAddHoleToPolygon(props);
211
+ const canOverlap = canPolygonOverlap(props);
212
+
213
+
214
+ // Check if the polygon intersects itself (excluding shared start/end point)
215
+ if (!canOverlap) {
216
+ const overlapping = lineIntersect(
217
+ newPolygon,
218
+ newPolygon,
219
+ ).features.filter(
220
+ (intersection) =>
221
+ !newPolygon.geometry.coordinates[0].some(
222
+ (coord) =>
223
+ coord[0] === intersection.geometry.coordinates[0] &&
224
+ coord[1] === intersection.geometry.coordinates[1],
225
+ ),
226
+ );
227
+
228
+ if (overlapping.length > 0) {
229
+ // ❌ Invalid polygon: overlaps
230
+ props.onEdit({
231
+ updatedData: props.data,
232
+ editType: "invalidPolygon",
233
+ editContext: { reason: "overlaps" },
234
+ });
235
+ this.resetClickSequence();
236
+ return;
237
+ }
238
+ }
239
+
240
+ if (canAddHole) {
241
+ const holeResult = this.tryAddHoleToExistingPolygon(newPolygon, polygon, props);
242
+ if (holeResult.handled) {
243
+ this.resetClickSequence();
244
+ return;
245
+ }
246
+ }
247
+
248
+ // If no valid hole was found, add the polygon as a new feature
249
+ const editAction = this.getAddFeatureOrBooleanPolygonAction(
250
+ {
251
+ type: "Polygon",
252
+ coordinates: [[...this.getClickSequence(), this.getClickSequence()[0]]],
253
+ },
254
+ props,
255
+ );
256
+ if (editAction) props.onEdit(editAction);
257
+ this.resetClickSequence();
258
+ return;
259
+ }
260
+
261
+ private tryAddHoleToExistingPolygon(
262
+ newPolygon: any,
263
+ polygon: Position[],
264
+ props: ModeProps<FeatureCollection>
265
+ ): { handled: boolean } {
266
+ for (const [featureIndex, feature] of props.data.features.entries()) {
267
+ if (feature.geometry.type === "Polygon") {
268
+ const result = this.validateAndCreateHole(feature, featureIndex, newPolygon, polygon, props);
269
+ if (result.handled) {
270
+ return result;
271
+ }
272
+ }
273
+ }
274
+
275
+ return { handled: false };
276
+ }
277
+
278
+ private validateAndCreateHole(
279
+ feature: any,
280
+ featureIndex: number,
281
+ newPolygon: any,
282
+ polygon: Position[],
283
+ props: ModeProps<FeatureCollection>
284
+ ): { handled: boolean } {
285
+ const outer = turfPolygon(feature.geometry.coordinates);
286
+
287
+ // Check existing holes for conflicts
288
+ for (let i = 1; i < feature.geometry.coordinates.length; i++) {
289
+ const hole = turfPolygon([feature.geometry.coordinates[i]]);
290
+ const intersection = lineIntersect(hole, newPolygon);
291
+
292
+ if (intersection.features.length > 0) {
293
+ props.onEdit({
294
+ updatedData: props.data,
295
+ editType: "invalidHole",
296
+ editContext: { reason: "intersects-existing-hole" },
297
+ });
298
+ return { handled: true };
299
+ }
300
+
301
+ if (booleanWithin(hole, newPolygon) || booleanWithin(newPolygon, hole)) {
302
+ props.onEdit({
303
+ updatedData: props.data,
304
+ editType: "invalidHole",
305
+ editContext: { reason: "contains-or-contained-by-existing-hole" },
306
+ });
307
+ return { handled: true };
308
+ }
309
+ }
310
+
311
+ // Check outer polygon conflicts
312
+ const intersectionWithOuter = lineIntersect(outer, newPolygon);
313
+ if (intersectionWithOuter.features.length > 0) {
314
+ props.onEdit({
315
+ updatedData: props.data,
316
+ editType: "invalidPolygon",
317
+ editContext: { reason: "intersects-existing-polygon" },
318
+ });
319
+ return { handled: true };
320
+ }
321
+
322
+ if (booleanWithin(outer, newPolygon)) {
323
+ props.onEdit({
324
+ updatedData: props.data,
325
+ editType: "invalidPolygon",
326
+ editContext: { reason: "contains-existing-polygon" },
327
+ });
328
+ return { handled: true };
329
+ }
330
+
331
+ // Check if new polygon is within outer polygon (valid hole)
332
+ if (booleanWithin(newPolygon, outer)) {
333
+ const updatedData = new ImmutableFeatureCollection(props.data)
334
+ .replaceGeometry(featureIndex, {
335
+ ...feature.geometry,
336
+ coordinates: [...feature.geometry.coordinates, polygon],
337
+ })
338
+ .getObject();
339
+
340
+ props.onEdit({
341
+ updatedData,
342
+ editType: "addHole",
343
+ editContext: { hole: newPolygon.geometry },
344
+ });
345
+ return { handled: true };
346
+ }
347
+ return { handled: false };
348
+ }
349
+ }
350
+
351
+ // Helper function to check if a point is near the first point in the sequence
352
+ function isNearFirstPoint(
353
+ click: Position,
354
+ first: Position,
355
+ threshold = 1e-4,
356
+ ): boolean {
357
+ const dx = click[0] - first[0];
358
+ const dy = click[1] - first[1];
359
+ return dx * dx + dy * dy < threshold * threshold;
360
+ }
361
+
362
+ // Helper function to determine if a hole can be added to a polygon
363
+ function canAddHoleToPolygon(
364
+ props: ModeProps<FeatureCollection>
365
+ ): boolean {
366
+ // For simplicity, always return true in this example.
367
+ // Implement your own logic based on application requirements.
368
+ return props.modeConfig?.allowHoles ?? false;
369
+ }
370
+
371
+ // Helper function to determine if a polygon can intersect itself
372
+ function canPolygonOverlap(
373
+ props: ModeProps<FeatureCollection>
374
+ ): boolean {
375
+ // Return the value of allowSelfIntersection (defaults to false for safety)
376
+ return props.modeConfig?.allowSelfIntersection ?? false;
173
377
  }