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:
2026-06-16 18:30:49 +02:00 Unverified
parent f32fa8620b
commit 220f613309
+153 -44
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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' import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
definePageMeta({ definePageMeta({
@@ -24,8 +24,8 @@ const actionError = ref('')
const deletingAreaId = ref<number | null>(null) const deletingAreaId = ref<number | null>(null)
const areaPendingDelete = ref<any | null>(null) const areaPendingDelete = ref<any | null>(null)
// Map variables
let map: any = null let map: any = null
let mainMap: any = null
let polygon: any = null let polygon: any = null
const mapPoints = ref<[number, number][]>([]) const mapPoints = ref<[number, number][]>([])
@@ -34,9 +34,98 @@ const submitLabel = computed(() => editingAreaId.value ? 'Salva Modifiche' : 'Cr
const isEditing = computed(() => editingAreaId.value !== null) const isEditing = computed(() => editingAreaId.value !== null)
const areas = computed(() => areasRes.value?.areas || []) const areas = computed(() => areasRes.value?.areas || [])
const areaDeleteMessage = computed(() => 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 = () => { const openCreateModal = () => {
actionError.value = '' actionError.value = ''
editingAreaId.value = null editingAreaId.value = null
@@ -48,7 +137,6 @@ const openCreateModal = () => {
isCreating.value = true isCreating.value = true
nextTick(async () => { nextTick(async () => {
// Import leaflet solo client-side
const L = (await import('leaflet')).default const L = (await import('leaflet')).default
if (map) { if (map) {
@@ -57,8 +145,8 @@ const openCreateModal = () => {
} }
const bariBounds = L.latLngBounds( const bariBounds = L.latLngBounds(
[BARI_BOUNDS.south, BARI_BOUNDS.west], [BARI_BOUNDS.south, BARI_BOUNDS.west],
[BARI_BOUNDS.north, BARI_BOUNDS.east] [BARI_BOUNDS.north, BARI_BOUNDS.east]
) )
map = L.map('areaMap', { map = L.map('areaMap', {
@@ -74,6 +162,8 @@ const openCreateModal = () => {
polygon = L.polygon([], { color: 'red' }).addTo(map) polygon = L.polygon([], { color: 'red' }).addTo(map)
map.fitBounds(bariBounds) map.fitBounds(bariBounds)
renderExistingAreasInModal(L, map)
map.on('click', (e: any) => { map.on('click', (e: any) => {
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) { if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.' jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
@@ -96,8 +186,8 @@ const loadPolygonFromGeoJson = (boundaryGeojson: any) => {
} }
const normalized = coordinates const normalized = coordinates
.slice(0, -1) .slice(0, -1)
.map(([longitude, latitude]: [number, number]) => [latitude, longitude] as [number, number]) .map(([longitude, latitude]: [number, number]) => [latitude, longitude] as [number, number])
mapPoints.value = normalized mapPoints.value = normalized
if (polygon) { if (polygon) {
@@ -110,7 +200,7 @@ const openEditModal = (area: any) => {
editingAreaId.value = area.id editingAreaId.value = area.id
formData.name = area.name || '' formData.name = area.name || ''
formData.description = area.description || '' formData.description = area.description || ''
formData.geojson = area.boundary_geojson ? JSON.stringify(area.boundary_geojson, null, 2) : '' formData.geojson = area.boundary_geojson || ''
jsonError.value = '' jsonError.value = ''
mapPoints.value = [] mapPoints.value = []
isCreating.value = true isCreating.value = true
@@ -124,8 +214,8 @@ const openEditModal = (area: any) => {
} }
const bariBounds = L.latLngBounds( const bariBounds = L.latLngBounds(
[BARI_BOUNDS.south, BARI_BOUNDS.west], [BARI_BOUNDS.south, BARI_BOUNDS.west],
[BARI_BOUNDS.north, BARI_BOUNDS.east] [BARI_BOUNDS.north, BARI_BOUNDS.east]
) )
map = L.map('areaMap', { map = L.map('areaMap', {
@@ -137,9 +227,20 @@ const openEditModal = (area: any) => {
maxZoom: 19, maxZoom: 19,
attribution: '© OpenStreetMap' attribution: '© OpenStreetMap'
}).addTo(map) }).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) polygon = L.polygon([], { color: 'red' }).addTo(map)
map.fitBounds(bariBounds) map.fitBounds(bariBounds)
renderExistingAreasInModal(L, map)
loadPolygonFromGeoJson(area.boundary_geojson) loadPolygonFromGeoJson(area.boundary_geojson)
map.on('click', (e: any) => { 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 const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number]) // Lng, Lat
if (coords[0]) { if (coords[0]) {
coords.push([...coords[0]]) // Chiudi poligono coords.push([...coords[0]])
} }
formData.geojson = JSON.stringify({ formData.geojson = JSON.stringify({
@@ -191,6 +292,7 @@ const closeCreateModal = () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (map) map.remove() if (map) map.remove()
if (mainMap) mainMap.remove()
}) })
const handleCreate = async () => { const handleCreate = async () => {
@@ -221,8 +323,8 @@ const handleCreate = async () => {
} }
const res = editingAreaId.value const res = editingAreaId.value
? await updateArea(editingAreaId.value, payload) ? await updateArea(editingAreaId.value, payload)
: await createArea(payload) : await createArea(payload)
if (res.ok) { if (res.ok) {
await refresh() await refresh()
@@ -291,13 +393,19 @@ const handleDeleteArea = async () => {
Nessuna area configurata. Nessuna area configurata.
</div> </div>
<div v-if="actionError" class="alert alert-danger"> <template v-else>
{{ actionError }} <!-- Main view map showing all areas -->
</div> <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"> <div v-if="actionError" class="alert alert-danger">
<table class="table table-hover align-middle bg-white shadow-sm rounded"> {{ actionError }}
<thead class="table-light"> </div>
<div v-else class="table-responsive">
<table class="table table-hover align-middle bg-white shadow-sm rounded">
<thead class="table-light">
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Nome</th> <th>Nome</th>
@@ -305,8 +413,8 @@ const handleDeleteArea = async () => {
<th>GeoJSON</th> <th>GeoJSON</th>
<th class="text-end">Azioni</th> <th class="text-end">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="area in areas" :key="area.id"> <tr v-for="area in areas" :key="area.id">
<td>{{ area.id }}</td> <td>{{ area.id }}</td>
<td><strong>{{ area.name }}</strong></td> <td><strong>{{ area.name }}</strong></td>
@@ -320,20 +428,21 @@ const handleDeleteArea = async () => {
Modifica Modifica
</button> </button>
<button <button
class="btn btn-sm btn-outline-danger ms-2" class="btn btn-sm btn-outline-danger ms-2"
:disabled="deletingAreaId === area.id" :disabled="deletingAreaId === area.id"
@click="askDeleteArea(area)" @click="askDeleteArea(area)"
> >
<span v-if="deletingAreaId === area.id" class="spinner-border spinner-border-sm me-1"></span> <span v-if="deletingAreaId === area.id" class="spinner-border spinner-border-sm me-1"></span>
Elimina Elimina
</button> </button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </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-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 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"> <div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
@@ -357,12 +466,12 @@ const handleDeleteArea = async () => {
<div class="mb-3"> <div class="mb-3">
<label class="form-label">GeoJSON Generato</label> <label class="form-label">GeoJSON Generato</label>
<textarea <textarea
class="form-control font-monospace text-sm" class="form-control font-monospace text-sm"
rows="6" rows="6"
v-model="formData.geojson" v-model="formData.geojson"
required required
readonly readonly
placeholder='{ "type": "Polygon", "coordinates": [...] }' placeholder='{ "type": "Polygon", "coordinates": [...] }'
></textarea> ></textarea>
<div v-if="jsonError" class="form-text text-danger mt-1">{{ jsonError }}</div> <div v-if="jsonError" class="form-text text-danger mt-1">{{ jsonError }}</div>
</div> </div>
@@ -394,20 +503,20 @@ const handleDeleteArea = async () => {
</div> </div>
<ConfirmModal <ConfirmModal
:open="areaPendingDelete !== null" :open="areaPendingDelete !== null"
title="Elimina area" title="Elimina area"
:message="areaDeleteMessage" :message="areaDeleteMessage"
confirm-label="Elimina area" confirm-label="Elimina area"
confirm-class="btn-danger" confirm-class="btn-danger"
:pending="deletingAreaId !== null" :pending="deletingAreaId !== null"
@cancel="closeDeleteAreaModal" @cancel="closeDeleteAreaModal"
@confirm="handleDeleteArea" @confirm="handleDeleteArea"
/> />
</div> </div>
</template> </template>
<style scoped> <style scoped>
#areaMap { #areaMap, #mainMap {
background: #e9ecef; background: #e9ecef;
} }