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