308 lines
10 KiB
Vue
308 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
import type { CircleMarker, LatLngBoundsExpression, Map as LeafletMap } from 'leaflet'
|
|
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
|
|
|
|
const { getOpenDataReports } = usePublic()
|
|
|
|
const currentPage = ref(1)
|
|
const perPage = ref(9)
|
|
const { data: openDataResponse, pending } = await useAsyncData(
|
|
'open-data-reports',
|
|
() => getOpenDataReports({ page: currentPage.value, per_page: perPage.value }),
|
|
{ watch: [currentPage] }
|
|
)
|
|
|
|
const visibleReports = computed(() =>
|
|
(openDataResponse.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude))
|
|
)
|
|
|
|
const isLastPage = computed(() => {
|
|
if (!openDataResponse.value?.reports) return true
|
|
return openDataResponse.value.reports.length < perPage.value
|
|
})
|
|
|
|
const nextPage = () => {
|
|
if (!isLastPage.value) currentPage.value++
|
|
}
|
|
const prevPage = () => {
|
|
if (currentPage.value > 1) currentPage.value--
|
|
}
|
|
|
|
const currentView = ref<'grid' | 'map'>('grid')
|
|
const mapContainer = ref<HTMLElement | null>(null)
|
|
let leaflet: typeof import('leaflet') | null = null
|
|
let map: LeafletMap | null = null
|
|
let markers: CircleMarker[] = []
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
switch (status) {
|
|
case 'RESOLVED':
|
|
return { class: 'bg-success', text: 'Risolto', icon: 'it-check-circle' }
|
|
case 'CLOSED':
|
|
return { class: 'bg-secondary', text: 'Chiuso', icon: 'it-lock' }
|
|
default:
|
|
return { class: 'bg-info', text: status, icon: 'it-info-circle' }
|
|
}
|
|
}
|
|
|
|
const loadLeaflet = async () => {
|
|
if (leaflet) return leaflet
|
|
leaflet = await import('leaflet')
|
|
return leaflet
|
|
}
|
|
|
|
const clearMarkers = () => {
|
|
if (!map) return
|
|
markers.forEach((marker) => marker.removeFrom(map!))
|
|
markers = []
|
|
}
|
|
|
|
const renderMapMarkers = async () => {
|
|
if (!import.meta.client || !mapContainer.value) return
|
|
|
|
const L = await loadLeaflet()
|
|
if (!map) return
|
|
|
|
clearMarkers()
|
|
|
|
if (visibleReports.value.length === 0) {
|
|
map.setView(BARI_CENTER, 12)
|
|
return
|
|
}
|
|
|
|
visibleReports.value.forEach((report) => {
|
|
const marker = L.circleMarker([report.latitude, report.longitude], {
|
|
radius: 8,
|
|
weight: 2,
|
|
color: '#ffffff',
|
|
fillColor: getStatusBadge(report.status).class === 'bg-success' ? '#008758' : getStatusBadge(report.status).class === 'bg-secondary' ? '#5c6f82' : '#2f80ed',
|
|
fillOpacity: 0.95,
|
|
})
|
|
|
|
marker.bindPopup(`
|
|
<div class="open-data-popup">
|
|
<strong>${report.category_name}</strong><br/>
|
|
<span>ID #${report.id}</span><br/>
|
|
<span>${report.area_name || 'Area comunale non specificata'}</span><br/>
|
|
<span>Segnalato ${new Date(report.created_at).toLocaleDateString('it-IT')}</span>
|
|
</div>
|
|
`)
|
|
|
|
marker.addTo(map!)
|
|
markers.push(marker)
|
|
})
|
|
|
|
const fitBounds: LatLngBoundsExpression = visibleReports.value.map((report) => [report.latitude, report.longitude])
|
|
map.fitBounds(fitBounds, { padding: [32, 32], maxZoom: 15 })
|
|
}
|
|
|
|
const initMap = async () => {
|
|
if (!import.meta.client || !mapContainer.value || map) return
|
|
|
|
const L = await loadLeaflet()
|
|
const bariBounds = L.latLngBounds(
|
|
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
|
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
|
)
|
|
|
|
map = L.map(mapContainer.value, {
|
|
maxBounds: bariBounds,
|
|
maxBoundsViscosity: 1,
|
|
minZoom: 11,
|
|
}).setView(BARI_CENTER, 12)
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map)
|
|
|
|
await renderMapMarkers()
|
|
}
|
|
|
|
watch(currentView, async (view) => {
|
|
if (view !== 'map') return
|
|
await nextTick()
|
|
if (!map) {
|
|
await initMap()
|
|
} else {
|
|
map.invalidateSize()
|
|
await renderMapMarkers()
|
|
}
|
|
})
|
|
|
|
watch(visibleReports, async () => {
|
|
if (currentView.value === 'map' && map) {
|
|
await renderMapMarkers()
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
clearMarkers()
|
|
map?.remove()
|
|
map = null
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="it-hero-wrapper it-primary">
|
|
<div class="container">
|
|
<div class="row">
|
|
<div class="col-12 text-center">
|
|
<div class="it-hero-text-wrapper p-4">
|
|
<h1 class="text-white font-weight-bold">Open Data Interventi</h1>
|
|
<p class="text-white mt-2 lead">
|
|
Esplora i dati pubblici degli interventi urbani risolti. La trasparenza al servizio del cittadino.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container my-5">
|
|
<div class="row align-items-center mb-5 pb-3 border-bottom">
|
|
<div class="col-12 col-md-6 mb-3 mb-md-0 d-flex gap-2">
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="currentView === 'grid' ? 'btn-primary' : 'btn-outline-primary'"
|
|
@click="currentView = 'grid'"
|
|
>
|
|
<BIIcon :color="currentView === 'grid' ? 'white' : 'primary'" class="me-1" name="it-grid" size="sm"/>
|
|
Griglia Dati
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="currentView === 'map' ? 'btn-primary' : 'btn-outline-primary'"
|
|
@click="currentView = 'map'"
|
|
>
|
|
<BIIcon :color="currentView === 'map' ? 'white' : 'primary'" class="me-1" name="it-map-marker" size="sm"/>
|
|
Mappa
|
|
</button>
|
|
</div>
|
|
<div class="col-12 col-md-6 text-md-end">
|
|
<button class="btn btn-sm btn-outline-secondary" title="Scarica dataset in formato JSON">
|
|
<BIIcon class="me-1" name="it-download" size="sm"/>
|
|
Scarica JSON
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="pending" class="d-flex justify-content-center my-5 py-5">
|
|
<div class="progress-spinner progress-spinner-active" role="status">
|
|
<span class="visually-hidden">Caricamento in corso...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-show="!pending && currentView === 'grid'">
|
|
<div class="row" v-if="openDataResponse?.reports && openDataResponse.reports.length > 0">
|
|
<div class="col-12 col-md-6 col-lg-4 mb-4" v-for="report in visibleReports" :key="report.id">
|
|
<div class="card-wrapper card-space h-100">
|
|
<div class="card card-bg h-100 shadow-sm">
|
|
<div class="card-body d-flex flex-column">
|
|
|
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
|
<div class="category-top m-0">
|
|
<span :class="getStatusBadge(report.status).class"
|
|
class="badge rounded-pill text-white px-3 py-1">
|
|
<BIIcon :name="getStatusBadge(report.status).icon" class="me-1" color="white" size="xs"/>
|
|
{{ getStatusBadge(report.status).text }}
|
|
</span>
|
|
</div>
|
|
<span class="text-muted small">ID: #{{ report.id }}</span>
|
|
</div>
|
|
|
|
<h3 class="card-title h5 font-weight-bold">{{ report.category_name }}</h3>
|
|
|
|
<p class="card-text text-muted mb-4">
|
|
<BIIcon class="me-1" name="it-map-marker-circle" size="sm"/>
|
|
{{ report.area_name || 'Area comunale non specificata' }}
|
|
</p>
|
|
|
|
<div class="mt-auto pt-3 border-top">
|
|
<div class="row text-muted small">
|
|
<div class="col-6">
|
|
<div class="fw-bold text-uppercase">Segnalato</div>
|
|
<div>{{ new Date(report.created_at).toLocaleDateString('it-IT') }}</div>
|
|
</div>
|
|
<div class="col-6 text-end">
|
|
<div class="fw-bold text-uppercase">Risolto</div>
|
|
<div>{{ new Date(report.updated_at).toLocaleDateString('it-IT') }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="alert alert-warning text-center" role="alert">
|
|
Nessun dato pubblico disponibile al momento.
|
|
</div>
|
|
|
|
<nav class="pagination-wrapper justify-content-center mt-5" aria-label="Navigazione pagine open data">
|
|
<ul class="pagination">
|
|
<li class="page-item" :class="{ disabled: currentPage === 1 }">
|
|
<a class="page-link" href="#" tabindex="-1" aria-disabled="true" @click.prevent="prevPage">
|
|
<BIIcon name="it-chevron-left" size="sm"/>
|
|
<span class="visually-hidden">Pagina precedente</span>
|
|
</a>
|
|
</li>
|
|
<li class="page-item">
|
|
<span class="page-link font-weight-semibold">Pagina {{ currentPage }}</span>
|
|
</li>
|
|
<li class="page-item" :class="{ disabled: isLastPage }">
|
|
<a class="page-link" href="#" @click.prevent="nextPage">
|
|
<span class="visually-hidden">Pagina successiva</span>
|
|
<BIIcon name="it-chevron-right" size="sm"/>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
|
|
<div v-show="!pending && currentView === 'map'" class="map-view-wrapper">
|
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
|
<p class="text-muted mb-0">Mappa pubblica delle segnalazioni nell'area di Bari.</p>
|
|
<span class="badge bg-light text-dark border">{{ visibleReports.length }} punti</span>
|
|
</div>
|
|
<ClientOnly>
|
|
<div ref="mapContainer" class="open-data-map rounded border"></div>
|
|
</ClientOnly>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.card-wrapper {
|
|
height: 100%;
|
|
}
|
|
|
|
.card-bg {
|
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
|
|
.card-bg:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important;
|
|
}
|
|
|
|
.open-data-map {
|
|
height: 520px;
|
|
width: 100%;
|
|
z-index: 1;
|
|
}
|
|
|
|
:global(.open-data-popup) {
|
|
color: #17324d;
|
|
font-family: "Titillium Web", Arial, sans-serif;
|
|
font-size: 0.875rem;
|
|
line-height: 1.4;
|
|
min-width: 12rem;
|
|
}
|
|
</style> |