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
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use App\Models\Lieu;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CarteController extends Controller
{
public function index(): View
{
return view('carte.index');
}
public function data(Request $request): JsonResponse
{
$lieux = Lieu::whereNotNull('latitude')
->whereNotNull('longitude')
->whereHas('sources.releves')
->with([
'sources' => function ($q) {
$q->withCount('releves')
->select('id', 'nom', 'lieu_id', 'status', 'annee_debut', 'annee_fin')
->orderBy('nom');
},
])
->get()
->map(function (Lieu $lieu) {
$relevesTotal = $lieu->sources->sum('releves_count');
return [
'id' => $lieu->id,
'nom' => $lieu->nom_long ?? $lieu->nom,
'lat' => (float) $lieu->latitude,
'lng' => (float) $lieu->longitude,
'sources_count' => $lieu->sources->count(),
'releves_count' => $relevesTotal,
'sources' => $lieu->sources->map(fn ($s) => [
'id' => $s->id,
'nom' => $s->nom,
'releves_count' => $s->releves_count,
'status' => $s->status->label(),
'status_value' => $s->status->value,
'annees' => $s->annee_debut
? ($s->annee_debut === $s->annee_fin || ! $s->annee_fin
? (string) $s->annee_debut
: "{$s->annee_debut}{$s->annee_fin}")
: null,
]),
];
});
return response()->json($lieux);
}
}
+11
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
class Lieu extends Model
{
@@ -32,6 +33,16 @@ class Lieu extends Model
return $this->hasMany(Section::class);
}
public function sources(): HasMany
{
return $this->hasMany(Source::class);
}
public function releves(): HasManyThrough
{
return $this->hasManyThrough(Releve::class, Source::class);
}
public function calculerNomLong(): string
{
$noms = [$this->nom];
+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')">
+3
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\CarteController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\ExportController;
use App\Http\Controllers\LieuController;
@@ -57,6 +58,8 @@ Route::middleware('auth')->group(function () {
->parameters(['releves' => 'releve']);
Route::get('recherche', [RechercheController::class, 'index'])->name('recherche');
Route::get('carte', [CarteController::class, 'index'])->name('carte');
Route::get('carte/data', [CarteController::class, 'data'])->name('carte.data');
Route::get('export/source/{source}', [ExportController::class, 'source'])->name('export.source');
Route::get('export/recherche', [ExportController::class, 'recherche'])->name('export.recherche');