feat(admin): enhance areas map interface with global view
Added mainMap to visualize all configured areas. Included reference layers for existing areas when editing or creating a new polygon to prevent overlap.
This commit is contained in:
+153
-44
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, nextTick, onBeforeUnmount, computed } from 'vue'
|
||||
import { ref, reactive, nextTick, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
|
||||
|
||||
definePageMeta({
|
||||
@@ -24,8 +24,8 @@ const actionError = ref('')
|
||||
const deletingAreaId = ref<number | null>(null)
|
||||
const areaPendingDelete = ref<any | null>(null)
|
||||
|
||||
// Map variables
|
||||
let map: any = null
|
||||
let mainMap: any = null
|
||||
let polygon: any = null
|
||||
const mapPoints = ref<[number, number][]>([])
|
||||
|
||||
@@ -34,9 +34,98 @@ const submitLabel = computed(() => editingAreaId.value ? 'Salva Modifiche' : 'Cr
|
||||
const isEditing = computed(() => editingAreaId.value !== null)
|
||||
const areas = computed(() => areasRes.value?.areas || [])
|
||||
const areaDeleteMessage = computed(() =>
|
||||
areaPendingDelete.value ? `Vuoi eliminare area "${areaPendingDelete.value.name}"? Operazione irreversibile.` : ''
|
||||
areaPendingDelete.value ? `Vuoi eliminare area "${areaPendingDelete.value.name}"? Operazione irreversibile.` : ''
|
||||
)
|
||||
|
||||
const initMainMap = async () => {
|
||||
const mapElement = document.getElementById('mainMap')
|
||||
if (!mapElement) return
|
||||
|
||||
const L = (await import('leaflet')).default
|
||||
|
||||
if (mainMap) {
|
||||
mainMap.remove()
|
||||
mainMap = null
|
||||
}
|
||||
|
||||
const bariBounds = L.latLngBounds(
|
||||
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||
)
|
||||
|
||||
mainMap = L.map('mainMap', {
|
||||
maxBounds: bariBounds,
|
||||
maxBoundsViscosity: 1,
|
||||
minZoom: 11
|
||||
}).setView(BARI_CENTER, 12)
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(mainMap)
|
||||
|
||||
const featureGroup = L.featureGroup().addTo(mainMap)
|
||||
let hasLayers = false
|
||||
|
||||
areas.value.forEach((area: any) => {
|
||||
if (area.boundary_geojson) {
|
||||
try {
|
||||
const layer = L.geoJSON(JSON.parse(area.boundary_geojson), {
|
||||
style: {
|
||||
color: '#0d6efd',
|
||||
weight: 2,
|
||||
fillOpacity: 0.2
|
||||
}
|
||||
}).bindPopup(`<strong>${area.name}</strong><br/>${area.description || ''}`)
|
||||
|
||||
layer.addTo(featureGroup)
|
||||
hasLayers = true
|
||||
} catch (e) {
|
||||
console.error('Invalid geojson for area', area.id, e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (hasLayers) {
|
||||
mainMap.fitBounds(featureGroup.getBounds())
|
||||
} else {
|
||||
mainMap.fitBounds(bariBounds)
|
||||
}
|
||||
}
|
||||
|
||||
watch([areas, pending], () => {
|
||||
if (!pending.value) {
|
||||
if (areas.value.length > 0) {
|
||||
nextTick(() => {
|
||||
initMainMap()
|
||||
})
|
||||
} else if (mainMap) {
|
||||
mainMap.remove()
|
||||
mainMap = null
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const renderExistingAreasInModal = (L: any, currentMap: any) => {
|
||||
areas.value.forEach((area: any) => {
|
||||
if (area.id === editingAreaId.value) return
|
||||
|
||||
if (area.boundary_geojson) {
|
||||
try {
|
||||
L.geoJSON(JSON.parse(area.boundary_geojson), {
|
||||
style: {
|
||||
color: '#6c757d',
|
||||
weight: 2,
|
||||
dashArray: '5, 5',
|
||||
fillOpacity: 0.1
|
||||
},
|
||||
interactive: false
|
||||
}).addTo(currentMap)
|
||||
} catch(e) {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
actionError.value = ''
|
||||
editingAreaId.value = null
|
||||
@@ -48,7 +137,6 @@ const openCreateModal = () => {
|
||||
isCreating.value = true
|
||||
|
||||
nextTick(async () => {
|
||||
// Import leaflet solo client-side
|
||||
const L = (await import('leaflet')).default
|
||||
|
||||
if (map) {
|
||||
@@ -57,8 +145,8 @@ const openCreateModal = () => {
|
||||
}
|
||||
|
||||
const bariBounds = L.latLngBounds(
|
||||
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||
)
|
||||
|
||||
map = L.map('areaMap', {
|
||||
@@ -74,6 +162,8 @@ const openCreateModal = () => {
|
||||
polygon = L.polygon([], { color: 'red' }).addTo(map)
|
||||
map.fitBounds(bariBounds)
|
||||
|
||||
renderExistingAreasInModal(L, map)
|
||||
|
||||
map.on('click', (e: any) => {
|
||||
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
|
||||
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
|
||||
@@ -96,8 +186,8 @@ const loadPolygonFromGeoJson = (boundaryGeojson: any) => {
|
||||
}
|
||||
|
||||
const normalized = coordinates
|
||||
.slice(0, -1)
|
||||
.map(([longitude, latitude]: [number, number]) => [latitude, longitude] as [number, number])
|
||||
.slice(0, -1)
|
||||
.map(([longitude, latitude]: [number, number]) => [latitude, longitude] as [number, number])
|
||||
|
||||
mapPoints.value = normalized
|
||||
if (polygon) {
|
||||
@@ -110,7 +200,7 @@ const openEditModal = (area: any) => {
|
||||
editingAreaId.value = area.id
|
||||
formData.name = area.name || ''
|
||||
formData.description = area.description || ''
|
||||
formData.geojson = area.boundary_geojson ? JSON.stringify(area.boundary_geojson, null, 2) : ''
|
||||
formData.geojson = area.boundary_geojson || ''
|
||||
jsonError.value = ''
|
||||
mapPoints.value = []
|
||||
isCreating.value = true
|
||||
@@ -124,8 +214,8 @@ const openEditModal = (area: any) => {
|
||||
}
|
||||
|
||||
const bariBounds = L.latLngBounds(
|
||||
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||
)
|
||||
|
||||
map = L.map('areaMap', {
|
||||
@@ -137,9 +227,20 @@ const openEditModal = (area: any) => {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(map)
|
||||
console.log(JSON.parse(area.boundary_geojson))
|
||||
L.geoJSON(JSON.parse(area.boundary_geojson), {
|
||||
style: {
|
||||
color: '#0d6efd',
|
||||
weight: 2,
|
||||
fillOpacity: 0.2
|
||||
}
|
||||
}).addTo(map)
|
||||
|
||||
polygon = L.polygon([], { color: 'red' }).addTo(map)
|
||||
map.fitBounds(bariBounds)
|
||||
|
||||
renderExistingAreasInModal(L, map)
|
||||
|
||||
loadPolygonFromGeoJson(area.boundary_geojson)
|
||||
|
||||
map.on('click', (e: any) => {
|
||||
@@ -171,7 +272,7 @@ const updateGeojson = () => {
|
||||
}
|
||||
const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number]) // Lng, Lat
|
||||
if (coords[0]) {
|
||||
coords.push([...coords[0]]) // Chiudi poligono
|
||||
coords.push([...coords[0]])
|
||||
}
|
||||
|
||||
formData.geojson = JSON.stringify({
|
||||
@@ -191,6 +292,7 @@ const closeCreateModal = () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (map) map.remove()
|
||||
if (mainMap) mainMap.remove()
|
||||
})
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -221,8 +323,8 @@ const handleCreate = async () => {
|
||||
}
|
||||
|
||||
const res = editingAreaId.value
|
||||
? await updateArea(editingAreaId.value, payload)
|
||||
: await createArea(payload)
|
||||
? await updateArea(editingAreaId.value, payload)
|
||||
: await createArea(payload)
|
||||
|
||||
if (res.ok) {
|
||||
await refresh()
|
||||
@@ -291,13 +393,19 @@ const handleDeleteArea = async () => {
|
||||
Nessuna area configurata.
|
||||
</div>
|
||||
|
||||
<div v-if="actionError" class="alert alert-danger">
|
||||
{{ actionError }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Main view map showing all areas -->
|
||||
<div class="card mb-4 shadow-sm border-0">
|
||||
<div id="mainMap" class="rounded" style="height: 400px; width: 100%; z-index: 1;"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-hover align-middle bg-white shadow-sm rounded">
|
||||
<thead class="table-light">
|
||||
<div v-if="actionError" class="alert alert-danger">
|
||||
{{ actionError }}
|
||||
</div>
|
||||
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-hover align-middle bg-white shadow-sm rounded">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nome</th>
|
||||
@@ -305,8 +413,8 @@ const handleDeleteArea = async () => {
|
||||
<th>GeoJSON</th>
|
||||
<th class="text-end">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="area in areas" :key="area.id">
|
||||
<td>{{ area.id }}</td>
|
||||
<td><strong>{{ area.name }}</strong></td>
|
||||
@@ -320,20 +428,21 @@ const handleDeleteArea = async () => {
|
||||
Modifica
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-danger ms-2"
|
||||
:disabled="deletingAreaId === area.id"
|
||||
@click="askDeleteArea(area)"
|
||||
class="btn btn-sm btn-outline-danger ms-2"
|
||||
:disabled="deletingAreaId === area.id"
|
||||
@click="askDeleteArea(area)"
|
||||
>
|
||||
<span v-if="deletingAreaId === area.id" class="spinner-border spinner-border-sm me-1"></span>
|
||||
Elimina
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Modale Creazione -->
|
||||
<!-- Modale Creazione/Modifica -->
|
||||
<div v-if="isCreating" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
|
||||
<div v-if="isCreating" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
|
||||
@@ -357,12 +466,12 @@ const handleDeleteArea = async () => {
|
||||
<div class="mb-3">
|
||||
<label class="form-label">GeoJSON Generato</label>
|
||||
<textarea
|
||||
class="form-control font-monospace text-sm"
|
||||
rows="6"
|
||||
v-model="formData.geojson"
|
||||
required
|
||||
readonly
|
||||
placeholder='{ "type": "Polygon", "coordinates": [...] }'
|
||||
class="form-control font-monospace text-sm"
|
||||
rows="6"
|
||||
v-model="formData.geojson"
|
||||
required
|
||||
readonly
|
||||
placeholder='{ "type": "Polygon", "coordinates": [...] }'
|
||||
></textarea>
|
||||
<div v-if="jsonError" class="form-text text-danger mt-1">{{ jsonError }}</div>
|
||||
</div>
|
||||
@@ -394,20 +503,20 @@ const handleDeleteArea = async () => {
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
:open="areaPendingDelete !== null"
|
||||
title="Elimina area"
|
||||
:message="areaDeleteMessage"
|
||||
confirm-label="Elimina area"
|
||||
confirm-class="btn-danger"
|
||||
:pending="deletingAreaId !== null"
|
||||
@cancel="closeDeleteAreaModal"
|
||||
@confirm="handleDeleteArea"
|
||||
:open="areaPendingDelete !== null"
|
||||
title="Elimina area"
|
||||
:message="areaDeleteMessage"
|
||||
confirm-label="Elimina area"
|
||||
confirm-class="btn-danger"
|
||||
:pending="deletingAreaId !== null"
|
||||
@cancel="closeDeleteAreaModal"
|
||||
@confirm="handleDeleteArea"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#areaMap {
|
||||
#areaMap, #mainMap {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user