Page carte interactive des relevés (Leaflet + OpenStreetMap)

- CarteController : index() + data() (JSON) — requête lieux géolocalisés
  ayant des sources avec relevés, agrégats par lieu
- Lieu model : relations sources() et releves() (hasManyThrough)
- Vue carte/index.blade.php : carte Leaflet pleine hauteur, marqueurs
  colorés par nombre de sources (taille/couleur proportionnels),
  popup par lieu avec liste des sources, statuts, années et lien recherche
- Tuiles OpenStreetMap inversées en mode sombre
- Route GET /carte + GET /carte/data
- Lien "Carte" dans la navigation desktop et mobile
- @stack('head') dans le layout pour injecter Leaflet uniquement sur la page carte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 21:02:28 +02:00
parent 6f55663984
commit a1860e9462
6 changed files with 248 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Carte des relevés</h2>
<p class="text-sm text-gray-500 dark:text-gray-400" id="carte-stats"></p>
</div>
</x-slot>
@push('head')
{{-- Leaflet CSS --}}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<style>
#carte-map {
height: calc(100vh - 120px);
min-height: 400px;
}
/* Popup dark mode */
.dark .leaflet-popup-content-wrapper,
.dark .leaflet-popup-tip {
background-color: #1f2937;
color: #f3f4f6;
}
.dark .leaflet-popup-content-wrapper {
border: 1px solid #374151;
}
/* Contrôles dark mode */
.dark .leaflet-control-zoom a,
.dark .leaflet-control-attribution {
background-color: #1f2937;
color: #d1d5db;
border-color: #374151;
}
.dark .leaflet-control-zoom a:hover {
background-color: #374151;
}
/* Tuiles légèrement assombries en dark mode */
.dark .leaflet-tile {
filter: brightness(0.7) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7);
}
</style>
@endpush
{{-- La carte occupe toute la hauteur disponible --}}
<div id="carte-map" class="w-full"></div>
@push('head')
{{-- Leaflet JS (chargé en fin de head pour ne pas bloquer le rendu) --}}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV/XN2GqM8=" crossorigin=""
defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// ── Initialisation de la carte ──────────────────────────────────────
const map = L.map('carte-map', {
center: [46.5, 2.2], // centre de la France
zoom: 6,
zoomControl: true,
});
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
// ── Icône personnalisée ────────────────────────────────────────────
function makeIcon(count) {
const size = count > 10 ? 40 : count > 3 ? 34 : 28;
const color = count > 10 ? '#4f46e5' : count > 3 ? '#6366f1' : '#818cf8';
return L.divIcon({
className: '',
html: `<div style="
width:${size}px; height:${size}px;
background:${color};
border:3px solid white;
border-radius:50%;
box-shadow:0 2px 6px rgba(0,0,0,.35);
display:flex; align-items:center; justify-content:center;
font-size:${size > 30 ? 13 : 11}px;
font-weight:700; color:white; font-family:sans-serif;
">${count}</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 4)],
});
}
// ── Statut → badge couleur ─────────────────────────────────────────
const statusColors = {
'a_faire': 'background:#f3f4f6;color:#374151',
'en_cours': 'background:#dbeafe;color:#1d4ed8',
'a_valider': 'background:#fef9c3;color:#92400e',
'termine': 'background:#d1fae5;color:#065f46',
};
// ── Chargement des données ─────────────────────────────────────────
fetch('{{ route('carte.data') }}')
.then(r => r.json())
.then(lieux => {
if (lieux.length === 0) {
document.getElementById('carte-stats').textContent =
'Aucun lieu géolocalisé avec des relevés.';
return;
}
const totalReleves = lieux.reduce((s, l) => s + l.releves_count, 0);
document.getElementById('carte-stats').textContent =
`${lieux.length} lieu${lieux.length > 1 ? 'x' : ''} · ${totalReleves.toLocaleString('fr-FR')} relevé${totalReleves > 1 ? 's' : ''}`;
const bounds = [];
lieux.forEach(lieu => {
bounds.push([lieu.lat, lieu.lng]);
// ── Contenu du popup ───────────────────────────────────
const sourcesHtml = lieu.sources.map(s => {
const style = statusColors[s.status_value] || statusColors['a_faire'];
const annees = s.annees ? ` <span style="color:#6b7280;font-size:11px">(${s.annees})</span>` : '';
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:3px 0;border-bottom:1px solid #f3f4f6">
<div style="min-width:0">
<a href="/sources/${s.id}" style="color:#4f46e5;font-size:13px;font-weight:500;text-decoration:none"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">${s.nom}</a>${annees}
</div>
<div style="display:flex;align-items:center;gap:6px;flex-shrink:0">
<span style="font-size:11px;${style};padding:1px 6px;border-radius:9999px;white-space:nowrap">${s.status}</span>
<span style="font-size:12px;color:#6b7280;white-space:nowrap">${s.releves_count.toLocaleString('fr-FR')} rel.</span>
</div>
</div>`;
}).join('');
const popup = `
<div style="min-width:260px;max-width:320px;font-family:sans-serif">
<div style="font-size:15px;font-weight:700;margin-bottom:6px;padding-bottom:6px;border-bottom:2px solid #e5e7eb">
📍 ${lieu.nom}
</div>
<div style="font-size:12px;color:#6b7280;margin-bottom:8px">
${lieu.sources_count} source${lieu.sources_count > 1 ? 's' : ''} ·
<strong>${lieu.releves_count.toLocaleString('fr-FR')}</strong> relevé${lieu.releves_count > 1 ? 's' : ''}
</div>
<div>${sourcesHtml}</div>
<div style="margin-top:8px;text-align:right">
<a href="/recherche?lieu_id=${lieu.id}"
style="font-size:12px;color:#4f46e5;text-decoration:none"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">
Rechercher dans ces relevés
</a>
</div>
</div>`;
L.marker([lieu.lat, lieu.lng], { icon: makeIcon(lieu.sources_count) })
.addTo(map)
.bindPopup(popup, { maxWidth: 340 });
});
// Ajuster la vue sur tous les marqueurs
if (bounds.length > 0) {
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
}
})
.catch(() => {
document.getElementById('carte-stats').textContent = 'Erreur lors du chargement des données.';
});
});
</script>
@endpush
</x-app-layout>
+1
View File
@@ -31,6 +31,7 @@
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@stack('head')
</head>
<body class="font-sans antialiased bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<div class="min-h-screen">
@@ -33,6 +33,10 @@
Recherche
</x-nav-link>
<x-nav-link :href="route('carte')" :active="request()->routeIs('carte*')">
Carte
</x-nav-link>
@if(auth()->user()->isSectionManager())
<div class="hidden sm:flex sm:items-center">
<x-dropdown align="left" width="w-56">
@@ -198,6 +202,9 @@
<x-responsive-nav-link :href="route('recherche')" :active="request()->routeIs('recherche')">
Recherche
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('carte')" :active="request()->routeIs('carte*')">
Carte
</x-responsive-nav-link>
@if(auth()->user()->isSectionManager())
@if(auth()->user()->isAdmin())
<x-responsive-nav-link :href="route('admin.dashboard')" :active="request()->routeIs('admin.dashboard')">