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