@rural-environmental-registry/map_component 1.0.0 → 1.1.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 (63) 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 +25452 -18225
  12. package/dist/types/index.d.ts +68 -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/memorialLayer.d.ts +3 -0
  17. package/dist/utils/menuHistory.d.ts +8 -0
  18. package/dist/utils/parseShapefileZip.d.ts +9 -0
  19. package/dist/utils/stableMarker.d.ts +15 -0
  20. package/dist/utils/validateShapefileGeometry.d.ts +9 -0
  21. package/package.json +21 -4
  22. package/src/assets/icons/file-import-regular-full-gray.svg +1 -0
  23. package/src/assets/icons/file-lines-regular-full-gray.svg +1 -0
  24. package/src/assets/icons/layer-icon.svg +3 -0
  25. package/src/assets/icons/loading.gif +0 -0
  26. package/src/assets/icons/marker-icon.svg +1 -0
  27. package/src/assets/icons/measure-icon.svg +5 -0
  28. package/src/assets/layers/mapLayers.json +16 -0
  29. package/src/assets/main.css +187 -0
  30. package/src/assets/styles/element-plus.css +25 -0
  31. package/src/assets/styles/layers-menu.css +95 -0
  32. package/src/assets/styles/leaflet.css +361 -0
  33. package/src/components/MapDPG.vue +302 -0
  34. package/src/components/coordinate/CoordinatePanel.vue +1183 -0
  35. package/src/components/fa-icon/FontAwesomeIcon.vue +44 -0
  36. package/src/components/loading/Loading.vue +25 -0
  37. package/src/components/map/LeafletMap.vue +183 -0
  38. package/src/components/menu/ChildMenu.vue +112 -0
  39. package/src/components/menu/LayerMenu.vue +294 -0
  40. package/src/components/menu/ParentMenu.vue +132 -0
  41. package/src/constants/coordinatePanel.ts +67 -0
  42. package/src/handlers/constants.ts +35 -0
  43. package/src/handlers/drawingControl.ts +143 -0
  44. package/src/handlers/mapHandler.ts +124 -0
  45. package/src/handlers/mapToolsHandler.ts +281 -0
  46. package/src/handlers/measureToolsHandler.ts +424 -0
  47. package/src/handlers/memorialButtonControl.ts +171 -0
  48. package/src/handlers/toolsConstants.ts +69 -0
  49. package/src/index.ts +6 -0
  50. package/src/shims-vue.d.ts +5 -0
  51. package/src/types/index.ts +259 -0
  52. package/src/types/json.d.ts +4 -0
  53. package/src/types/shpjs.d.ts +7 -0
  54. package/src/types/svg.d.ts +4 -0
  55. package/src/utils/CoordinateConverter.ts +43 -0
  56. package/src/utils/areaCalculator.ts +9 -0
  57. package/src/utils/drawingPathOptions.ts +57 -0
  58. package/src/utils/geometryCalculator.ts +62 -0
  59. package/src/utils/memorialLayer.ts +7 -0
  60. package/src/utils/menuHistory.ts +71 -0
  61. package/src/utils/parseShapefileZip.ts +56 -0
  62. package/src/utils/stableMarker.ts +52 -0
  63. package/src/utils/validateShapefileGeometry.ts +54 -0
