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:
+160
-51
@@ -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
|
||||||
@@ -46,19 +135,18 @@ const openCreateModal = () => {
|
|||||||
jsonError.value = ''
|
jsonError.value = ''
|
||||||
mapPoints.value = []
|
mapPoints.value = []
|
||||||
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) {
|
||||||
map.remove()
|
map.remove()
|
||||||
map = null
|
map = null
|
||||||
}
|
}
|
||||||
|
|
||||||
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,12 +292,13 @@ const closeCreateModal = () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (map) map.remove()
|
if (map) map.remove()
|
||||||
|
if (mainMap) mainMap.remove()
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
jsonError.value = ''
|
jsonError.value = ''
|
||||||
let parsedGeojson = null
|
let parsedGeojson = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
parsedGeojson = JSON.parse(formData.geojson)
|
parsedGeojson = JSON.parse(formData.geojson)
|
||||||
if (!parsedGeojson.type || !parsedGeojson.coordinates) {
|
if (!parsedGeojson.type || !parsedGeojson.coordinates) {
|
||||||
@@ -221,9 +323,9 @@ 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()
|
||||||
closeCreateModal()
|
closeCreateModal()
|
||||||
@@ -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">
|
||||||
@@ -356,18 +465,18 @@ const handleDeleteArea = async () => {
|
|||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="mb-2 d-flex justify-content-between align-items-center">
|
<div class="mb-2 d-flex justify-content-between align-items-center">
|
||||||
<label class="form-label mb-0">Disegna Poligono sulla Mappa</label>
|
<label class="form-label mb-0">Disegna Poligono sulla Mappa</label>
|
||||||
@@ -394,24 +503,24 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-dialog {
|
.modal-dialog {
|
||||||
margin-block: 0!important;
|
margin-block: 0!important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user