@mongoosejs/studio 0.5.11 → 0.5.13

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.
@@ -0,0 +1,1104 @@
1
+ 'use strict';
2
+
3
+ /* global L */
4
+ const template = require('./edit-geojson.html');
5
+ const appendCSS = require('../appendCSS');
6
+
7
+ // Add CSS for polygon vertex markers and context menu
8
+ appendCSS(`
9
+ .polygon-vertex-marker {
10
+ pointer-events: auto !important;
11
+ }
12
+ .polygon-vertex-marker > div {
13
+ pointer-events: auto !important;
14
+ }
15
+ .leaflet-context-menu {
16
+ position: absolute;
17
+ background: white;
18
+ border: 1px solid #ccc;
19
+ border-radius: 4px;
20
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
21
+ z-index: 10000;
22
+ min-width: 120px;
23
+ padding: 4px 0;
24
+ }
25
+ .leaflet-context-menu-item {
26
+ padding: 8px 16px;
27
+ cursor: pointer;
28
+ font-size: 14px;
29
+ color: #333;
30
+ }
31
+ .leaflet-context-menu-item:hover {
32
+ background-color: #f0f0f0;
33
+ }
34
+ .leaflet-context-menu-item.delete {
35
+ color: #dc3545;
36
+ }
37
+ .leaflet-context-menu-item.delete:hover {
38
+ background-color: #fee;
39
+ }
40
+ `);
41
+
42
+ module.exports = app => app.component('edit-geojson', {
43
+ template: template,
44
+ props: ['value', 'viewMode'],
45
+ emits: ['input'],
46
+ data() {
47
+ return {
48
+ mapVisible: false,
49
+ mapInstance: null,
50
+ mapLayer: null,
51
+ mapTileLayer: null,
52
+ draggableMarker: null,
53
+ draggableMarkers: [], // For polygon vertices
54
+ hasUnsavedChanges: false,
55
+ currentEditedGeometry: null, // Track the current edited geometry state
56
+ contextMenu: null, // Custom context menu element
57
+ contextMenuMarker: null, // Marker that triggered context menu
58
+ originalGeometry: null, // Store the original geometry when editing starts
59
+ isCreatingMarkers: false // Guard against re-entrant marker creation
60
+ };
61
+ },
62
+ computed: {
63
+ displayValue() {
64
+ if (this.value === null) {
65
+ return 'null';
66
+ }
67
+ if (this.value === undefined) {
68
+ return 'undefined';
69
+ }
70
+ if (typeof this.value === 'string') {
71
+ return this.value;
72
+ }
73
+ if (typeof this.value === 'number' || typeof this.value === 'boolean' || typeof this.value === 'bigint') {
74
+ return String(this.value);
75
+ }
76
+ try {
77
+ return JSON.stringify(this.value, null, 2);
78
+ } catch (err) {
79
+ return String(this.value);
80
+ }
81
+ },
82
+ isGeoJsonGeometry() {
83
+ return this.value != null
84
+ && typeof this.value === 'object'
85
+ && !Array.isArray(this.value)
86
+ && Object.prototype.hasOwnProperty.call(this.value, 'type')
87
+ && Object.prototype.hasOwnProperty.call(this.value, 'coordinates');
88
+ },
89
+ isGeoJsonPoint() {
90
+ return this.isGeoJsonGeometry && this.value.type === 'Point';
91
+ },
92
+ isGeoJsonPolygon() {
93
+ return this.isGeoJsonGeometry && (this.value.type === 'Polygon' || this.value.type === 'MultiPolygon');
94
+ },
95
+ isMultiPolygon() {
96
+ return this.isGeoJsonGeometry && this.value.type === 'MultiPolygon';
97
+ },
98
+ isEditable() {
99
+ return (this.isGeoJsonPoint || this.isGeoJsonPolygon);
100
+ },
101
+ canUndo() {
102
+ // Can undo if there are any changes from the original geometry
103
+ return this.hasUnsavedChanges && this.originalGeometry != null;
104
+ }
105
+ },
106
+ watch: {
107
+ viewMode: {
108
+ handler(newValue) {
109
+ this.mapVisible = newValue === 'map';
110
+ if (this.mapVisible) {
111
+ this.$nextTick(() => {
112
+ this.ensureMap();
113
+ });
114
+ }
115
+ },
116
+ immediate: true
117
+ },
118
+ value: {
119
+ handler(newValue) {
120
+ if (this.mapVisible) {
121
+ this.$nextTick(() => {
122
+ this.ensureMap();
123
+ });
124
+ }
125
+ // Reset unsaved changes flag when value changes externally (e.g., after save)
126
+ if (this.hasUnsavedChanges && (this.isGeoJsonPoint || this.isGeoJsonPolygon)) {
127
+ this.hasUnsavedChanges = false;
128
+ this.currentEditedGeometry = null; // Reset edited geometry when value changes externally
129
+ }
130
+ // Store the new value as the original geometry for future edits
131
+ if (newValue && this.isGeoJsonGeometry) {
132
+ this.originalGeometry = JSON.parse(JSON.stringify(newValue));
133
+ } else {
134
+ this.originalGeometry = null;
135
+ }
136
+ },
137
+ deep: true,
138
+ immediate: true
139
+ }
140
+ },
141
+ beforeDestroy() {
142
+ this.hideContextMenu();
143
+ document.documentElement.removeEventListener('studio-theme-changed', this._onStudioThemeChanged);
144
+ if (this.draggableMarker) {
145
+ this.draggableMarker.remove();
146
+ this.draggableMarker = null;
147
+ }
148
+ // Clean up polygon vertex markers
149
+ this.draggableMarkers.forEach(marker => {
150
+ if (marker) {
151
+ marker.remove();
152
+ }
153
+ });
154
+ this.draggableMarkers = [];
155
+ if (this.mapInstance) {
156
+ if (this.mapTileLayer) {
157
+ this.mapTileLayer.remove();
158
+ this.mapTileLayer = null;
159
+ }
160
+ this.mapInstance.remove();
161
+ this.mapInstance = null;
162
+ this.mapLayer = null;
163
+ }
164
+ },
165
+ methods: {
166
+ ensureMap() {
167
+ if (!this.mapVisible || !this.isGeoJsonGeometry || !this.$refs.map) {
168
+ return;
169
+ }
170
+
171
+ if (typeof L === 'undefined') {
172
+ return;
173
+ }
174
+
175
+ if (!this.mapInstance) {
176
+ // Ensure the map container has explicit dimensions
177
+ const mapElement = this.$refs.map;
178
+ if (mapElement) {
179
+ // Set explicit dimensions inline with !important to override any CSS
180
+ mapElement.style.setProperty('height', '256px', 'important');
181
+ mapElement.style.setProperty('min-height', '256px', 'important');
182
+ mapElement.style.setProperty('width', '100%', 'important');
183
+ mapElement.style.setProperty('display', 'block', 'important');
184
+
185
+ // Force a reflow to ensure dimensions are applied
186
+ void mapElement.offsetHeight;
187
+ }
188
+
189
+ try {
190
+ this.mapInstance = L.map(this.$refs.map, {
191
+ preferCanvas: false
192
+ }).setView([0, 0], 1);
193
+
194
+ // Ensure map container has relative positioning for context menu
195
+ const mapContainer = this.mapInstance.getContainer();
196
+ if (mapContainer) {
197
+ mapContainer.style.position = 'relative';
198
+ }
199
+
200
+ this.updateMapTileLayer();
201
+ this._onStudioThemeChanged = () => this.updateMapTileLayer();
202
+ document.documentElement.addEventListener('studio-theme-changed', this._onStudioThemeChanged);
203
+
204
+ // Explicitly invalidate size after creation to ensure proper rendering
205
+ this.$nextTick(() => {
206
+ if (this.mapInstance) {
207
+ this.mapInstance.invalidateSize();
208
+ }
209
+ });
210
+ } catch (error) {
211
+ return;
212
+ }
213
+ }
214
+
215
+ this.updateMapLayer();
216
+ this.$nextTick(() => {
217
+ if (this.mapInstance) {
218
+ this.mapInstance.invalidateSize();
219
+ }
220
+ });
221
+ },
222
+ getMapTileLayerOptions() {
223
+ const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
224
+ return isDark
225
+ ? { url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>', subdomains: 'abcd', maxZoom: 20 }
226
+ : { url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '&copy; OpenStreetMap contributors' };
227
+ },
228
+ updateMapTileLayer() {
229
+ if (!this.mapInstance || typeof L === 'undefined') return;
230
+ if (this.mapTileLayer) {
231
+ this.mapTileLayer.remove();
232
+ this.mapTileLayer = null;
233
+ }
234
+ const opts = this.getMapTileLayerOptions();
235
+ this.mapTileLayer = L.tileLayer(opts.url, opts).addTo(this.mapInstance);
236
+ },
237
+ updateMapLayer() {
238
+ if (!this.mapInstance || !this.isGeoJsonGeometry) {
239
+ return;
240
+ }
241
+
242
+ // For Point geometries in edit mode, use a draggable marker
243
+ if (this.isGeoJsonPoint && this.isEditable) {
244
+ const [lng, lat] = this.value.coordinates;
245
+
246
+ // If marker exists, update its position instead of recreating
247
+ if (this.draggableMarker) {
248
+ const currentLatLng = this.draggableMarker.getLatLng();
249
+ // Only update if coordinates actually changed (avoid interrupting drag)
250
+ if (Math.abs(currentLatLng.lat - lat) > 0.0001 || Math.abs(currentLatLng.lng - lng) > 0.0001) {
251
+ this.draggableMarker.setLatLng([lat, lng]);
252
+ }
253
+ } else {
254
+ // Create new draggable marker
255
+ this.draggableMarker = L.marker([lat, lng], {
256
+ draggable: true
257
+ }).addTo(this.mapInstance);
258
+
259
+ // Add dragend event handler
260
+ this.draggableMarker.on('dragend', () => {
261
+ const newLat = this.draggableMarker.getLatLng().lat;
262
+ const newLng = this.draggableMarker.getLatLng().lng;
263
+ const newGeometry = {
264
+ type: 'Point',
265
+ coordinates: [newLng, newLat]
266
+ };
267
+ this.hasUnsavedChanges = true;
268
+ this.$emit('input', newGeometry);
269
+ });
270
+
271
+ // Center map on marker with appropriate zoom level
272
+ const currentZoom = this.mapInstance.getZoom();
273
+ // If zoom is too low (less than 10), set a good default zoom level (13)
274
+ const targetZoom = currentZoom < 10 ? 13 : currentZoom;
275
+ this.mapInstance.setView([lat, lng], targetZoom);
276
+ }
277
+
278
+ // Remove any existing non-draggable layer
279
+ if (this.mapLayer) {
280
+ this.mapLayer.remove();
281
+ this.mapLayer = null;
282
+ }
283
+
284
+ // Clean up polygon markers if they exist
285
+ this.draggableMarkers.forEach(marker => {
286
+ if (marker) marker.remove();
287
+ });
288
+ this.draggableMarkers = [];
289
+ } else if (this.isGeoJsonPolygon && this.isEditable) {
290
+ // Initialize current edited geometry if not set
291
+ if (!this.currentEditedGeometry) {
292
+ this.currentEditedGeometry = JSON.parse(JSON.stringify(this.value));
293
+ }
294
+
295
+ // For Polygon geometries in edit mode, create polygon layer with draggable vertex markers
296
+ // Use current edited geometry if available, otherwise use value
297
+ const polygonGeometryToUse = this.currentEditedGeometry || this.value;
298
+ const feature = {
299
+ type: 'Feature',
300
+ geometry: polygonGeometryToUse,
301
+ properties: {}
302
+ };
303
+
304
+ // Update or create polygon layer
305
+ if (this.mapLayer) {
306
+ this.mapLayer.remove();
307
+ }
308
+ this.mapLayer = L.geoJSON(feature, {
309
+ style: {
310
+ color: '#3388ff',
311
+ weight: 2,
312
+ opacity: 0.8,
313
+ fillOpacity: 0.2
314
+ },
315
+ interactive: this.isMultiPolygon // Only interactive for MultiPolygon (to allow edge clicks)
316
+ }).addTo(this.mapInstance);
317
+
318
+ // Add contextmenu handler to polygon edges to add vertices (only for MultiPolygon)
319
+ if (this.isMultiPolygon) {
320
+ this.mapLayer.eachLayer((layer) => {
321
+ layer.on('contextmenu', (e) => {
322
+ e.originalEvent.preventDefault();
323
+ e.originalEvent.stopPropagation();
324
+
325
+ // Check if clicking near an existing marker (using pixel distance)
326
+ const clickPoint = e.latlng;
327
+ const clickContainerPoint = this.mapInstance.latLngToContainerPoint(clickPoint);
328
+
329
+ const isClickingOnMarker = this.draggableMarkers.some(marker => {
330
+ if (!marker || !marker._icon) return false;
331
+ const markerLatLng = marker.getLatLng();
332
+ const markerContainerPoint = this.mapInstance.latLngToContainerPoint(markerLatLng);
333
+
334
+ // Calculate pixel distance
335
+ const dx = clickContainerPoint.x - markerContainerPoint.x;
336
+ const dy = clickContainerPoint.y - markerContainerPoint.y;
337
+ const pixelDistance = Math.sqrt(dx * dx + dy * dy);
338
+
339
+ // 15 pixel threshold - marker icon is about 12px, so 15px gives some buffer
340
+ return pixelDistance < 15;
341
+ });
342
+
343
+ if (!isClickingOnMarker) {
344
+ this.showAddVertexContextMenu(e.originalEvent, clickPoint);
345
+ }
346
+ });
347
+ });
348
+ }
349
+
350
+ // Make polygon layers non-interactive to avoid interfering with marker dragging
351
+ this.mapLayer.eachLayer((layer) => {
352
+ layer.setStyle({
353
+ interactive: this.isMultiPolygon, // Only interactive for MultiPolygon (for context menu)
354
+ stroke: true,
355
+ weight: 2,
356
+ opacity: 0.8
357
+ });
358
+ layer._path.style.pointerEvents = this.isMultiPolygon ? 'stroke' : 'none';
359
+ });
360
+
361
+ // Get the outer ring coordinates
362
+ // For Polygon: coordinates[0] is the outer ring
363
+ // For MultiPolygon: coordinates[0][0] is the first polygon's outer ring
364
+ // Use current edited geometry if available
365
+ const ringGeometryToUse = this.currentEditedGeometry || this.value;
366
+ let outerRing = [];
367
+
368
+ if (this.isMultiPolygon) {
369
+ // MultiPolygon structure: [[[[lng, lat], ...]], [[[lng, lat], ...]]]
370
+ // Get the first polygon's outer ring
371
+ if (ringGeometryToUse.coordinates[0] && ringGeometryToUse.coordinates[0][0]) {
372
+ outerRing = ringGeometryToUse.coordinates[0][0];
373
+ }
374
+ } else {
375
+ // Polygon structure: [[[lng, lat], ...]]
376
+ outerRing = ringGeometryToUse.coordinates[0] || [];
377
+ }
378
+
379
+ if (outerRing.length === 0) {
380
+ return;
381
+ }
382
+
383
+ // Determine how many markers we should have (accounting for closed rings)
384
+ const isClosedRing = outerRing.length > 0 &&
385
+ outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
386
+ outerRing[0][1] === outerRing[outerRing.length - 1][1];
387
+ const expectedMarkerCount = isClosedRing ? outerRing.length - 1 : outerRing.length;
388
+
389
+ // Remove existing markers if count doesn't match
390
+ if (this.draggableMarkers.length !== expectedMarkerCount && !this.isCreatingMarkers) {
391
+ this.isCreatingMarkers = true;
392
+ this.draggableMarkers.forEach(marker => {
393
+ if (marker) marker.remove();
394
+ });
395
+ this.draggableMarkers = [];
396
+
397
+ // Create draggable markers for each vertex
398
+ // Use setTimeout to ensure markers are added after polygon layer is fully rendered
399
+ this.$nextTick(() => {
400
+ // GeoJSON polygons typically have the first and last coordinate the same (closed ring)
401
+ // We'll create markers for all coordinates except the last one if it's a duplicate
402
+ // But we need to track the original index for updating the correct coordinate
403
+ const isClosedRing = outerRing.length > 0 &&
404
+ outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
405
+ outerRing[0][1] === outerRing[outerRing.length - 1][1];
406
+
407
+ const coordsToProcess = isClosedRing
408
+ ? outerRing.slice(0, -1) // Skip last coordinate if it's a duplicate of first
409
+ : outerRing;
410
+
411
+ coordsToProcess.forEach((coord, visibleIndex) => {
412
+ // visibleIndex is the index in the visible markers array
413
+ // actualIndex is the index in the coordinates array (same for non-closed rings)
414
+ const actualIndex = visibleIndex;
415
+ if (!Array.isArray(coord) || coord.length < 2) {
416
+ return;
417
+ }
418
+
419
+ const [lng, lat] = coord;
420
+
421
+ // Create a custom icon for the vertex marker
422
+ const icon = L.divIcon({
423
+ className: 'polygon-vertex-marker',
424
+ html: '<div style="width: 12px; height: 12px; background-color: #3388ff; border: 2px solid white; border-radius: 50%; cursor: move;"></div>',
425
+ iconSize: [12, 12],
426
+ iconAnchor: [6, 6]
427
+ });
428
+
429
+ const marker = L.marker([lat, lng], {
430
+ draggable: true,
431
+ icon: icon,
432
+ zIndexOffset: 1000, // Ensure markers are above polygon layer
433
+ riseOnHover: true, // Bring marker to front on hover
434
+ bubblingMouseEvents: false // Prevent events from bubbling to polygon
435
+ });
436
+
437
+ // Add marker to map
438
+ marker.addTo(this.mapInstance);
439
+
440
+ // Ensure marker is in the marker pane (above overlay pane where polygon is)
441
+ // Note: markers don't have bringToFront(), we use zIndexOffset and DOM manipulation
442
+ if (marker._icon) {
443
+ marker._icon.style.pointerEvents = 'auto';
444
+ marker._icon.style.zIndex = (1001 + visibleIndex).toString(); // Unique z-index for each marker
445
+ // Move marker icon to top of marker pane
446
+ if (marker._icon.parentNode) {
447
+ marker._icon.parentNode.appendChild(marker._icon);
448
+ }
449
+ }
450
+
451
+ // Add dragend event handler
452
+ // Store the actualIndex in closure for this marker
453
+ const markerActualIndex = actualIndex;
454
+ const markerIsFirstInClosedRing = isClosedRing && actualIndex === 0;
455
+
456
+ // Add right-click handler to show context menu
457
+ marker.on('contextmenu', (e) => {
458
+ e.originalEvent.preventDefault();
459
+ e.originalEvent.stopPropagation();
460
+ this.showContextMenu(e.originalEvent, markerActualIndex, marker);
461
+ return false;
462
+ });
463
+
464
+ // Also attach directly to icon element as the event might not bubble to marker
465
+ if (marker._icon) {
466
+ marker._icon.addEventListener('contextmenu', (e) => {
467
+ e.preventDefault();
468
+ e.stopPropagation();
469
+ this.showContextMenu(e, markerActualIndex, marker);
470
+ return false;
471
+ }, true); // Use capture phase
472
+ }
473
+
474
+ marker.on('dragend', () => {
475
+ const newLat = marker.getLatLng().lat;
476
+ const newLng = marker.getLatLng().lng;
477
+
478
+ // Use current edited geometry if available, otherwise use original value
479
+ const baseGeometry = this.currentEditedGeometry || this.value;
480
+ const newCoordinates = JSON.parse(JSON.stringify(baseGeometry.coordinates));
481
+
482
+ // Get the outer ring to check if it's closed
483
+ let outerRing = [];
484
+ if (this.isMultiPolygon) {
485
+ outerRing = newCoordinates[0][0] || [];
486
+ } else {
487
+ outerRing = newCoordinates[0] || [];
488
+ }
489
+
490
+ // Check if this is a closed ring
491
+ const isClosedRingNow = outerRing.length > 0 &&
492
+ outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
493
+ outerRing[0][1] === outerRing[outerRing.length - 1][1];
494
+
495
+ // Update the coordinate
496
+ if (this.isMultiPolygon) {
497
+ // MultiPolygon: coordinates[0][0][index] = [lng, lat]
498
+ newCoordinates[0][0][markerActualIndex] = [newLng, newLat];
499
+ // If closed ring and this is the first coordinate, also update the closing coordinate
500
+ if (isClosedRingNow && markerIsFirstInClosedRing) {
501
+ newCoordinates[0][0][outerRing.length - 1] = [newLng, newLat];
502
+ }
503
+ } else {
504
+ // Polygon: coordinates[0][index] = [lng, lat]
505
+ newCoordinates[0][markerActualIndex] = [newLng, newLat];
506
+ // If closed ring and this is the first coordinate, also update the closing coordinate
507
+ if (isClosedRingNow && markerIsFirstInClosedRing) {
508
+ newCoordinates[0][outerRing.length - 1] = [newLng, newLat];
509
+ }
510
+ }
511
+
512
+ // Validate coordinate structure before creating geometry
513
+ try {
514
+ // Ensure coordinates are valid numbers
515
+ if (isNaN(newLng) || isNaN(newLat)) {
516
+ throw new Error(`Invalid coordinates: [${newLng}, ${newLat}]`);
517
+ }
518
+
519
+ const newGeometry = {
520
+ type: baseGeometry.type, // Preserve Polygon or MultiPolygon type
521
+ coordinates: newCoordinates
522
+ };
523
+
524
+ // Validate the geometry structure
525
+ if (!newGeometry.type || !Array.isArray(newGeometry.coordinates)) {
526
+ throw new Error('Invalid geometry structure');
527
+ }
528
+
529
+ // Store the current edited geometry state
530
+ this.currentEditedGeometry = newGeometry;
531
+ this.hasUnsavedChanges = true;
532
+
533
+ // Update the polygon layer immediately for visual feedback
534
+ this.updatePolygonLayer(newGeometry);
535
+
536
+ // Notify parent of the change
537
+ this.$emit('input', newGeometry);
538
+ } catch (error) {
539
+ // Log errors to aid debugging while still preventing UI crashes
540
+ console.error('Error updating geometry on marker dragend:', error);
541
+ }
542
+ });
543
+
544
+ this.draggableMarkers.push(marker);
545
+ });
546
+ // Reset the guard after all markers are created
547
+ this.isCreatingMarkers = false;
548
+ });
549
+ } else {
550
+ // Update existing marker positions if coordinates changed
551
+ // Get the correct outer ring based on geometry type
552
+ // Use current edited geometry if available
553
+ const updateGeometryToUse = this.currentEditedGeometry || this.value;
554
+ let currentOuterRing = [];
555
+ if (this.isMultiPolygon) {
556
+ if (updateGeometryToUse.coordinates[0] && updateGeometryToUse.coordinates[0][0]) {
557
+ currentOuterRing = updateGeometryToUse.coordinates[0][0];
558
+ }
559
+ } else {
560
+ currentOuterRing = updateGeometryToUse.coordinates[0] || [];
561
+ }
562
+
563
+ currentOuterRing.forEach((coord, index) => {
564
+ const [lng, lat] = coord;
565
+ const marker = this.draggableMarkers[index];
566
+ if (marker) {
567
+ const currentLatLng = marker.getLatLng();
568
+ // Only update if coordinates actually changed (avoid interrupting drag)
569
+ if (Math.abs(currentLatLng.lat - lat) > 0.0001 || Math.abs(currentLatLng.lng - lng) > 0.0001) {
570
+ marker.setLatLng([lat, lng]);
571
+ }
572
+ }
573
+ });
574
+
575
+ // Update polygon layer to reflect any coordinate changes
576
+ // Use current edited geometry if available, otherwise use value
577
+ const layerGeometryToUse = this.currentEditedGeometry || this.value;
578
+ this.updatePolygonLayer(layerGeometryToUse);
579
+ }
580
+
581
+ // Remove point marker if it exists
582
+ if (this.draggableMarker) {
583
+ this.draggableMarkers = this.draggableMarkers.filter(marker => marker !== this.draggableMarker);
584
+ this.draggableMarker.remove();
585
+ this.draggableMarker = null;
586
+ }
587
+
588
+ // Fit bounds to polygon
589
+ const bounds = this.mapLayer.getBounds();
590
+ if (bounds.isValid()) {
591
+ const currentZoom = this.mapInstance.getZoom();
592
+ if (currentZoom < 10) {
593
+ this.mapInstance.fitBounds(bounds, { maxZoom: 16 });
594
+ } else {
595
+ // Just ensure polygon is visible, don't change zoom if already zoomed in
596
+ if (!bounds.contains(this.mapInstance.getBounds())) {
597
+ this.mapInstance.fitBounds(bounds, { maxZoom: 16 });
598
+ }
599
+ }
600
+ }
601
+ } else {
602
+ // For other geometries or non-editable mode, use standard GeoJSON layer
603
+ if (this.draggableMarker) {
604
+ this.draggableMarker.remove();
605
+ this.draggableMarker = null;
606
+ }
607
+
608
+ // Clean up polygon markers
609
+ this.draggableMarkers.forEach(marker => {
610
+ if (marker) marker.remove();
611
+ });
612
+ this.draggableMarkers = [];
613
+
614
+ if (this.mapLayer) {
615
+ this.mapLayer.remove();
616
+ }
617
+
618
+ const feature = {
619
+ type: 'Feature',
620
+ geometry: this.value,
621
+ properties: {}
622
+ };
623
+
624
+ this.mapLayer = L.geoJSON(feature).addTo(this.mapInstance);
625
+ const bounds = this.mapLayer.getBounds();
626
+ if (bounds.isValid()) {
627
+ this.mapInstance.fitBounds(bounds, { maxZoom: 16 });
628
+ }
629
+ }
630
+ },
631
+ updatePolygonLayer(geometry) {
632
+ if (!this.mapInstance || !this.mapLayer) {
633
+ return;
634
+ }
635
+
636
+ // Remove old layer
637
+ this.mapLayer.remove();
638
+
639
+ // Create new layer with updated geometry
640
+ const feature = {
641
+ type: 'Feature',
642
+ geometry: geometry,
643
+ properties: {}
644
+ };
645
+
646
+ this.mapLayer = L.geoJSON(feature, {
647
+ style: {
648
+ color: '#3388ff',
649
+ weight: 2,
650
+ opacity: 0.8,
651
+ fillOpacity: 0.2
652
+ },
653
+ interactive: this.isMultiPolygon // Only interactive for MultiPolygon (to allow edge clicks)
654
+ }).addTo(this.mapInstance);
655
+
656
+ // Add contextmenu handler to polygon edges to add vertices (only for MultiPolygon)
657
+ if (this.isMultiPolygon && this.mapLayer.eachLayer) {
658
+ this.mapLayer.eachLayer((layer) => {
659
+ // Remove any existing contextmenu handlers first
660
+ layer.off('contextmenu');
661
+
662
+ layer.on('contextmenu', (e) => {
663
+ e.originalEvent.preventDefault();
664
+ e.originalEvent.stopPropagation();
665
+
666
+ const clickPoint = e.latlng;
667
+ const clickContainerPoint = this.mapInstance.latLngToContainerPoint(clickPoint);
668
+
669
+ // Check if clicking near an existing marker
670
+ const isClickingOnMarker = this.draggableMarkers.some(marker => {
671
+ if (!marker || !marker._icon) return false;
672
+ const markerLatLng = marker.getLatLng();
673
+ const markerContainerPoint = this.mapInstance.latLngToContainerPoint(markerLatLng);
674
+
675
+ const dx = clickContainerPoint.x - markerContainerPoint.x;
676
+ const dy = clickContainerPoint.y - markerContainerPoint.y;
677
+ const pixelDistance = Math.sqrt(dx * dx + dy * dy);
678
+
679
+ return pixelDistance < 15;
680
+ });
681
+
682
+ if (!isClickingOnMarker) {
683
+ this.showAddVertexContextMenu(e.originalEvent, clickPoint);
684
+ }
685
+ });
686
+
687
+ // Style polygon layer
688
+ if (layer.setStyle) {
689
+ layer.setStyle({
690
+ interactive: true,
691
+ stroke: true,
692
+ weight: 2,
693
+ opacity: 0.8
694
+ });
695
+ }
696
+
697
+ if (layer._path) {
698
+ layer._path.style.pointerEvents = 'stroke';
699
+ }
700
+ });
701
+ } else if (!this.isMultiPolygon && this.mapLayer.eachLayer) {
702
+ // For regular Polygon, ensure it's non-interactive
703
+ this.mapLayer.eachLayer((layer) => {
704
+ layer.off('contextmenu'); // Remove any contextmenu handlers
705
+ if (layer.setStyle) {
706
+ layer.setStyle({ interactive: false });
707
+ }
708
+ if (layer._path) {
709
+ layer._path.style.pointerEvents = 'none';
710
+ }
711
+ });
712
+ }
713
+
714
+ // Bring all markers to front after updating polygon
715
+ // Note: markers don't have bringToFront(), we manipulate DOM directly
716
+ this.draggableMarkers.forEach((marker, index) => {
717
+ if (marker && marker._icon && marker._icon.parentNode) {
718
+ marker._icon.style.zIndex = (1001 + index).toString();
719
+ marker._icon.parentNode.appendChild(marker._icon);
720
+ }
721
+ });
722
+ },
723
+ showContextMenu(event, index, marker) {
724
+ // Hide any existing context menu
725
+ this.hideContextMenu();
726
+
727
+ // Store the marker for deletion
728
+ this.contextMenuMarker = { index, marker, type: 'vertex' };
729
+
730
+ // Create context menu if it doesn't exist
731
+ if (!this.contextMenu) {
732
+ this.contextMenu = document.createElement('div');
733
+ this.contextMenu.className = 'leaflet-context-menu';
734
+
735
+ // Append to map container so it's positioned relative to the map
736
+ if (this.mapInstance && this.mapInstance.getContainer()) {
737
+ this.mapInstance.getContainer().appendChild(this.contextMenu);
738
+ } else {
739
+ document.body.appendChild(this.contextMenu);
740
+ }
741
+ }
742
+
743
+ // Clear existing menu items
744
+ this.contextMenu.innerHTML = '';
745
+
746
+ // Add Delete option for vertices
747
+ const deleteItem = document.createElement('div');
748
+ deleteItem.className = 'leaflet-context-menu-item delete';
749
+ deleteItem.textContent = 'Delete';
750
+ deleteItem.addEventListener('click', (e) => {
751
+ e.stopPropagation();
752
+ if (this.contextMenuMarker && this.contextMenuMarker.type === 'vertex') {
753
+ this.deleteVertex(this.contextMenuMarker.index, this.contextMenuMarker.marker);
754
+ }
755
+ this.hideContextMenu();
756
+ });
757
+ this.contextMenu.appendChild(deleteItem);
758
+
759
+ // Get map container position for relative positioning
760
+ const mapContainer = this.mapInstance ? this.mapInstance.getContainer() : null;
761
+ let left = event.clientX;
762
+ let top = event.clientY;
763
+
764
+ if (mapContainer) {
765
+ const rect = mapContainer.getBoundingClientRect();
766
+ left = event.clientX - rect.left;
767
+ top = event.clientY - rect.top;
768
+ this.contextMenu.style.position = 'absolute';
769
+ } else {
770
+ this.contextMenu.style.position = 'fixed';
771
+ }
772
+
773
+ // Position the context menu at the click location
774
+ this.contextMenu.style.left = left + 'px';
775
+ this.contextMenu.style.top = top + 'px';
776
+ this.contextMenu.style.display = 'block';
777
+
778
+ // Hide context menu when clicking elsewhere
779
+ const hideMenu = (e) => {
780
+ if (this.contextMenu && !this.contextMenu.contains(e.target)) {
781
+ this.hideContextMenu();
782
+ document.removeEventListener('click', hideMenu);
783
+ document.removeEventListener('contextmenu', hideMenu);
784
+ }
785
+ };
786
+
787
+ // Use setTimeout to avoid immediate hide from the current click
788
+ setTimeout(() => {
789
+ document.addEventListener('click', hideMenu);
790
+ document.addEventListener('contextmenu', hideMenu);
791
+ }, 10);
792
+ },
793
+ hideContextMenu() {
794
+ if (this.contextMenu) {
795
+ this.contextMenu.style.display = 'none';
796
+ }
797
+ this.contextMenuMarker = null;
798
+ },
799
+ showAddVertexContextMenu(event, latlng) {
800
+ // Hide any existing context menu
801
+ this.hideContextMenu();
802
+
803
+ // Store the location for adding vertex
804
+ this.contextMenuMarker = { latlng, type: 'edge' };
805
+
806
+ // Create context menu if it doesn't exist
807
+ if (!this.contextMenu) {
808
+ this.contextMenu = document.createElement('div');
809
+ this.contextMenu.className = 'leaflet-context-menu';
810
+
811
+ // Append to map container so it's positioned relative to the map
812
+ if (this.mapInstance && this.mapInstance.getContainer()) {
813
+ this.mapInstance.getContainer().appendChild(this.contextMenu);
814
+ } else {
815
+ document.body.appendChild(this.contextMenu);
816
+ }
817
+ }
818
+
819
+ // Clear existing menu items
820
+ this.contextMenu.innerHTML = '';
821
+
822
+ // Add "Add Vertex" option
823
+ const addVertexItem = document.createElement('div');
824
+ addVertexItem.className = 'leaflet-context-menu-item';
825
+ addVertexItem.textContent = 'Add Vertex';
826
+ addVertexItem.addEventListener('click', (e) => {
827
+ e.stopPropagation();
828
+ if (this.contextMenuMarker && this.contextMenuMarker.type === 'edge') {
829
+ this.addVertexAtLocation(this.contextMenuMarker.latlng);
830
+ }
831
+ this.hideContextMenu();
832
+ });
833
+ this.contextMenu.appendChild(addVertexItem);
834
+
835
+ // Position the context menu
836
+ const mapContainer = this.mapInstance ? this.mapInstance.getContainer() : null;
837
+ let left = event.clientX;
838
+ let top = event.clientY;
839
+
840
+ if (mapContainer) {
841
+ const rect = mapContainer.getBoundingClientRect();
842
+ left = event.clientX - rect.left;
843
+ top = event.clientY - rect.top;
844
+ this.contextMenu.style.position = 'absolute';
845
+ } else {
846
+ this.contextMenu.style.position = 'fixed';
847
+ }
848
+
849
+ this.contextMenu.style.left = left + 'px';
850
+ this.contextMenu.style.top = top + 'px';
851
+ this.contextMenu.style.display = 'block';
852
+
853
+ // Hide menu when clicking elsewhere
854
+ const hideMenu = (e) => {
855
+ if (!this.contextMenu || !this.contextMenu.contains(e.target)) {
856
+ this.hideContextMenu();
857
+ document.removeEventListener('click', hideMenu);
858
+ }
859
+ };
860
+ setTimeout(() => {
861
+ document.addEventListener('click', hideMenu);
862
+ }, 0);
863
+ },
864
+ addVertexAtLocation(latlng) {
865
+ // Get current geometry
866
+ const baseGeometry = this.currentEditedGeometry || this.value;
867
+ const newCoordinates = JSON.parse(JSON.stringify(baseGeometry.coordinates));
868
+
869
+ // Get the outer ring
870
+ let outerRing = [];
871
+ if (this.isMultiPolygon) {
872
+ outerRing = newCoordinates[0][0] || [];
873
+ } else {
874
+ outerRing = newCoordinates[0] || [];
875
+ }
876
+
877
+ if (outerRing.length === 0) {
878
+ return;
879
+ }
880
+
881
+ // Check if this is a closed ring
882
+ const isClosedRing = outerRing.length > 0 &&
883
+ outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
884
+ outerRing[0][1] === outerRing[outerRing.length - 1][1];
885
+
886
+ // Convert latlng to [lng, lat] format
887
+ const newCoord = [latlng.lng, latlng.lat];
888
+
889
+ // Find the closest edge segment and insert the vertex
890
+ let closestEdgeIndex = 0;
891
+ let minDistance = Infinity;
892
+
893
+ // Check each edge segment
894
+ for (let i = 0; i < outerRing.length - 1; i++) {
895
+ const p1 = outerRing[i];
896
+ const p2 = outerRing[i + 1];
897
+
898
+ // Calculate distance from click point to edge segment
899
+ const distance = this.distanceToLineSegment(
900
+ [latlng.lng, latlng.lat],
901
+ [p1[0], p1[1]],
902
+ [p2[0], p2[1]]
903
+ );
904
+
905
+ if (distance < minDistance) {
906
+ minDistance = distance;
907
+ closestEdgeIndex = i + 1; // Insert after point i
908
+ }
909
+ }
910
+
911
+ // For closed rings, also check the edge from last to first
912
+ if (isClosedRing) {
913
+ const p1 = outerRing[outerRing.length - 1];
914
+ const p2 = outerRing[0];
915
+ const distance = this.distanceToLineSegment(
916
+ [latlng.lng, latlng.lat],
917
+ [p1[0], p1[1]],
918
+ [p2[0], p2[1]]
919
+ );
920
+
921
+ if (distance < minDistance) {
922
+ minDistance = distance;
923
+ closestEdgeIndex = outerRing.length; // Insert at end (before closing coordinate)
924
+ }
925
+ }
926
+
927
+ // Insert the new coordinate
928
+ if (this.isMultiPolygon) {
929
+ newCoordinates[0][0].splice(closestEdgeIndex, 0, newCoord);
930
+ // If it was a closed ring, update the closing coordinate
931
+ if (isClosedRing) {
932
+ newCoordinates[0][0][newCoordinates[0][0].length - 1] = newCoordinates[0][0][0];
933
+ }
934
+ } else {
935
+ newCoordinates[0].splice(closestEdgeIndex, 0, newCoord);
936
+ // If it was a closed ring, update the closing coordinate
937
+ if (isClosedRing) {
938
+ newCoordinates[0][newCoordinates[0].length - 1] = newCoordinates[0][0];
939
+ }
940
+ }
941
+
942
+ const newGeometry = {
943
+ type: baseGeometry.type,
944
+ coordinates: newCoordinates
945
+ };
946
+
947
+ // Store the current edited geometry state
948
+ this.currentEditedGeometry = newGeometry;
949
+ this.hasUnsavedChanges = true;
950
+
951
+ // Update the polygon layer and recreate markers
952
+ this.updatePolygonLayer(newGeometry);
953
+ this.$nextTick(() => {
954
+ this.updateMapLayer();
955
+ });
956
+
957
+ // Notify parent of the change
958
+ this.$emit('input', newGeometry);
959
+ },
960
+ distanceToLineSegment(point, lineStart, lineEnd) {
961
+ // Calculate distance from point to line segment
962
+ // point, lineStart, lineEnd are [lng, lat] arrays
963
+ const A = point[0] - lineStart[0];
964
+ const B = point[1] - lineStart[1];
965
+ const C = lineEnd[0] - lineStart[0];
966
+ const D = lineEnd[1] - lineStart[1];
967
+
968
+ const dot = A * C + B * D;
969
+ const lenSq = C * C + D * D;
970
+ let param = -1;
971
+
972
+ if (lenSq !== 0) {
973
+ param = dot / lenSq;
974
+ }
975
+
976
+ let xx, yy;
977
+
978
+ if (param < 0) {
979
+ xx = lineStart[0];
980
+ yy = lineStart[1];
981
+ } else if (param > 1) {
982
+ xx = lineEnd[0];
983
+ yy = lineEnd[1];
984
+ } else {
985
+ xx = lineStart[0] + param * C;
986
+ yy = lineStart[1] + param * D;
987
+ }
988
+
989
+ const dx = point[0] - xx;
990
+ const dy = point[1] - yy;
991
+
992
+ // Use simple Euclidean distance (approximation for small areas)
993
+ return Math.sqrt(dx * dx + dy * dy);
994
+ },
995
+ deleteVertex(index, marker) {
996
+ // Get current geometry
997
+ const baseGeometry = this.currentEditedGeometry || this.value;
998
+ const newCoordinates = JSON.parse(JSON.stringify(baseGeometry.coordinates));
999
+
1000
+ // Get the outer ring
1001
+ let outerRing = [];
1002
+ if (this.isMultiPolygon) {
1003
+ outerRing = newCoordinates[0][0] || [];
1004
+ } else {
1005
+ outerRing = newCoordinates[0] || [];
1006
+ }
1007
+
1008
+ // Check if this is a closed ring
1009
+ const isClosedRing = outerRing.length > 0 &&
1010
+ outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
1011
+ outerRing[0][1] === outerRing[outerRing.length - 1][1];
1012
+
1013
+ // Minimum vertices for a valid polygon (3 for triangle, but GeoJSON requires at least 4 for closed ring)
1014
+ const minVertices = isClosedRing ? 4 : 3;
1015
+
1016
+ // Don't allow deletion if we'd have too few vertices
1017
+ const currentVertexCount = isClosedRing ? outerRing.length - 1 : outerRing.length;
1018
+ if (currentVertexCount < minVertices) {
1019
+ const message = isClosedRing
1020
+ ? `Cannot delete vertex. A polygon requires at least ${minVertices} vertices (including the closing vertex).`
1021
+ : `Cannot delete vertex. A polygon requires at least ${minVertices} vertices.`;
1022
+ this.$toast.error(message, {
1023
+ timeout: 5000
1024
+ });
1025
+ this.hideContextMenu();
1026
+ return; // Can't delete - would make invalid polygon
1027
+ }
1028
+
1029
+ // Remove the coordinate
1030
+ if (this.isMultiPolygon) {
1031
+ newCoordinates[0][0].splice(index, 1);
1032
+ // If it was a closed ring and we removed a coordinate, update the closing coordinate
1033
+ if (isClosedRing && index === 0) {
1034
+ // If we deleted the first coordinate, the new first becomes the closing coordinate
1035
+ newCoordinates[0][0][newCoordinates[0][0].length - 1] = newCoordinates[0][0][0];
1036
+ } else if (isClosedRing && index === outerRing.length - 1) {
1037
+ // If we deleted the closing coordinate, update it to match the first
1038
+ newCoordinates[0][0][newCoordinates[0][0].length - 1] = newCoordinates[0][0][0];
1039
+ }
1040
+ } else {
1041
+ newCoordinates[0].splice(index, 1);
1042
+ // If it was a closed ring and we removed a coordinate, update the closing coordinate
1043
+ if (isClosedRing && index === 0) {
1044
+ // If we deleted the first coordinate, the new first becomes the closing coordinate
1045
+ newCoordinates[0][newCoordinates[0].length - 1] = newCoordinates[0][0];
1046
+ } else if (isClosedRing && index === outerRing.length - 1) {
1047
+ // If we deleted the closing coordinate, update it to match the first
1048
+ newCoordinates[0][newCoordinates[0].length - 1] = newCoordinates[0][0];
1049
+ }
1050
+ }
1051
+
1052
+ const newGeometry = {
1053
+ type: baseGeometry.type,
1054
+ coordinates: newCoordinates
1055
+ };
1056
+
1057
+ // Store the current edited geometry state
1058
+ this.currentEditedGeometry = newGeometry;
1059
+ this.hasUnsavedChanges = true;
1060
+
1061
+ // Remove all markers and force recreation (needed because marker closures capture indices)
1062
+ this.draggableMarkers.forEach(m => {
1063
+ if (m) m.remove();
1064
+ });
1065
+ this.draggableMarkers = [];
1066
+
1067
+ // Update the polygon layer and recreate markers
1068
+ this.updatePolygonLayer(newGeometry);
1069
+ this.$nextTick(() => {
1070
+ this.updateMapLayer();
1071
+ });
1072
+
1073
+ // Notify parent of the change
1074
+ this.$emit('input', newGeometry);
1075
+ },
1076
+ undoDelete() {
1077
+ // Restore the original geometry (undo all changes)
1078
+ if (!this.originalGeometry) {
1079
+ return;
1080
+ }
1081
+
1082
+ const restoredGeometry = JSON.parse(JSON.stringify(this.originalGeometry));
1083
+
1084
+ // Clear all edited state
1085
+ this.currentEditedGeometry = null;
1086
+ this.hasUnsavedChanges = false;
1087
+
1088
+ // Update the map to show the original geometry
1089
+ if (this.isGeoJsonPoint) {
1090
+ this.$nextTick(() => {
1091
+ this.updateMapLayer();
1092
+ });
1093
+ } else if (this.isGeoJsonPolygon) {
1094
+ this.updatePolygonLayer(restoredGeometry);
1095
+ this.$nextTick(() => {
1096
+ this.updateMapLayer();
1097
+ });
1098
+ }
1099
+
1100
+ // Notify parent of the change (restore to original)
1101
+ this.$emit('input', restoredGeometry);
1102
+ }
1103
+ }
1104
+ });