@@ -0,0 +1,1183 @@
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"
324
+ class="shapefile-input-hidden"
325
+ @change="handleShapefileInputChange"
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 { validateShapefileGeometry } from '../../utils/validateShapefileGeometry'
357
+ import type { Feature, MultiPolygon, Polygon } from 'geojson'
358
+
359
+ interface CSVRow {
360
+ X?: string
361
+ Y?: string
362
+ AZIMUTH?: string
363
+ DISTANCIA?: string
364
+ DISTANCE?: string
365
+ }
366
+
367
+ const rowDistance = (row: CSVRow): string | undefined => row.DISTANCIA ?? row.DISTANCE
368
+
369
+ interface ParsedData {
370
+ data: CSVRow[]
371
+ meta: {
372
+ fields: string[] | undefined
373
+ }
374
+ }
375
+
376
+ interface Point {
377
+ x: number
378
+ y: number
379
+ }
380
+
381
+ interface ManualPoint {
382
+ index: number
383
+ x: string
384
+ y: string
385
+ azimuth: string
386
+ distance: string
387
+ }
388
+
389
+ const props = defineProps<{
390
+ map?: L.Map
391
+ descriptiveMemorial?: DescriptiveMemorial
392
+ }>()
393
+
394
+ const emit = defineEmits<{
395
+ (e: 'systemChange', system: string): void
396
+ (e: 'coordinatesChange', coordinates: { lat: number; lng: number }): void
397
+ (e: 'geometryChange', geometry: string): void
398
+ (e: 'geometryGeoJsonChange', feature: Feature<Polygon | MultiPolygon>): void
399
+ (e: 'geometryRemoved'): void
400
+ }>()
401
+
402
+ const isOpen = ref<boolean>(false)
403
+ const activeTab = ref<string>('manual')
404
+ const selectedSystem = ref<string>('SIRGAS2000')
405
+ const selectedFormat = ref<string>('DD')
406
+ const latitude = ref<string>('')
407
+ const longitude = ref<string>('')
408
+ const csvData = ref<Array<{ x: number; y: number; azimuth: number; distance: number }>>([])
409
+ const shapefileLoaded = ref<boolean>(false)
410
+ const uploadRef = ref()
411
+ const shapefileInputRef = ref<HTMLInputElement>()
412
+ const manualInput = ref({
413
+ x: '',
414
+ y: '',
415
+ xDegrees: '',
416
+ xMinutes: '',
417
+ xSeconds: '',
418
+ yDegrees: '',
419
+ yMinutes: '',
420
+ ySeconds: '',
421
+ azimuth: '',
422
+ distance: ''
423
+ })
424
+ const manualPoints = ref<any[]>([])
425
+ const manualGeometries = ref<string[]>([])
426
+ const editingIndex = ref<number | null>(null)
427
+ let memorialButtonControl: MemorialButtonControl | null = null
428
+
429
+ const texts = computed(() => {
430
+ return { ...COORDINATE_PANEL_TEXTS, ...props.descriptiveMemorial?.customTexts }
431
+ })
432
+
433
+ const togglePanel = () => {
434
+ isOpen.value = !isOpen.value
435
+ }
436
+
437
+ const getMemorialButtonControl = (): MemorialButtonControl | null => {
438
+ if (!memorialButtonControl && props.map) {
439
+ memorialButtonControl = new MemorialButtonControl(props.map)
440
+ }
441
+
442
+ return memorialButtonControl
443
+ }
444
+
445
+ const alignMapControls = () => {
446
+ const align = (props.map as L.Map & { alignTopRightControls?: () => void })?.alignTopRightControls
447
+ align?.()
448
+ }
449
+
450
+ const registerMapButtons = () => {
451
+ const controller = getMemorialButtonControl()
452
+ if (!controller) return
453
+
454
+ controller.addMemorial(togglePanel, texts.value.memorialDescriptive || '')
455
+ controller.addShapefile(openShapefilePicker, texts.value.shapefileUpload || '')
456
+ alignMapControls()
457
+ }
458
+
459
+ onMounted(() => {
460
+ if (props.descriptiveMemorial?.show) {
461
+ registerMapButtons()
462
+ }
463
+ })
464
+
465
+ onBeforeUnmount(() => {
466
+ memorialButtonControl?.remove()
467
+ memorialButtonControl = null
468
+ })
469
+
470
+ watch(
471
+ () => props.descriptiveMemorial?.show,
472
+ newValue => {
473
+ const controller = getMemorialButtonControl()
474
+ if (!controller) return
475
+
476
+ if (newValue) {
477
+ registerMapButtons()
478
+ } else {
479
+ controller.remove()
480
+ }
481
+ }
482
+ )
483
+
484
+ watch(
485
+ () => props.descriptiveMemorial?.customTexts?.memorialDescriptive,
486
+ newTitle => {
487
+ const controller = getMemorialButtonControl()
488
+ if (newTitle && controller) {
489
+ controller.updateMemorialTitle(newTitle)
490
+ }
491
+ }
492
+ )
493
+
494
+ watch(
495
+ () => props.descriptiveMemorial?.customTexts?.shapefileUpload,
496
+ newTitle => {
497
+ const controller = getMemorialButtonControl()
498
+ if (newTitle && controller) {
499
+ controller.updateShapefileTitle(newTitle)
500
+ }
501
+ }
502
+ )
503
+
504
+ const closePanel = () => {
505
+ isOpen.value = false
506
+ }
507
+
508
+ const onSystemChange = (value: string) => {
509
+ emit('systemChange', value)
510
+ }
511
+
512
+ const onFormatChange = (value: string) => {
513
+ manualInput.value = {
514
+ x: '',
515
+ y: '',
516
+ xDegrees: '',
517
+ xMinutes: '',
518
+ xSeconds: '',
519
+ yDegrees: '',
520
+ yMinutes: '',
521
+ ySeconds: '',
522
+ azimuth: '',
523
+ distance: ''
524
+ }
525
+ }
526
+
527
+ const calculateNewPoint = (startPoint: Point, azimuth: number, distance: number): Point => {
528
+ const azimuthRad = (azimuth * Math.PI) / 180
529
+
530
+ distance = distance / 111111.111111111
531
+
532
+ const x = startPoint.x + distance * Math.sin(azimuthRad)
533
+ const y = startPoint.y + distance * Math.cos(azimuthRad)
534
+
535
+ return { x, y }
536
+ }
537
+
538
+ const generateWKT = (points: Point[]): string => {
539
+ if (points.length === 1) {
540
+ return `POINT(${points[0].x} ${points[0].y})`
541
+ }
542
+
543
+ if (points.length < 3) {
544
+ const coordinates = points.map(p => `${p.x} ${p.y}`).join(', ')
545
+ return `LINESTRING(${coordinates})`
546
+ }
547
+
548
+ const firstPoint = points[0]
549
+ const lastPoint = points[points.length - 1]
550
+ const isClosedPolygon = firstPoint.x === lastPoint.x && firstPoint.y === lastPoint.y
551
+
552
+ let coordinates: string
553
+ if (isClosedPolygon) {
554
+ coordinates = points.map(p => `${p.x} ${p.y}`).join(', ')
555
+ } else {
556
+ const closedPoints = [...points, firstPoint]
557
+ coordinates = closedPoints.map(p => `${p.x} ${p.y}`).join(', ')
558
+ }
559
+ return `POLYGON((${coordinates}))`
560
+ }
561
+
562
+ const convertDMSToDDFromString = (dmsString: string): number => {
563
+ const cleanString = dmsString.trim().toUpperCase().replace(/'/g, '′').replace(/"/g, '″')
564
+
565
+ if (!cleanString) return NaN
566
+
567
+ const dmsRegex =
568
+ /^\s*(-)?\s*(?:(\d+(?:[.,]\d+)?)\s*°)?\s*(?:(\d+(?:[.,]\d+)?)\s*′)?\s*(?:(\d+(?:[.,]\d+)?)\s*(?:″|′′))?\s*([NSEW])?\s*$/
569
+ const match = cleanString.match(dmsRegex)
570
+
571
+ if (match && (match[2] != null || match[3] != null || match[4] != null || cleanString.includes('°'))) {
572
+ const isNegative = !!match[1] || ['S', 'W'].includes(match[5] || '')
573
+ const degrees = parseFloat(match[2]?.replace(',', '.') || '0')
574
+ const minutes = parseFloat(match[3]?.replace(',', '.') || '0')
575
+ const seconds = parseFloat(match[4]?.replace(',', '.') || '0')
576
+
577
+ const dd = Math.abs(degrees) + minutes / 60 + seconds / 3600
578
+ return isNegative ? -dd : dd
579
+ }
580
+
581
+ const value = parseFloat(cleanString.replace(',', '.'))
582
+ if (Number.isNaN(value)) return NaN
583
+
584
+ const isNegative = cleanString.includes('S') || cleanString.includes('W') || cleanString.startsWith('-')
585
+ return isNegative ? -Math.abs(value) : value
586
+ }
587
+
588
+ const processCSVData = (data: CSVRow[]): Point[] => {
589
+ const points: Point[] = []
590
+ let lastPoint: Point | null = null
591
+
592
+ for (const row of data) {
593
+ const distanceValue = rowDistance(row)
594
+ const hasXY = Boolean(row.X?.trim() && row.Y?.trim())
595
+ const hasAzimuthDistance = Boolean(row.AZIMUTH?.trim() && distanceValue?.trim())
596
+
597
+ if (!hasXY && !hasAzimuthDistance) continue
598
+
599
+ let currentPoint: Point | null = null
600
+
601
+ if (hasXY) {
602
+ currentPoint = {
603
+ x: convertDMSToDDFromString(row.X!),
604
+ y: convertDMSToDDFromString(row.Y!)
605
+ }
606
+ } else if (hasAzimuthDistance && lastPoint) {
607
+ const azimuth = parseFloat(row.AZIMUTH!)
608
+ const distance = parseFloat(distanceValue!)
609
+ currentPoint = calculateNewPoint(lastPoint, azimuth, distance)
610
+ }
611
+
612
+ if (currentPoint && !Number.isNaN(currentPoint.x) && !Number.isNaN(currentPoint.y)) {
613
+ points.push(currentPoint)
614
+ lastPoint = currentPoint
615
+ }
616
+ }
617
+
618
+ return points
619
+ }
620
+
621
+ const handleFileChange = (file: any) => {
622
+ if (file.raw) {
623
+ csvData.value = []
624
+ shapefileLoaded.value = false
625
+ emit('geometryRemoved')
626
+
627
+ Papa.parse(file.raw, {
628
+ header: true,
629
+ skipEmptyLines: true,
630
+ complete: (results: Papa.ParseResult<CSVRow>) => {
631
+ const rows = results.data.filter(
632
+ (row) =>
633
+ (row.X?.trim() && row.Y?.trim()) ||
634
+ (row.AZIMUTH?.trim() && rowDistance(row)?.trim())
635
+ )
636
+
637
+ const firstRow = rows[0]
638
+ if (!firstRow?.X?.trim() || !firstRow?.Y?.trim()) {
639
+ ElMessage.error(texts.value.errorFirstRowXY)
640
+ return
641
+ }
642
+
643
+ const points = processCSVData(rows)
644
+ if (points.length > 0) {
645
+ csvData.value = points.map(p => ({
646
+ x: p.x,
647
+ y: p.y,
648
+ azimuth: 0,
649
+ distance: 0
650
+ }))
651
+ const wkt = generateWKT(points)
652
+ emit('geometryChange', wkt)
653
+ }
654
+ }
655
+ })
656
+ }
657
+ }
658
+
659
+ const handleFileRemove = () => {
660
+ csvData.value = []
661
+ manualGeometries.value = []
662
+ if (uploadRef.value) {
663
+ uploadRef.value.clearFiles()
664
+ }
665
+ emit('geometryRemoved')
666
+ }
667
+
668
+ const openShapefilePicker = () => {
669
+ shapefileInputRef.value?.click()
670
+ }
671
+
672
+ const processShapefile = async (file: File) => {
673
+ emit('geometryRemoved')
674
+ csvData.value = []
675
+ shapefileLoaded.value = false
676
+
677
+ const parseResult = await parseShapefileZip(file)
678
+ if (!parseResult.ok) {
679
+ ElMessage.error(parseResult.error)
680
+ return
681
+ }
682
+
683
+ const validationResult = validateShapefileGeometry(parseResult.features)
684
+ if (!validationResult.ok) {
685
+ ElMessage.error(validationResult.error)
686
+ return
687
+ }
688
+
689
+ shapefileLoaded.value = true
690
+ emit('geometryGeoJsonChange', validationResult.feature)
691
+ ElMessage.success(texts.value.shapefileAppliedSuccess)
692
+ }
693
+
694
+ const handleShapefileInputChange = async (event: Event) => {
695
+ const input = event.target as HTMLInputElement
696
+ const file = input.files?.[0]
697
+ if (!file) return
698
+
699
+ await processShapefile(file)
700
+ input.value = ''
701
+ }
702
+
703
+ const editPoint = (index: number) => {
704
+ const point = manualPoints.value[index]
705
+ const dms = CoordinateConverter.ddToDMS(parseFloat(point.x))
706
+ const dmsY = CoordinateConverter.ddToDMS(parseFloat(point.y))
707
+
708
+ manualInput.value = {
709
+ x: point.x.toString(),
710
+ y: point.y.toString(),
711
+ xDegrees: dms.degrees.toString(),
712
+ xMinutes: dms.minutes.toString(),
713
+ xSeconds: dms.seconds.toString(),
714
+ yDegrees: dmsY.degrees.toString(),
715
+ yMinutes: dmsY.minutes.toString(),
716
+ ySeconds: dmsY.seconds.toString(),
717
+ azimuth: point.azimuth,
718
+ distance: point.distance
719
+ }
720
+ editingIndex.value = index
721
+ }
722
+
723
+ const removePoint = (index: number) => {
724
+ manualPoints.value.splice(index, 1)
725
+ if (editingIndex.value === index) {
726
+ editingIndex.value = null
727
+ manualInput.value = {
728
+ x: '',
729
+ y: '',
730
+ xDegrees: '',
731
+ xMinutes: '',
732
+ xSeconds: '',
733
+ yDegrees: '',
734
+ yMinutes: '',
735
+ ySeconds: '',
736
+ azimuth: '',
737
+ distance: ''
738
+ }
739
+ }
740
+ }
741
+
742
+ const convertDMSToDD = (degrees: string, minutes: string, seconds: string): number => {
743
+ const deg = parseFloat(degrees) || 0
744
+ const min = parseFloat(minutes) || 0
745
+ const sec = parseFloat(seconds) || 0
746
+ return CoordinateConverter.dmsToDD(deg, min, sec)
747
+ }
748
+
749
+ const addManualPoint = () => {
750
+ if (editingIndex.value !== null) {
751
+ let x: number, y: number
752
+
753
+ if (selectedFormat.value === 'DD') {
754
+ if (editingIndex.value === 0 && (!manualInput.value.x || !manualInput.value.y)) {
755
+ ElMessage.error(texts.value.errorXYRequired)
756
+ return
757
+ }
758
+ x = manualInput.value.x !== '' ? parseFloat(manualInput.value.x) : parseFloat(manualPoints.value[editingIndex.value].x)
759
+ y = manualInput.value.y !== '' ? parseFloat(manualInput.value.y) : parseFloat(manualPoints.value[editingIndex.value].y)
760
+ } else {
761
+ if (editingIndex.value === 0 && (!manualInput.value.xDegrees || !manualInput.value.yDegrees)) {
762
+ ElMessage.error(texts.value.errorDegreesRequired)
763
+ return
764
+ }
765
+ x = manualInput.value.xDegrees !== ''
766
+ ? convertDMSToDD(manualInput.value.xDegrees, manualInput.value.xMinutes, manualInput.value.xSeconds)
767
+ : parseFloat(manualPoints.value[editingIndex.value].x)
768
+ y = manualInput.value.yDegrees !== ''
769
+ ? convertDMSToDD(manualInput.value.yDegrees, manualInput.value.yMinutes, manualInput.value.ySeconds)
770
+ : parseFloat(manualPoints.value[editingIndex.value].y)
771
+ }
772
+
773
+ if (Number.isNaN(x) || Number.isNaN(y)) {
774
+ ElMessage.error(texts.value.errorProvideCoordinatesOrAzimuthDistance)
775
+ return
776
+ }
777
+
778
+ manualPoints.value[editingIndex.value] = {
779
+ index: editingIndex.value + 1,
780
+ x: x.toString(),
781
+ y: y.toString(),
782
+ azimuth: manualInput.value.azimuth || manualPoints.value[editingIndex.value].azimuth,
783
+ distance: manualInput.value.distance || manualPoints.value[editingIndex.value].distance
784
+ }
785
+
786
+ editingIndex.value = null
787
+ manualInput.value = {
788
+ x: '',
789
+ y: '',
790
+ xDegrees: '',
791
+ xMinutes: '',
792
+ xSeconds: '',
793
+ yDegrees: '',
794
+ yMinutes: '',
795
+ ySeconds: '',
796
+ azimuth: '',
797
+ distance: ''
798
+ }
799
+ return
800
+ }
801
+
802
+ if (manualPoints.value.length === 0) {
803
+ let x: number, y: number
804
+
805
+ if (selectedFormat.value === 'DD') {
806
+ if (!manualInput.value.x || !manualInput.value.y) {
807
+ ElMessage.error(texts.value.errorXYRequired)
808
+ return
809
+ }
810
+ x = parseFloat(manualInput.value.x)
811
+ y = parseFloat(manualInput.value.y)
812
+ } else {
813
+ if (!manualInput.value.xDegrees || !manualInput.value.yDegrees) {
814
+ ElMessage.error(texts.value.errorDegreesRequired)
815
+ return
816
+ }
817
+ x = convertDMSToDD(manualInput.value.xDegrees, manualInput.value.xMinutes, manualInput.value.xSeconds)
818
+ y = convertDMSToDD(manualInput.value.yDegrees, manualInput.value.yMinutes, manualInput.value.ySeconds)
819
+ }
820
+
821
+ manualPoints.value.push({
822
+ index: 1,
823
+ x: x.toString(),
824
+ y: y.toString(),
825
+ azimuth: manualInput.value.azimuth,
826
+ distance: manualInput.value.distance
827
+ })
828
+ } else {
829
+ const last = manualPoints.value[manualPoints.value.length - 1]
830
+ let newPoint: Point
831
+
832
+ if (selectedFormat.value === 'DD' && manualInput.value.x && manualInput.value.y) {
833
+ newPoint = {
834
+ x: parseFloat(manualInput.value.x),
835
+ y: parseFloat(manualInput.value.y)
836
+ }
837
+ } else if (selectedFormat.value === 'DMS' && manualInput.value.xDegrees && manualInput.value.yDegrees) {
838
+ newPoint = {
839
+ x: convertDMSToDD(manualInput.value.xDegrees, manualInput.value.xMinutes, manualInput.value.xSeconds),
840
+ y: convertDMSToDD(manualInput.value.yDegrees, manualInput.value.yMinutes, manualInput.value.ySeconds)
841
+ }
842
+ } else if (manualInput.value.azimuth && manualInput.value.distance) {
843
+ newPoint = calculateNewPoint(
844
+ { x: parseFloat(last.x), y: parseFloat(last.y) },
845
+ parseFloat(manualInput.value.azimuth),
846
+ parseFloat(manualInput.value.distance)
847
+ )
848
+ } else {
849
+ ElMessage.error(texts.value.errorProvideCoordinatesOrAzimuthDistance)
850
+ return
851
+ }
852
+
853
+ manualPoints.value.push({
854
+ index: manualPoints.value.length + 1,
855
+ x: newPoint.x.toString(),
856
+ y: newPoint.y.toString(),
857
+ azimuth: manualInput.value.azimuth,
858
+ distance: manualInput.value.distance
859
+ })
860
+ }
861
+
862
+ manualInput.value = {
863
+ x: '',
864
+ y: '',
865
+ xDegrees: '',
866
+ xMinutes: '',
867
+ xSeconds: '',
868
+ yDegrees: '',
869
+ yMinutes: '',
870
+ ySeconds: '',
871
+ azimuth: '',
872
+ distance: ''
873
+ }
874
+ }
875
+
876
+ const finalizeManualGeometry = () => {
877
+ if (manualPoints.value.length > 0) {
878
+ const points = manualPoints.value.map(p => ({ x: parseFloat(p.x), y: parseFloat(p.y) }))
879
+ const wkt = generateWKT(points)
880
+ manualGeometries.value.push(wkt)
881
+ emit('geometryChange', wkt)
882
+ manualPoints.value = []
883
+ manualInput.value = {
884
+ x: '',
885
+ y: '',
886
+ xDegrees: '',
887
+ xMinutes: '',
888
+ xSeconds: '',
889
+ yDegrees: '',
890
+ yMinutes: '',
891
+ ySeconds: '',
892
+ azimuth: '',
893
+ distance: ''
894
+ }
895
+ }
896
+ }
897
+
898
+ const applyCSVCoordinates = () => {
899
+ if (csvData.value.length > 0) {
900
+ const points = csvData.value.map(row => ({ x: row.x, y: row.y }))
901
+ const wkt = generateWKT(points)
902
+ emit('geometryChange', wkt)
903
+ }
904
+ }
905
+
906
+ const clearGeometries = () => {
907
+ manualGeometries.value = []
908
+ csvData.value = []
909
+ manualPoints.value = []
910
+ shapefileLoaded.value = false
911
+ if (uploadRef.value) {
912
+ uploadRef.value.clearFiles()
913
+ }
914
+ if (shapefileInputRef.value) {
915
+ shapefileInputRef.value.value = ''
916
+ }
917
+ manualInput.value = {
918
+ x: '',
919
+ y: '',
920
+ xDegrees: '',
921
+ xMinutes: '',
922
+ xSeconds: '',
923
+ yDegrees: '',
924
+ yMinutes: '',
925
+ ySeconds: '',
926
+ azimuth: '',
927
+ distance: ''
928
+ }
929
+ emit('geometryRemoved')
930
+ }
931
+
932
+ defineExpose({
933
+ togglePanel,
934
+ closePanel,
935
+ openShapefilePicker
936
+ })
937
+ </script>
938
+
939
+ <style scoped>
940
+ .coordinate-panel-shell {
941
+ position: absolute;
942
+ bottom: 50px;
943
+ right: 10px;
944
+ z-index: 1000;
945
+ max-height: min(445px, calc(100vh - 120px));
946
+ max-width: 480px;
947
+ min-width: 480px !important;
948
+ transform: translateX(100%);
949
+ transition: transform 0.3s ease;
950
+ opacity: 0;
951
+ pointer-events: none;
952
+
953
+ &.panel-open {
954
+ transform: translateX(0);
955
+ opacity: 1;
956
+ pointer-events: all;
957
+ }
958
+ }
959
+
960
+ .coordinate-panel {
961
+ display: flex;
962
+ flex-direction: column;
963
+ height: min(445px, calc(100vh - 120px));
964
+ max-height: min(445px, calc(100vh - 120px));
965
+ background-color: white;
966
+ border-radius: 8px;
967
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
968
+ overflow: hidden;
969
+ }
970
+
971
+ .coordinate-inputs-column {
972
+ display: flex;
973
+ flex-direction: column;
974
+ gap: 16px;
975
+ margin-top: 10px;
976
+ margin-bottom: 10px;
977
+ }
978
+
979
+ .panel-header {
980
+ flex-shrink: 0;
981
+ display: flex;
982
+ justify-content: space-between;
983
+ align-items: center;
984
+ padding: 15px;
985
+ border-bottom: 1px solid #eee;
986
+ background-color: white;
987
+
988
+ h3 {
989
+ margin: 0;
990
+ color: var(--mapa-base-green);
991
+ font-size: 16px;
992
+ }
993
+
994
+ .close-button {
995
+ padding: 4px;
996
+ color: #666;
997
+
998
+ &:hover {
999
+ color: var(--mapa-base-green);
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ .panel-content {
1005
+ flex: 1 1 auto;
1006
+ min-height: 0;
1007
+ padding: 15px;
1008
+ overflow-y: auto;
1009
+ overflow-x: hidden;
1010
+ -webkit-overflow-scrolling: touch;
1011
+ }
1012
+
1013
+ :deep(.el-tabs__header) {
1014
+ position: sticky;
1015
+ top: 0;
1016
+ z-index: 5;
1017
+ margin-bottom: 0;
1018
+ background-color: white;
1019
+ }
1020
+
1021
+ .coordinate-section {
1022
+ margin-bottom: 20px;
1023
+ padding: 0.2px;
1024
+
1025
+ h4 {
1026
+ margin: 0 0 10px 0;
1027
+ color: #666;
1028
+ font-size: 14px;
1029
+ }
1030
+ }
1031
+
1032
+ .coordinate-select {
1033
+ width: 100%;
1034
+ }
1035
+
1036
+ .coordinate-inputs-row {
1037
+ display: flex;
1038
+ flex-direction: row;
1039
+ gap: 16px;
1040
+ justify-content: space-between;
1041
+ margin-bottom: 10px;
1042
+
1043
+ .el-input {
1044
+ width: 100%;
1045
+ }
1046
+ }
1047
+
1048
+ .input-group {
1049
+ display: flex;
1050
+ flex-direction: column;
1051
+ gap: 5px;
1052
+
1053
+ label {
1054
+ font-size: 12px;
1055
+ color: #666;
1056
+ }
1057
+ }
1058
+
1059
+ .apply-button {
1060
+ width: 100%;
1061
+ background-color: var(--mapa-base-green);
1062
+ border-color: var(--mapa-base-green);
1063
+ margin-top: 30px;
1064
+
1065
+ &:hover {
1066
+ background-color: var(--mapa-base-green);
1067
+ opacity: 0.9;
1068
+ }
1069
+ }
1070
+
1071
+ .csv-upload {
1072
+ .upload-area {
1073
+ width: 100%;
1074
+ margin-bottom: 15px;
1075
+ }
1076
+
1077
+ .upload-icon {
1078
+ font-size: 24px;
1079
+ color: var(--mapa-base-green);
1080
+ margin-bottom: 10px;
1081
+ }
1082
+
1083
+ .el-upload__tip {
1084
+ color: #666;
1085
+ font-size: 12px;
1086
+ margin-top: 5px;
1087
+ }
1088
+ }
1089
+
1090
+ .geometry-list {
1091
+ margin-top: 15px;
1092
+
1093
+ h4 {
1094
+ margin: 0 0 10px 0;
1095
+ color: #666;
1096
+ font-size: 14px;
1097
+ }
1098
+
1099
+ ul {
1100
+ list-style-type: none;
1101
+ padding: 0;
1102
+
1103
+ li {
1104
+ margin-bottom: 5px;
1105
+ color: #666;
1106
+ font-size: 12px;
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+ .table-container {
1112
+ width: 100%;
1113
+ overflow-x: auto;
1114
+ }
1115
+
1116
+ .el-dropdown-menu__item {
1117
+ display: flex;
1118
+ align-items: center;
1119
+ gap: 8px;
1120
+ }
1121
+
1122
+ :deep(.el-dropdown-menu__item) {
1123
+ display: flex;
1124
+ align-items: center;
1125
+ gap: 8px;
1126
+ }
1127
+
1128
+ :deep(.el-button--primary) {
1129
+ background-color: var(--mapa-base-green);
1130
+ border-color: var(--mapa-base-green);
1131
+ }
1132
+
1133
+ :deep(.el-button--primary:hover) {
1134
+ background-color: var(--mapa-base-green);
1135
+ opacity: 0.9;
1136
+ }
1137
+
1138
+ .clear-button {
1139
+ margin-top: 15px;
1140
+ display: flex;
1141
+ align-items: center;
1142
+ gap: 8px;
1143
+ }
1144
+
1145
+ :deep(.clear-button .el-icon) {
1146
+ margin-right: 4px;
1147
+ }
1148
+
1149
+ .geometry-items {
1150
+ margin: 10px 0;
1151
+ }
1152
+
1153
+ .geometry-item {
1154
+ display: flex;
1155
+ justify-content: space-between;
1156
+ align-items: center;
1157
+ padding: 8px;
1158
+ background-color: #f5f7fa;
1159
+ border-radius: 4px;
1160
+ margin-bottom: 8px;
1161
+ }
1162
+
1163
+ .geometry-text {
1164
+ font-family: monospace;
1165
+ font-size: 12px;
1166
+ color: #666;
1167
+ word-break: break-all;
1168
+ }
1169
+
1170
+ .dms-inputs {
1171
+ display: flex;
1172
+ gap: 16px;
1173
+ justify-content: space-between;
1174
+
1175
+ .el-input {
1176
+ width: 100%;
1177
+ }
1178
+ }
1179
+
1180
+ .shapefile-input-hidden {
1181
+ display: none;
1182
+ }
1183
+ </style>