@rural-environmental-registry/map_component 1.0.0 → 1.2.0

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.
Files changed (71) hide show
  1. package/README.md +444 -0
  2. package/dist/constants/coordinatePanel.d.ts +1 -1
  3. package/dist/handlers/drawingControl.d.ts +0 -1
  4. package/dist/handlers/mapHandler.d.ts +8 -3
  5. package/dist/handlers/mapToolsHandler.d.ts +39 -0
  6. package/dist/handlers/measureToolsHandler.d.ts +51 -0
  7. package/dist/handlers/memorialButtonControl.d.ts +19 -0
  8. package/dist/handlers/toolsConstants.d.ts +3 -0
  9. package/dist/index.css +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.mjs +32914 -25131
  12. package/dist/types/index.d.ts +69 -0
  13. package/dist/utils/areaCalculator.d.ts +3 -0
  14. package/dist/utils/drawingPathOptions.d.ts +8 -0
  15. package/dist/utils/geometryCalculator.d.ts +13 -0
  16. package/dist/utils/geometryFileParseResult.d.ts +8 -0
  17. package/dist/utils/memorialLayer.d.ts +3 -0
  18. package/dist/utils/menuHistory.d.ts +8 -0
  19. package/dist/utils/normalizeGeoJsonToFeatures.d.ts +2 -0
  20. package/dist/utils/parseGeoJsonFile.d.ts +2 -0
  21. package/dist/utils/parseKmlFile.d.ts +2 -0
  22. package/dist/utils/parseShapefileZip.d.ts +3 -0
  23. package/dist/utils/stableMarker.d.ts +15 -0
  24. package/dist/utils/validateImportedPolygonGeometry.d.ts +9 -0
  25. package/package.json +22 -4
  26. package/src/assets/icons/file-import-regular-full-gray.svg +1 -0
  27. package/src/assets/icons/file-lines-regular-full-gray.svg +1 -0
  28. package/src/assets/icons/layer-icon.svg +3 -0
  29. package/src/assets/icons/loading.gif +0 -0
  30. package/src/assets/icons/marker-icon.svg +1 -0
  31. package/src/assets/icons/measure-icon.svg +5 -0
  32. package/src/assets/layers/mapLayers.json +16 -0
  33. package/src/assets/main.css +187 -0
  34. package/src/assets/styles/element-plus.css +25 -0
  35. package/src/assets/styles/layers-menu.css +95 -0
  36. package/src/assets/styles/leaflet.css +361 -0
  37. package/src/components/MapDPG.vue +302 -0
  38. package/src/components/coordinate/CoordinatePanel.vue +1206 -0
  39. package/src/components/fa-icon/FontAwesomeIcon.vue +44 -0
  40. package/src/components/loading/Loading.vue +25 -0
  41. package/src/components/map/LeafletMap.vue +183 -0
  42. package/src/components/menu/ChildMenu.vue +112 -0
  43. package/src/components/menu/LayerMenu.vue +294 -0
  44. package/src/components/menu/ParentMenu.vue +132 -0
  45. package/src/constants/coordinatePanel.ts +68 -0
  46. package/src/handlers/constants.ts +35 -0
  47. package/src/handlers/drawingControl.ts +143 -0
  48. package/src/handlers/mapHandler.ts +124 -0
  49. package/src/handlers/mapToolsHandler.ts +281 -0
  50. package/src/handlers/measureToolsHandler.ts +424 -0
  51. package/src/handlers/memorialButtonControl.ts +171 -0
  52. package/src/handlers/toolsConstants.ts +69 -0
  53. package/src/index.ts +6 -0
  54. package/src/shims-vue.d.ts +5 -0
  55. package/src/types/index.ts +260 -0
  56. package/src/types/json.d.ts +4 -0
  57. package/src/types/shpjs.d.ts +7 -0
  58. package/src/types/svg.d.ts +4 -0
  59. package/src/utils/CoordinateConverter.ts +43 -0
  60. package/src/utils/areaCalculator.ts +9 -0
  61. package/src/utils/drawingPathOptions.ts +57 -0
  62. package/src/utils/geometryCalculator.ts +62 -0
  63. package/src/utils/geometryFileParseResult.ts +5 -0
  64. package/src/utils/memorialLayer.ts +7 -0
  65. package/src/utils/menuHistory.ts +71 -0
  66. package/src/utils/normalizeGeoJsonToFeatures.ts +52 -0
  67. package/src/utils/parseGeoJsonFile.ts +23 -0
  68. package/src/utils/parseKmlFile.ts +31 -0
  69. package/src/utils/parseShapefileZip.ts +29 -0
  70. package/src/utils/stableMarker.ts +52 -0
  71. package/src/utils/validateImportedPolygonGeometry.ts +62 -0
