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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
|
|
||||||
class Lieu extends Model
|
class Lieu extends Model
|
||||||
{
|
{
|
||||||
@@ -32,6 +33,16 @@ class Lieu extends Model
|
|||||||
return $this->hasMany(Section::class);
|
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
|
public function calculerNomLong(): string
|
||||||
{
|
{
|
||||||
$noms = [$this->nom];
|
$noms = [$this->nom];
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
@stack('head')
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
<body class="font-sans antialiased bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
Recherche
|
Recherche
|
||||||
</x-nav-link>
|
</x-nav-link>
|
||||||
|
|
||||||
|
<x-nav-link :href="route('carte')" :active="request()->routeIs('carte*')">
|
||||||
|
Carte
|
||||||
|
</x-nav-link>
|
||||||
|
|
||||||
@if(auth()->user()->isSectionManager())
|
@if(auth()->user()->isSectionManager())
|
||||||
<div class="hidden sm:flex sm:items-center">
|
<div class="hidden sm:flex sm:items-center">
|
||||||
<x-dropdown align="left" width="w-56">
|
<x-dropdown align="left" width="w-56">
|
||||||
@@ -198,6 +202,9 @@
|
|||||||
<x-responsive-nav-link :href="route('recherche')" :active="request()->routeIs('recherche')">
|
<x-responsive-nav-link :href="route('recherche')" :active="request()->routeIs('recherche')">
|
||||||
Recherche
|
Recherche
|
||||||
</x-responsive-nav-link>
|
</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()->isSectionManager())
|
||||||
@if(auth()->user()->isAdmin())
|
@if(auth()->user()->isAdmin())
|
||||||
<x-responsive-nav-link :href="route('admin.dashboard')" :active="request()->routeIs('admin.dashboard')">
|
<x-responsive-nav-link :href="route('admin.dashboard')" :active="request()->routeIs('admin.dashboard')">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\CarteController;
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
use App\Http\Controllers\ExportController;
|
use App\Http\Controllers\ExportController;
|
||||||
use App\Http\Controllers\LieuController;
|
use App\Http\Controllers\LieuController;
|
||||||
@@ -57,6 +58,8 @@ Route::middleware('auth')->group(function () {
|
|||||||
->parameters(['releves' => 'releve']);
|
->parameters(['releves' => 'releve']);
|
||||||
|
|
||||||
Route::get('recherche', [RechercheController::class, 'index'])->name('recherche');
|
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/source/{source}', [ExportController::class, 'source'])->name('export.source');
|
||||||
Route::get('export/recherche', [ExportController::class, 'recherche'])->name('export.recherche');
|
Route::get('export/recherche', [ExportController::class, 'recherche'])->name('export.recherche');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user