@@ -0,0 +1,1206 @@
1
+ <template>
2
+ <div
3
+ class="coordinate-panel-shell"
4
+ :class="{ 'panel-open': isOpen }"
5
+ >
6
+ <div class="coordinate-panel">
7
+ <div class="panel-header">
8
+ <h3>{{ texts.title }}</h3>
9
+ <ElButton
10
+ class="close-button"
11
+ @click="togglePanel"
12
+ >
13
+ <FontAwesomeIcon iconName="xmark" />
14
+ </ElButton>
15
+ </div>
16
+ <div class="panel-content">
17
+ <div class="coordinate-section">
18
+ <h4>{{ texts.referenceSystem }}</h4>
19
+ <ElSelect
20
+ v-model="selectedSystem"
21
+ class="coordinate-select"
22
+ :placeholder="texts.selectSystem"
23
+ :disabled="true"
24
+ @change="onSystemChange"
25
+ >
26
+ <ElOption
27
+ :label="texts.sirgas2000"
28
+ value="SIRGAS2000"
29
+ />
30
+ </ElSelect>
31
+ </div>
32
+
33
+ <div class="coordinate-section">
34
+ <h4>{{ texts.coordinateFormat }}</h4>
35
+ <ElSelect
36
+ v-model="selectedFormat"
37
+ class="coordinate-select"
38
+ :placeholder="texts.selectFormat"
39
+ @change="onFormatChange"
40
+ >
41
+ <ElOption
42
+ :label="texts.decimalDegrees"
43
+ value="DD"
44
+ />
45
+ <ElOption
46
+ :label="texts.degreesMinutesSeconds"
47
+ value="DMS"
48
+ />
49
+ </ElSelect>
50
+ </div>
51
+
52
+ <ElTabs v-model="activeTab">
53
+ <ElTabPane
54
+ :label="texts.manualInput"
55
+ name="manual"
56
+ >
57
+ <div class="coordinate-section">
58
+ <h4>{{ texts.insertCoordinates }}</h4>
59
+ <div
60
+ v-if="selectedFormat === 'DD'"
61
+ class="coordinate-inputs-row"
62
+ >
63
+ <div class="input-group">
64
+ <label>{{ texts.xLongitude }}</label>
65
+ <ElInput
66
+ v-model="manualInput.x"
67
+ :placeholder="texts.placeholderLongitude"
68
+ type="number"
69
+ step="any"
70
+ />
71
+ </div>
72
+ <div class="input-group">
73
+ <label>{{ texts.yLatitude }}</label>
74
+ <ElInput
75
+ v-model="manualInput.y"
76
+ :placeholder="texts.placeholderLatitude"
77
+ type="number"
78
+ step="any"
79
+ />
80
+ </div>
81
+ </div>
82
+
83
+ <div
84
+ v-if="selectedFormat === 'DMS'"
85
+ class="coordinate-inputs-column"
86
+ >
87
+ <div class="input-group">
88
+ <label>{{ texts.xLongitude }}</label>
89
+ <div class="dms-inputs">
90
+ <ElInput
91
+ v-model="manualInput.xDegrees"
92
+ :placeholder="texts.placeholderDegrees"
93
+ type="number"
94
+ step="1"
95
+ />
96
+ <ElInput
97
+ v-model="manualInput.xMinutes"
98
+ :placeholder="texts.placeholderMinutes"
99
+ type="number"
100
+ step="1"
101
+ />
102
+ <ElInput
103
+ v-model="manualInput.xSeconds"
104
+ :placeholder="texts.placeholderSeconds"
105
+ type="number"
106
+ step="any"
107
+ />
108
+ </div>
109
+ </div>
110
+ <div class="input-group">
111
+ <label>{{ texts.yLatitude }}</label>
112
+ <div class="dms-inputs">
113
+ <ElInput
114
+ v-model="manualInput.yDegrees"
115
+ :placeholder="texts.placeholderDegrees"
116
+ type="number"
117
+ step="1"
118
+ />
119
+ <ElInput
120
+ v-model="manualInput.yMinutes"
121
+ :placeholder="texts.placeholderMinutes"
122
+ type="number"
123
+ step="1"
124
+ />
125
+ <ElInput
126
+ v-model="manualInput.ySeconds"
127
+ :placeholder="texts.placeholderSeconds"
128
+ type="number"
129
+ step="any"
130
+ />
131
+ </div>
132
+ </div>
133
+ </div>
134
+ <div class="coordinate-inputs-row">
135
+ <div class="input-group">
136
+ <label>{{ texts.azimuth }}</label>
137
+ <ElInput
138
+ v-model="manualInput.azimuth"
139
+ :placeholder="texts.placeholderAzimuth"
140
+ type="number"
141
+ step="any"
142
+ />
143
+ </div>
144
+ <div class="input-group">
145
+ <label>{{ texts.distance }}</label>
146
+ <ElInput
147
+ v-model="manualInput.distance"
148
+ :placeholder="texts.placeholderDistance"
149
+ type="number"
150
+ step="any"
151
+ />
152
+ </div>
153
+ </div>
154
+ <ElButton
155
+ class="apply-button"
156
+ type="primary"
157
+ @click="addManualPoint"
158
+ >
159
+ {{ texts.addPoint }}
160
+ </ElButton>
161
+ <div
162
+ v-if="manualPoints.length > 0"
163
+ class="geometry-list"
164
+ >
165
+ <h4>{{ texts.addedPoints }}</h4>
166
+ <div class="table-container">
167
+ <ElTable
168
+ :data="manualPoints"
169
+ style="width: 100%"
170
+ >
171
+ <ElTableColumn
172
+ :label="texts.index"
173
+ prop="index"
174
+ width="80"
175
+ />
176
+ <ElTableColumn
177
+ :label="texts.x"
178
+ prop="x"
179
+ width="120"
180
+ />
181
+ <ElTableColumn
182
+ :label="texts.y"
183
+ prop="y"
184
+ width="120"
185
+ />
186
+ <ElTableColumn
187
+ :label="texts.azimuth"
188
+ prop="azimuth"
189
+ width="120"
190
+ />
191
+ <ElTableColumn
192
+ :label="texts.distance"
193
+ prop="distance"
194
+ width="120"
195
+ />
196
+ <ElTableColumn
197
+ :label="texts.actions"
198
+ width="120"
199
+ >
200
+ <template #default="{ $index }">
201
+ <ElDropdown trigger="click">
202
+ <ElButton type="primary">
203
+ {{ texts.actions }}
204
+ <FontAwesomeIcon iconName="chevron-down" />
205
+ </ElButton>
206
+ <template #dropdown>
207
+ <ElDropdownMenu>
208
+ <ElDropdownItem
209
+ :title="texts.editPointTitle"
210
+ :aria-label="texts.editPointDescription"
211
+ @click="editPoint($index)"
212
+ >
213
+ <FontAwesomeIcon
214
+ iconName="pencil"
215
+ class="icon-black"
216
+ />
217
+ {{ texts.editPoint }}
218
+ </ElDropdownItem>
219
+ <ElDropdownItem
220
+ :title="texts.removePointTitle"
221
+ :aria-label="texts.removePointDescription"
222
+ @click="removePoint($index)"
223
+ >
224
+ <FontAwesomeIcon
225
+ iconName="trash"
226
+ class="icon-black"
227
+ />
228
+ {{ texts.removePoint }}
229
+ </ElDropdownItem>
230
+ </ElDropdownMenu>
231
+ </template>
232
+ </ElDropdown>
233
+ </template>
234
+ </ElTableColumn>
235
+ </ElTable>
236
+ </div>
237
+ </div>
238
+ <ElButton
239
+ v-if="manualPoints.length > 0"
240
+ class="apply-button"
241
+ type="success"
242
+ @click="finalizeManualGeometry"
243
+ >
244
+ {{ texts.finalizeGeometry }}
245
+ </ElButton>
246
+ <div
247
+ v-if="manualGeometries.length > 0"
248
+ class="geometry-list"
249
+ >
250
+ <ElButton
251
+ type="danger"
252
+ class="clear-button"
253
+ :title="texts.clearGeometriesTitle"
254
+ :aria-label="texts.clearGeometriesDescription"
255
+ @click="clearGeometries"
256
+ >
257
+ <FontAwesomeIcon iconName="trash" />
258
+ {{ texts.clearGeometries }}
259
+ </ElButton>
260
+ </div>
261
+ </div>
262
+ </ElTabPane>
263
+
264
+ <ElTabPane
265
+ :label="texts.csvUpload"
266
+ name="csv"
267
+ >
268
+ <div class="coordinate-section">
269
+ <h4>{{ texts.csvFileUpload }}</h4>
270
+ <div class="csv-upload">
271
+ <ElUpload
272
+ ref="uploadRef"
273
+ class="upload-area"
274
+ drag
275
+ action="#"
276
+ :auto-upload="false"
277
+ :on-change="handleFileChange"
278
+ :on-remove="handleFileRemove"
279
+ :limit="1"
280
+ accept=".csv"
281
+ >
282
+ <FontAwesomeIcon
283
+ iconName="upload"
284
+ class="upload-icon"
285
+ />
286
+ <div class="el-upload__text">
287
+ {{ texts.dragCsvFile }}
288
+ </div>
289
+ <template #tip>
290
+ <div class="el-upload__tip">
291
+ {{ texts.csvColumnsInfo }}
292
+ </div>
293
+ </template>
294
+ </ElUpload>
295
+ <ElButton
296
+ v-if="csvData.length > 0"
297
+ class="apply-button"
298
+ type="primary"
299
+ @click="applyCSVCoordinates"
300
+ >
301
+ {{ texts.applyCsvCoordinates }}
302
+ </ElButton>
303
+ <ElButton
304
+ type="danger"
305
+ class="clear-button"
306
+ v-if="csvData.length > 0"
307
+ :title="texts.clearGeometriesTitle"
308
+ :aria-label="texts.clearGeometriesDescription"
309
+ @click="clearGeometries"
310
+ >
311
+ <FontAwesomeIcon iconName="trash" />
312
+ {{ texts.clearGeometries }}
313
+ </ElButton>
314
+ </div>
315
+ </div>
316
+ </ElTabPane>
317
+ </ElTabs>
318
+ </div>
319
+ </div>
320
+ <input
321
+ ref="shapefileInputRef"
322
+ type="file"
323
+ accept=".zip,.kml,.geojson,.json"
324
+ class="shapefile-input-hidden"
325
+ @change="handleGeometryFileInputChange"
326
+ />
327
+ </div>
328
+ </template>
329
+
330
+ <script setup lang="ts">
331
+ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
332
+ import {
333
+ ElButton,
334
+ ElSelect,
335
+ ElOption,
336
+ ElInput,
337
+ ElTabs,
338
+ ElTabPane,
339
+ ElUpload,
340
+ ElMessage,
341
+ ElTable,
342
+ ElTableColumn,
343
+ ElDropdown,
344
+ ElDropdownMenu,
345
+ ElDropdownItem,
346
+ ElTooltip
347
+ } from 'element-plus'
348
+ import FontAwesomeIcon from '../fa-icon/FontAwesomeIcon.vue'
349
+ import Papa from 'papaparse'
350
+ import L from 'leaflet'
351
+ import { COORDINATE_PANEL_TEXTS } from '../../constants/coordinatePanel'
352
+ import { CoordinateConverter } from '../../utils/CoordinateConverter'
353
+ import MemorialButtonControl from '../../handlers/memorialButtonControl'
354
+ import type { DescriptiveMemorial } from '../../types'
355
+ import { parseShapefileZip } from '../../utils/parseShapefileZip'
356
+ import { parseGeoJsonFile } from '../../utils/parseGeoJsonFile'
357
+ import { parseKmlFile } from '../../utils/parseKmlFile'
358
+ import { validateImportedPolygonGeometry } from '../../utils/validateImportedPolygonGeometry'
359
+ import type { GeometryFileParseResult } from '../../utils/geometryFileParseResult'
360
+ import type { Feature, MultiPolygon, Polygon } from 'geojson'
361
+
362
+ interface CSVRow {
363
+ X?: string
364
+ Y?: string
365
+ AZIMUTH?: string
366
+ DISTANCIA?: string
367
+ DISTANCE?: string
368
+ }
369
+
370
+ const rowDistance = (row: CSVRow): string | undefined => row.DISTANCIA ?? row.DISTANCE
371
+
372
+ interface ParsedData {
373
+ data: CSVRow[]
374
+ meta: {
375
+ fields: string[] | undefined
376
+ }
377
+ }
378
+
379
+ interface Point {
380
+ x: number
381
+ y: number
382
+ }
383
+
384
+ interface ManualPoint {
385
+ index: number
386
+ x: string
387
+ y: string
388
+ azimuth: string
389
+ distance: string
390
+ }
391
+
392
+ const props = defineProps<{
393
+ map?: L.Map
394
+ descriptiveMemorial?: DescriptiveMemorial
395
+ }>()
396
+
397
+ const emit = defineEmits<{
398
+ (e: 'systemChange', system: string): void
399
+ (e: 'coordinatesChange', coordinates: { lat: number; lng: number }): void
400
+ (e: 'geometryChange', geometry: string): void
401
+ (e: 'geometryGeoJsonChange', feature: Feature<Polygon | MultiPolygon>): void
402
+ (e: 'geometryRemoved'): void
403
+ }>()
404
+
405
+ const isOpen = ref<boolean>(false)
406
+ const activeTab = ref<string>('manual')
407
+ const selectedSystem = ref<string>('SIRGAS2000')
408
+ const selectedFormat = ref<string>('DD')
409
+ const latitude = ref<string>('')
410
+ const longitude = ref<string>('')
411
+ const csvData = ref<Array<{ x: number; y: number; azimuth: number; distance: number }>>([])
412
+ const shapefileLoaded = ref<boolean>(false)
413
+ const uploadRef = ref()
414
+ const shapefileInputRef = ref<HTMLInputElement>()
415
+ const manualInput = ref({
416
+ x: '',
417
+ y: '',
418
+ xDegrees: '',
419
+ xMinutes: '',
420
+ xSeconds: '',
421
+ yDegrees: '',
422
+ yMinutes: '',
423
+ ySeconds: '',
424
+ azimuth: '',
425
+ distance: ''
426
+ })
427
+ const manualPoints = ref<any[]>([])
428
+ const manualGeometries = ref<string[]>([])
429
+ const editingIndex = ref<number | null>(null)
430
+ let memorialButtonControl: MemorialButtonControl | null = null
431
+
432
+ const texts = computed(() => {
433
+ return { ...COORDINATE_PANEL_TEXTS, ...props.descriptiveMemorial?.customTexts }
434
+ })
435
+
436
+ const togglePanel = () => {
437
+ isOpen.value = !isOpen.value
438
+ }
439
+
440
+ const getMemorialButtonControl = (): MemorialButtonControl | null => {
441
+ if (!memorialButtonControl && props.map) {
442
+ memorialButtonControl = new MemorialButtonControl(props.map)
443
+ }
444
+
445
+ return memorialButtonControl
446
+ }
447
+
448
+ const alignMapControls = () => {
449
+ const align = (props.map as L.Map & { alignTopRightControls?: () => void })?.alignTopRightControls
450
+ align?.()
451
+ }
452
+
453
+ const registerMapButtons = () => {
454
+ const controller = getMemorialButtonControl()
455
+ if (!controller) return
456
+
457
+ controller.addMemorial(togglePanel, texts.value.memorialDescriptive || '')
458
+ controller.addShapefile(openShapefilePicker, texts.value.shapefileUpload || '')
459
+ alignMapControls()
460
+ }
461
+
462
+ onMounted(() => {
463
+ if (props.descriptiveMemorial?.show) {
464
+ registerMapButtons()
465
+ }
466
+ })
467
+
468
+ onBeforeUnmount(() => {
469
+ memorialButtonControl?.remove()
470
+ memorialButtonControl = null
471
+ })
472
+
473
+ watch(
474
+ () => props.descriptiveMemorial?.show,
475
+ newValue => {
476
+ const controller = getMemorialButtonControl()
477
+ if (!controller) return
478
+
479
+ if (newValue) {
480
+ registerMapButtons()
481
+ } else {
482
+ controller.remove()
483
+ }
484
+ }
485
+ )
486
+
487
+ watch(
488
+ () => props.descriptiveMemorial?.customTexts?.memorialDescriptive,
489
+ newTitle => {
490
+ const controller = getMemorialButtonControl()
491
+ if (newTitle && controller) {
492
+ controller.updateMemorialTitle(newTitle)
493
+ }
494
+ }
495
+ )
496
+
497
+ watch(
498
+ () => props.descriptiveMemorial?.customTexts?.shapefileUpload,
499
+ newTitle => {
500
+ const controller = getMemorialButtonControl()
501
+ if (newTitle && controller) {
502
+ controller.updateShapefileTitle(newTitle)
503
+ }
504
+ }
505
+ )
506
+
507
+ const closePanel = () => {
508
+ isOpen.value = false
509
+ }
510
+
511
+ const onSystemChange = (value: string) => {
512
+ emit('systemChange', value)
513
+ }
514
+
515
+ const onFormatChange = (value: string) => {
516
+ manualInput.value = {
517
+ x: '',
518
+ y: '',
519
+ xDegrees: '',
520
+ xMinutes: '',
521
+ xSeconds: '',
522
+ yDegrees: '',
523
+ yMinutes: '',
524
+ ySeconds: '',
525
+ azimuth: '',
526
+ distance: ''
527
+ }
528
+ }
529
+
530
+ const calculateNewPoint = (startPoint: Point, azimuth: number, distance: number): Point => {
531
+ const azimuthRad = (azimuth * Math.PI) / 180
532
+
533
+ distance = distance / 111111.111111111
534
+
535
+ const x = startPoint.x + distance * Math.sin(azimuthRad)
536
+ const y = startPoint.y + distance * Math.cos(azimuthRad)
537
+
538
+ return { x, y }
539
+ }
540
+
541
+ const generateWKT = (points: Point[]): string => {
542
+ if (points.length === 1) {
543
+ return `POINT(${points[0].x} ${points[0].y})`
544
+ }
545
+
546
+ if (points.length < 3) {
547
+ const coordinates = points.map(p => `${p.x} ${p.y}`).join(', ')
548
+ return `LINESTRING(${coordinates})`
549
+ }
550
+
551
+ const firstPoint = points[0]
552
+ const lastPoint = points[points.length - 1]
553
+ const isClosedPolygon = firstPoint.x === lastPoint.x && firstPoint.y === lastPoint.y
554
+
555
+ let coordinates: string
556
+ if (isClosedPolygon) {
557
+ coordinates = points.map(p => `${p.x} ${p.y}`).join(', ')
558
+ } else {
559
+ const closedPoints = [...points, firstPoint]
560
+ coordinates = closedPoints.map(p => `${p.x} ${p.y}`).join(', ')
561
+ }
562
+ return `POLYGON((${coordinates}))`
563
+ }
564
+
565
+ const convertDMSToDDFromString = (dmsString: string): number => {
566
+ const cleanString = dmsString.trim().toUpperCase().replace(/'/g, '′').replace(/"/g, '″')
567
+
568
+ if (!cleanString) return NaN
569
+
570
+ const dmsRegex =
571
+ /^\s*(-)?\s*(?:(\d+(?:[.,]\d+)?)\s*°)?\s*(?:(\d+(?:[.,]\d+)?)\s*′)?\s*(?:(\d+(?:[.,]\d+)?)\s*(?:″|′′))?\s*([NSEW])?\s*$/
572
+ const match = cleanString.match(dmsRegex)
573
+
574
+ if (match && (match[2] != null || match[3] != null || match[4] != null || cleanString.includes('°'))) {
575
+ const isNegative = !!match[1] || ['S', 'W'].includes(match[5] || '')
576
+ const degrees = parseFloat(match[2]?.replace(',', '.') || '0')
577
+ const minutes = parseFloat(match[3]?.replace(',', '.') || '0')
578
+ const seconds = parseFloat(match[4]?.replace(',', '.') || '0')
579
+
580
+ const dd = Math.abs(degrees) + minutes / 60 + seconds / 3600
581
+ return isNegative ? -dd : dd
582
+ }
583
+
584
+ const value = parseFloat(cleanString.replace(',', '.'))
585
+ if (Number.isNaN(value)) return NaN
586
+
587
+ const isNegative = cleanString.includes('S') || cleanString.includes('W') || cleanString.startsWith('-')
588
+ return isNegative ? -Math.abs(value) : value
589
+ }
590
+
591
+ const processCSVData = (data: CSVRow[]): Point[] => {
592
+ const points: Point[] = []
593
+ let lastPoint: Point | null = null
594
+
595
+ for (const row of data) {
596
+ const distanceValue = rowDistance(row)
597
+ const hasXY = Boolean(row.X?.trim() && row.Y?.trim())
598
+ const hasAzimuthDistance = Boolean(row.AZIMUTH?.trim() && distanceValue?.trim())
599
+
600
+ if (!hasXY && !hasAzimuthDistance) continue
601
+
602
+ let currentPoint: Point | null = null
603
+
604
+ if (hasXY) {
605
+ currentPoint = {
606
+ x: convertDMSToDDFromString(row.X!),
607
+ y: convertDMSToDDFromString(row.Y!)
608
+ }
609
+ } else if (hasAzimuthDistance && lastPoint) {
610
+ const azimuth = parseFloat(row.AZIMUTH!)
611
+ const distance = parseFloat(distanceValue!)
612
+ currentPoint = calculateNewPoint(lastPoint, azimuth, distance)
613
+ }
614
+
615
+ if (currentPoint && !Number.isNaN(currentPoint.x) && !Number.isNaN(currentPoint.y)) {
616
+ points.push(currentPoint)
617
+ lastPoint = currentPoint
618
+ }
619
+ }
620
+
621
+ return points
622
+ }
623
+
624
+ const handleFileChange = (file: any) => {
625
+ if (file.raw) {
626
+ csvData.value = []
627
+ shapefileLoaded.value = false
628
+ emit('geometryRemoved')
629
+
630
+ Papa.parse(file.raw, {
631
+ header: true,
632
+ skipEmptyLines: true,
633
+ complete: (results: Papa.ParseResult<CSVRow>) => {
634
+ const rows = results.data.filter(
635
+ (row) =>
636
+ (row.X?.trim() && row.Y?.trim()) ||
637
+ (row.AZIMUTH?.trim() && rowDistance(row)?.trim())
638
+ )
639
+
640
+ const firstRow = rows[0]
641
+ if (!firstRow?.X?.trim() || !firstRow?.Y?.trim()) {
642
+ ElMessage.error(texts.value.errorFirstRowXY)
643
+ return
644
+ }
645
+
646
+ const points = processCSVData(rows)
647
+ if (points.length > 0) {
648
+ csvData.value = points.map(p => ({
649
+ x: p.x,
650
+ y: p.y,
651
+ azimuth: 0,
652
+ distance: 0
653
+ }))
654
+ const wkt = generateWKT(points)
655
+ emit('geometryChange', wkt)
656
+ }
657
+ }
658
+ })
659
+ }
660
+ }
661
+
662
+ const handleFileRemove = () => {
663
+ csvData.value = []
664
+ manualGeometries.value = []
665
+ if (uploadRef.value) {
666
+ uploadRef.value.clearFiles()
667
+ }
668
+ emit('geometryRemoved')
669
+ }
670
+
671
+ const openShapefilePicker = () => {
672
+ shapefileInputRef.value?.click()
673
+ }
674
+
675
+ const getGeometryFileExtension = (fileName: string): 'zip' | 'kml' | 'geojson' | null => {
676
+ const lower = fileName.toLowerCase()
677
+
678
+ if (lower.endsWith('.zip')) return 'zip'
679
+ if (lower.endsWith('.kml')) return 'kml'
680
+ if (lower.endsWith('.geojson') || lower.endsWith('.json')) return 'geojson'
681
+
682
+ return null
683
+ }
684
+
685
+ const parseGeometryFile = async (file: File): Promise<GeometryFileParseResult> => {
686
+ const extension = getGeometryFileExtension(file.name)
687
+
688
+ if (extension === 'zip') return parseShapefileZip(file)
689
+ if (extension === 'kml') return parseKmlFile(file)
690
+ if (extension === 'geojson') return parseGeoJsonFile(file)
691
+
692
+ return { ok: false, error: texts.value.geometryImportUnsupportedFormat || 'Unsupported file format' }
693
+ }
694
+
695
+ const processGeometryFile = async (file: File) => {
696
+ emit('geometryRemoved')
697
+ csvData.value = []
698
+ shapefileLoaded.value = false
699
+
700
+ const parseResult = await parseGeometryFile(file)
701
+ if (!parseResult.ok) {
702
+ ElMessage.error(parseResult.error)
703
+ return
704
+ }
705
+
706
+ const validationResult = validateImportedPolygonGeometry(parseResult.features)
707
+ if (!validationResult.ok) {
708
+ ElMessage.error(validationResult.error)
709
+ return
710
+ }
711
+
712
+ shapefileLoaded.value = true
713
+ emit('geometryGeoJsonChange', validationResult.feature)
714
+ ElMessage.success(texts.value.shapefileAppliedSuccess)
715
+ }
716
+
717
+ const handleGeometryFileInputChange = async (event: Event) => {
718
+ const input = event.target as HTMLInputElement
719
+ const file = input.files?.[0]
720
+ if (!file) return
721
+
722
+ await processGeometryFile(file)
723
+ input.value = ''
724
+ }
725
+
726
+ const editPoint = (index: number) => {
727
+ const point = manualPoints.value[index]
728
+ const dms = CoordinateConverter.ddToDMS(parseFloat(point.x))
729
+ const dmsY = CoordinateConverter.ddToDMS(parseFloat(point.y))
730
+
731
+ manualInput.value = {
732
+ x: point.x.toString(),
733
+ y: point.y.toString(),
734
+ xDegrees: dms.degrees.toString(),
735
+ xMinutes: dms.minutes.toString(),
736
+ xSeconds: dms.seconds.toString(),
737
+ yDegrees: dmsY.degrees.toString(),
738
+ yMinutes: dmsY.minutes.toString(),
739
+ ySeconds: dmsY.seconds.toString(),
740
+ azimuth: point.azimuth,
741
+ distance: point.distance
742
+ }
743
+ editingIndex.value = index
744
+ }
745
+
746
+ const removePoint = (index: number) => {
747
+ manualPoints.value.splice(index, 1)
748
+ if (editingIndex.value === index) {
749
+ editingIndex.value = null
750
+ manualInput.value = {
751
+ x: '',
752
+ y: '',
753
+ xDegrees: '',
754
+ xMinutes: '',
755
+ xSeconds: '',
756
+ yDegrees: '',
757
+ yMinutes: '',
758
+ ySeconds: '',
759
+ azimuth: '',
760
+ distance: ''
761
+ }
762
+ }
763
+ }
764
+
765
+ const convertDMSToDD = (degrees: string, minutes: string, seconds: string): number => {
766
+ const deg = parseFloat(degrees) || 0
767
+ const min = parseFloat(minutes) || 0
768
+ const sec = parseFloat(seconds) || 0
769
+ return CoordinateConverter.dmsToDD(deg, min, sec)
770
+ }
771
+
772
+ const addManualPoint = () => {
773
+ if (editingIndex.value !== null) {
774
+ let x: number, y: number
775
+
776
+ if (selectedFormat.value === 'DD') {
777
+ if (editingIndex.value === 0 && (!manualInput.value.x || !manualInput.value.y)) {
778
+ ElMessage.error(texts.value.errorXYRequired)
779
+ return
780
+ }
781
+ x = manualInput.value.x !== '' ? parseFloat(manualInput.value.x) : parseFloat(manualPoints.value[editingIndex.value].x)
782
+ y = manualInput.value.y !== '' ? parseFloat(manualInput.value.y) : parseFloat(manualPoints.value[editingIndex.value].y)
783
+ } else {
784
+ if (editingIndex.value === 0 && (!manualInput.value.xDegrees || !manualInput.value.yDegrees)) {
785
+ ElMessage.error(texts.value.errorDegreesRequired)
786
+ return
787
+ }
788
+ x = manualInput.value.xDegrees !== ''
789
+ ? convertDMSToDD(manualInput.value.xDegrees, manualInput.value.xMinutes, manualInput.value.xSeconds)
790
+ : parseFloat(manualPoints.value[editingIndex.value].x)
791
+ y = manualInput.value.yDegrees !== ''
792
+ ? convertDMSToDD(manualInput.value.yDegrees, manualInput.value.yMinutes, manualInput.value.ySeconds)
793
+ : parseFloat(manualPoints.value[editingIndex.value].y)
794
+ }
795
+
796
+ if (Number.isNaN(x) || Number.isNaN(y)) {
797
+ ElMessage.error(texts.value.errorProvideCoordinatesOrAzimuthDistance)
798
+ return
799
+ }
800
+
801
+ manualPoints.value[editingIndex.value] = {
802
+ index: editingIndex.value + 1,
803
+ x: x.toString(),
804
+ y: y.toString(),
805
+ azimuth: manualInput.value.azimuth || manualPoints.value[editingIndex.value].azimuth,
806
+ distance: manualInput.value.distance || manualPoints.value[editingIndex.value].distance
807
+ }
808
+
809
+ editingIndex.value = null
810
+ manualInput.value = {
811
+ x: '',
812
+ y: '',
813
+ xDegrees: '',
814
+ xMinutes: '',
815
+ xSeconds: '',
816
+ yDegrees: '',
817
+ yMinutes: '',
818
+ ySeconds: '',
819
+ azimuth: '',
820
+ distance: ''
821
+ }
822
+ return
823
+ }
824
+
825
+ if (manualPoints.value.length === 0) {
826
+ let x: number, y: number
827
+
828
+ if (selectedFormat.value === 'DD') {
829
+ if (!manualInput.value.x || !manualInput.value.y) {
830
+ ElMessage.error(texts.value.errorXYRequired)
831
+ return
832
+ }
833
+ x = parseFloat(manualInput.value.x)
834
+ y = parseFloat(manualInput.value.y)
835
+ } else {
836
+ if (!manualInput.value.xDegrees || !manualInput.value.yDegrees) {
837
+ ElMessage.error(texts.value.errorDegreesRequired)
838
+ return
839
+ }
840
+ x = convertDMSToDD(manualInput.value.xDegrees, manualInput.value.xMinutes, manualInput.value.xSeconds)
841
+ y = convertDMSToDD(manualInput.value.yDegrees, manualInput.value.yMinutes, manualInput.value.ySeconds)
842
+ }
843
+
844
+ manualPoints.value.push({
845
+ index: 1,
846
+ x: x.toString(),
847
+ y: y.toString(),
848
+ azimuth: manualInput.value.azimuth,
849
+ distance: manualInput.value.distance
850
+ })
851
+ } else {
852
+ const last = manualPoints.value[manualPoints.value.length - 1]
853
+ let newPoint: Point
854
+
855
+ if (selectedFormat.value === 'DD' && manualInput.value.x && manualInput.value.y) {
856
+ newPoint = {
857
+ x: parseFloat(manualInput.value.x),
858
+ y: parseFloat(manualInput.value.y)
859
+ }
860
+ } else if (selectedFormat.value === 'DMS' && manualInput.value.xDegrees && manualInput.value.yDegrees) {
861
+ newPoint = {
862
+ x: convertDMSToDD(manualInput.value.xDegrees, manualInput.value.xMinutes, manualInput.value.xSeconds),
863
+ y: convertDMSToDD(manualInput.value.yDegrees, manualInput.value.yMinutes, manualInput.value.ySeconds)
864
+ }
865
+ } else if (manualInput.value.azimuth && manualInput.value.distance) {
866
+ newPoint = calculateNewPoint(
867
+ { x: parseFloat(last.x), y: parseFloat(last.y) },
868
+ parseFloat(manualInput.value.azimuth),
869
+ parseFloat(manualInput.value.distance)
870
+ )
871
+ } else {
872
+ ElMessage.error(texts.value.errorProvideCoordinatesOrAzimuthDistance)
873
+ return
874
+ }
875
+
876
+ manualPoints.value.push({
877
+ index: manualPoints.value.length + 1,
878
+ x: newPoint.x.toString(),
879
+ y: newPoint.y.toString(),
880
+ azimuth: manualInput.value.azimuth,
881
+ distance: manualInput.value.distance
882
+ })
883
+ }
884
+
885
+ manualInput.value = {
886
+ x: '',
887
+ y: '',
888
+ xDegrees: '',
889
+ xMinutes: '',
890
+ xSeconds: '',
891
+ yDegrees: '',
892
+ yMinutes: '',
893
+ ySeconds: '',
894
+ azimuth: '',
895
+ distance: ''
896
+ }
897
+ }
898
+
899
+ const finalizeManualGeometry = () => {
900
+ if (manualPoints.value.length > 0) {
901
+ const points = manualPoints.value.map(p => ({ x: parseFloat(p.x), y: parseFloat(p.y) }))
902
+ const wkt = generateWKT(points)
903
+ manualGeometries.value.push(wkt)
904
+ emit('geometryChange', wkt)
905
+ manualPoints.value = []
906
+ manualInput.value = {
907
+ x: '',
908
+ y: '',
909
+ xDegrees: '',
910
+ xMinutes: '',
911
+ xSeconds: '',
912
+ yDegrees: '',
913
+ yMinutes: '',
914
+ ySeconds: '',
915
+ azimuth: '',
916
+ distance: ''
917
+ }
918
+ }
919
+ }
920
+
921
+ const applyCSVCoordinates = () => {
922
+ if (csvData.value.length > 0) {
923
+ const points = csvData.value.map(row => ({ x: row.x, y: row.y }))
924
+ const wkt = generateWKT(points)
925
+ emit('geometryChange', wkt)
926
+ }
927
+ }
928
+
929
+ const clearGeometries = () => {
930
+ manualGeometries.value = []
931
+ csvData.value = []
932
+ manualPoints.value = []
933
+ shapefileLoaded.value = false
934
+ if (uploadRef.value) {
935
+ uploadRef.value.clearFiles()
936
+ }
937
+ if (shapefileInputRef.value) {
938
+ shapefileInputRef.value.value = ''
939
+ }
940
+ manualInput.value = {
941
+ x: '',
942
+ y: '',
943
+ xDegrees: '',
944
+ xMinutes: '',
945
+ xSeconds: '',
946
+ yDegrees: '',
947
+ yMinutes: '',
948
+ ySeconds: '',
949
+ azimuth: '',
950
+ distance: ''
951
+ }
952
+ emit('geometryRemoved')
953
+ }
954
+
955
+ defineExpose({
956
+ togglePanel,
957
+ closePanel,
958
+ openShapefilePicker
959
+ })
960
+ </script>
961
+
962
+ <style scoped>
963
+ .coordinate-panel-shell {
964
+ position: absolute;
965
+ bottom: 50px;
966
+ right: 10px;
967
+ z-index: 1000;
968
+ max-height: min(445px, calc(100vh - 120px));
969
+ max-width: 480px;
970
+ min-width: 480px !important;
971
+ transform: translateX(100%);
972
+ transition: transform 0.3s ease;
973
+ opacity: 0;
974
+ pointer-events: none;
975
+
976
+ &.panel-open {
977
+ transform: translateX(0);
978
+ opacity: 1;
979
+ pointer-events: all;
980
+ }
981
+ }
982
+
983
+ .coordinate-panel {
984
+ display: flex;
985
+ flex-direction: column;
986
+ height: min(445px, calc(100vh - 120px));
987
+ max-height: min(445px, calc(100vh - 120px));
988
+ background-color: white;
989
+ border-radius: 8px;
990
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
991
+ overflow: hidden;
992
+ }
993
+
994
+ .coordinate-inputs-column {
995
+ display: flex;
996
+ flex-direction: column;
997
+ gap: 16px;
998
+ margin-top: 10px;
999
+ margin-bottom: 10px;
1000
+ }
1001
+
1002
+ .panel-header {
1003
+ flex-shrink: 0;
1004
+ display: flex;
1005
+ justify-content: space-between;
1006
+ align-items: center;
1007
+ padding: 15px;
1008
+ border-bottom: 1px solid #eee;
1009
+ background-color: white;
1010
+
1011
+ h3 {
1012
+ margin: 0;
1013
+ color: var(--mapa-base-green);
1014
+ font-size: 16px;
1015
+ }
1016
+
1017
+ .close-button {
1018
+ padding: 4px;
1019
+ color: #666;
1020
+
1021
+ &:hover {
1022
+ color: var(--mapa-base-green);
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ .panel-content {
1028
+ flex: 1 1 auto;
1029
+ min-height: 0;
1030
+ padding: 15px;
1031
+ overflow-y: auto;
1032
+ overflow-x: hidden;
1033
+ -webkit-overflow-scrolling: touch;
1034
+ }
1035
+
1036
+ :deep(.el-tabs__header) {
1037
+ position: sticky;
1038
+ top: 0;
1039
+ z-index: 5;
1040
+ margin-bottom: 0;
1041
+ background-color: white;
1042
+ }
1043
+
1044
+ .coordinate-section {
1045
+ margin-bottom: 20px;
1046
+ padding: 0.2px;
1047
+
1048
+ h4 {
1049
+ margin: 0 0 10px 0;
1050
+ color: #666;
1051
+ font-size: 14px;
1052
+ }
1053
+ }
1054
+
1055
+ .coordinate-select {
1056
+ width: 100%;
1057
+ }
1058
+
1059
+ .coordinate-inputs-row {
1060
+ display: flex;
1061
+ flex-direction: row;
1062
+ gap: 16px;
1063
+ justify-content: space-between;
1064
+ margin-bottom: 10px;
1065
+
1066
+ .el-input {
1067
+ width: 100%;
1068
+ }
1069
+ }
1070
+
1071
+ .input-group {
1072
+ display: flex;
1073
+ flex-direction: column;
1074
+ gap: 5px;
1075
+
1076
+ label {
1077
+ font-size: 12px;
1078
+ color: #666;
1079
+ }
1080
+ }
1081
+
1082
+ .apply-button {
1083
+ width: 100%;
1084
+ background-color: var(--mapa-base-green);
1085
+ border-color: var(--mapa-base-green);
1086
+ margin-top: 30px;
1087
+
1088
+ &:hover {
1089
+ background-color: var(--mapa-base-green);
1090
+ opacity: 0.9;
1091
+ }
1092
+ }
1093
+
1094
+ .csv-upload {
1095
+ .upload-area {
1096
+ width: 100%;
1097
+ margin-bottom: 15px;
1098
+ }
1099
+
1100
+ .upload-icon {
1101
+ font-size: 24px;
1102
+ color: var(--mapa-base-green);
1103
+ margin-bottom: 10px;
1104
+ }
1105
+
1106
+ .el-upload__tip {
1107
+ color: #666;
1108
+ font-size: 12px;
1109
+ margin-top: 5px;
1110
+ }
1111
+ }
1112
+
1113
+ .geometry-list {
1114
+ margin-top: 15px;
1115
+
1116
+ h4 {
1117
+ margin: 0 0 10px 0;
1118
+ color: #666;
1119
+ font-size: 14px;
1120
+ }
1121
+
1122
+ ul {
1123
+ list-style-type: none;
1124
+ padding: 0;
1125
+
1126
+ li {
1127
+ margin-bottom: 5px;
1128
+ color: #666;
1129
+ font-size: 12px;
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ .table-container {
1135
+ width: 100%;
1136
+ overflow-x: auto;
1137
+ }
1138
+
1139
+ .el-dropdown-menu__item {
1140
+ display: flex;
1141
+ align-items: center;
1142
+ gap: 8px;
1143
+ }
1144
+
1145
+ :deep(.el-dropdown-menu__item) {
1146
+ display: flex;
1147
+ align-items: center;
1148
+ gap: 8px;
1149
+ }
1150
+
1151
+ :deep(.el-button--primary) {
1152
+ background-color: var(--mapa-base-green);
1153
+ border-color: var(--mapa-base-green);
1154
+ }
1155
+
1156
+ :deep(.el-button--primary:hover) {
1157
+ background-color: var(--mapa-base-green);
1158
+ opacity: 0.9;
1159
+ }
1160
+
1161
+ .clear-button {
1162
+ margin-top: 15px;
1163
+ display: flex;
1164
+ align-items: center;
1165
+ gap: 8px;
1166
+ }
1167
+
1168
+ :deep(.clear-button .el-icon) {
1169
+ margin-right: 4px;
1170
+ }
1171
+
1172
+ .geometry-items {
1173
+ margin: 10px 0;
1174
+ }
1175
+
1176
+ .geometry-item {
1177
+ display: flex;
1178
+ justify-content: space-between;
1179
+ align-items: center;
1180
+ padding: 8px;
1181
+ background-color: #f5f7fa;
1182
+ border-radius: 4px;
1183
+ margin-bottom: 8px;
1184
+ }
1185
+
1186
+ .geometry-text {
1187
+ font-family: monospace;
1188
+ font-size: 12px;
1189
+ color: #666;
1190
+ word-break: break-all;
1191
+ }
1192
+
1193
+ .dms-inputs {
1194
+ display: flex;
1195
+ gap: 16px;
1196
+ justify-content: space-between;
1197
+
1198
+ .el-input {
1199
+ width: 100%;
1200
+ }
1201
+ }
1202
+
1203
+ .shapefile-input-hidden {
1204
+ display: none;
1205
+ }
1206
+ </style>