From 5fe2804a1aec221aab1a57c3c0d93009ac7d7044 Mon Sep 17 00:00:00 2001 From: Tykayn Date: Sat, 5 Jul 2025 16:15:56 +0200 Subject: [PATCH] =?UTF-8?q?carte=20th=C3=A8matique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Controller/AdminController.php | 67 +++-- src/Service/FollowUpService.php | 4 +- src/Service/Motocultrice.php | 55 ++++ .../admin/followup_embed_graph.html.twig | 2 +- .../admin/followup_theme_graph.html.twig | 267 +++++++++++------- 5 files changed, 253 insertions(+), 142 deletions(-) diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 943bac7..13f1539 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -381,7 +381,7 @@ final class AdminController extends AbstractController } } unset($row); - + // Normalisation des scores pondérés entre 0 et 100 foreach ($podium_local as &$row) { if ($maxPondere > 0 && $row['completion_pondere'] !== null) { @@ -391,7 +391,7 @@ final class AdminController extends AbstractController } } unset($row); - + // Tri décroissant sur le score normalisé usort($podium_local, function ($a, $b) { return ($b['completion_pondere_normalisee'] ?? 0) <=> ($a['completion_pondere_normalisee'] ?? 0); @@ -452,6 +452,8 @@ final class AdminController extends AbstractController $progression7Days[$type] = \App\Service\FollowUpService::calculate7DayProgression($stats, $type); } $progression7Days['places'] = \App\Service\FollowUpService::calculate7DayProgression($stats, 'places'); + + return $this->render('admin/stats.html.twig', [ 'stats' => $stats, @@ -467,7 +469,7 @@ final class AdminController extends AbstractController 'latestFollowups' => $latestFollowups, 'followup_labels' => \App\Service\FollowUpService::getFollowUpThemes(), 'followup_icons' => \App\Service\FollowUpService::getFollowUpIcons(), - 'progression7Days' => $progression7Days, + 'progression7Days' => $progression7Days, 'all_types' => \App\Service\FollowUpService::getFollowUpThemes(), ]); } @@ -491,7 +493,7 @@ final class AdminController extends AbstractController $followups = $stats->getCityFollowUps(); $countData = []; $completionData = []; - + foreach ($followups as $fu) { if ($fu->getName() === $theme . '_count') { $countData[] = [ @@ -525,7 +527,7 @@ final class AdminController extends AbstractController $josm_url = null; } // Fonction utilitaire pour extraire clé/valeur de la requête Overpass - $extractTag = function($query) { + $extractTag = function ($query) { if (preg_match('/\\[([a-zA-Z0-9:_-]+)\\]="([^"]+)"/', $query, $matches)) { return [$matches[1], $matches[2]]; } @@ -569,7 +571,7 @@ final class AdminController extends AbstractController 'lat' => $place->getLat(), 'lon' => $place->getLon(), 'name' => $place->getName(), - 'tags' => [ 'main_tag' => $place->getMainTag() ], + 'tags' => ['main_tag' => $place->getMainTag()], 'is_complete' => !empty($place->getName()), 'osm_url' => 'https://www.openstreetmap.org/' . $place->getOsmKind() . '/' . $place->getOsmId(), ]; @@ -577,7 +579,7 @@ final class AdminController extends AbstractController } $geojson = [ 'type' => 'FeatureCollection', - 'features' => array_map(function($obj) { + 'features' => array_map(function ($obj) { return [ 'type' => 'Feature', 'geometry' => [ @@ -608,6 +610,7 @@ final class AdminController extends AbstractController 'completion_data' => json_encode($completionData), 'icons' => \App\Service\FollowUpService::getFollowUpIcons(), 'geojson' => json_encode($geojson), + 'overpass_query' => $overpass_query, 'josm_url' => $josm_url, 'center' => $center, 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null, @@ -617,14 +620,16 @@ final class AdminController extends AbstractController #[Route('/admin/placeType/{osm_kind}/{osm_id}', name: 'app_admin_by_osm_id')] public function placeType(string $osm_kind, string $osm_id): Response { - + $place = $this->entityManager->getRepository(Place::class)->findOneBy(['osm_kind' => $osm_kind, 'osmId' => $osm_id]); if ($place) { - $this->actionLogger->log('admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id, - 'name' => $place->getName(), - 'code_insee' => $place->getZipCode(), - 'uuid' => $place->getUuidForUrl() - ]); + $this->actionLogger->log('admin/placeType', [ + 'osm_kind' => $osm_kind, + 'osm_id' => $osm_id, + 'name' => $place->getName(), + 'code_insee' => $place->getZipCode(), + 'uuid' => $place->getUuidForUrl() + ]); return $this->redirectToRoute('app_admin_commerce', ['id' => $place->getId()]); } else { $this->actionLogger->log('ERROR_admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id]); @@ -1520,11 +1525,11 @@ final class AdminController extends AbstractController public function podiumContributeursOsm(): Response { $this->actionLogger->log('admin/podium_contributeurs_osm', []); - + // Récupérer tous les lieux avec un utilisateur OSM $places = $this->entityManager->getRepository(Place::class)->findBy(['osm_user' => null], ['osm_user' => 'ASC']); $places = array_filter($places, fn($place) => $place->getOsmUser() !== null); - + // Compter les contributions par utilisateur $contributions = []; foreach ($places as $place) { @@ -1536,13 +1541,13 @@ final class AdminController extends AbstractController $contributions[$user]++; } } - + // Trier par nombre de contributions décroissant arsort($contributions); - + // Prendre les 10 premiers $topContributors = array_slice($contributions, 0, 10, true); - + return $this->render('admin/podium_contributeurs_osm.html.twig', [ 'contributors' => $topContributors ]); @@ -1555,15 +1560,17 @@ final class AdminController extends AbstractController if ($request->isMethod('POST')) { $uploadedFile = $request->files->get('json_file'); - + if (!$uploadedFile) { $this->addFlash('error', 'Aucun fichier JSON n\'a été fourni.'); return $this->redirectToRoute('app_admin_import_stats'); } // Vérifier le type de fichier - if ($uploadedFile->getClientMimeType() !== 'application/json' && - $uploadedFile->getClientOriginalExtension() !== 'json') { + if ( + $uploadedFile->getClientMimeType() !== 'application/json' && + $uploadedFile->getClientOriginalExtension() !== 'json' + ) { $this->addFlash('error', 'Le fichier doit être au format JSON.'); return $this->redirectToRoute('app_admin_import_stats'); } @@ -1598,7 +1605,7 @@ final class AdminController extends AbstractController // Vérifier si l'objet Stats existe déjà $existingStats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zone]); - + if ($existingStats) { $skippedCount++; continue; // Ignorer les objets existants @@ -1607,9 +1614,9 @@ final class AdminController extends AbstractController // Créer un nouvel objet Stats $stats = new Stats(); $stats->setZone($zone) - ->setName($name) - ->setDateCreated(new \DateTime()) - ->setDateModified(new \DateTime()); + ->setName($name) + ->setDateCreated(new \DateTime()) + ->setDateModified(new \DateTime()); // Remplir les champs optionnels if (isset($statData['population'])) { @@ -1656,7 +1663,6 @@ final class AdminController extends AbstractController $this->entityManager->persist($stats); $createdCount++; - } catch (\Exception $e) { $errors[] = "Ligne " . ($index + 1) . ": " . $e->getMessage(); } @@ -1680,7 +1686,6 @@ final class AdminController extends AbstractController 'skipped' => $skippedCount, 'errors' => count($errors) ]); - } catch (\Exception $e) { $this->addFlash('error', 'Erreur lors de l\'import : ' . $e->getMessage()); $this->actionLogger->log('admin/import_stats_error', ['error' => $e->getMessage()]); @@ -1696,7 +1701,7 @@ final class AdminController extends AbstractController public function exportOverpassCsv($insee_code): Response { $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); - + if (!$stats) { throw $this->createNotFoundException('Stats non trouvées pour ce code INSEE'); } @@ -1747,7 +1752,7 @@ final class AdminController extends AbstractController public function exportTableCsv($insee_code): Response { $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); - + if (!$stats) { throw $this->createNotFoundException('Stats non trouvées pour ce code INSEE'); } @@ -1784,7 +1789,7 @@ final class AdminController extends AbstractController $osmKind = $place->getOsmKind(); $osmId = $place->getOsmId(); $osmLink = ($osmKind && $osmId) ? 'https://www.openstreetmap.org/' . $osmKind . '/' . $osmId : ''; - + // Construire l'adresse complète $address = ''; if ($place->getHousenumber() && $place->getStreet()) { @@ -1792,7 +1797,7 @@ final class AdminController extends AbstractController } elseif ($place->getStreet()) { $address = $place->getStreet(); } - + fputcsv($output, [ $place->getName() ?: '(sans nom)', $place->getEmail() ?: '', diff --git a/src/Service/FollowUpService.php b/src/Service/FollowUpService.php index 6a47222..4b0b3b9 100644 --- a/src/Service/FollowUpService.php +++ b/src/Service/FollowUpService.php @@ -480,7 +480,7 @@ class FollowUpService public static function getFollowUpOverpassQueries(): array { - return [ + return [ 'fire_hydrant' => 'nwr["emergency"="fire_hydrant"](area.searchArea);', 'charging_station' => 'nwr["amenity"="charging_station"](area.searchArea);', 'toilets' => 'nwr["amenity"="toilets"](area.searchArea);', @@ -495,7 +495,7 @@ class FollowUpService 'healthcare' => 'nwr["healthcare"](area.searchArea);nwr["amenity"="doctors"](area.searchArea);nwr["amenity"="pharmacy"](area.searchArea);nwr["amenity"="hospital"](area.searchArea);nwr["amenity"="clinic"](area.searchArea);nwr["amenity"="social_facility"](area.searchArea);', 'bicycle_parking' => 'nwr["amenity"="bicycle_parking"](area.searchArea);', 'advertising_board' => 'nwr["advertising"="board"]["message"="political"](area.searchArea);', - 'building' => 'way["building"](area.searchArea);', + 'building' => 'nwr["building"](area.searchArea);', 'email' => 'nwr["email"](area.searchArea);nwr["contact:email"](area.searchArea);', 'bench' => 'nwr["amenity"="bench"](area.searchArea);', 'waste_basket' => 'nwr["amenity"="waste_basket"](area.searchArea);', diff --git a/src/Service/Motocultrice.php b/src/Service/Motocultrice.php index 68e3bc5..742486e 100644 --- a/src/Service/Motocultrice.php +++ b/src/Service/Motocultrice.php @@ -619,4 +619,59 @@ QUERY; return []; } } + + /** + * Calcule la progression sur 7j, 30j, 6 mois, 1 an pour une série de points [{date, value}] + * @param array $data + * @param \DateTime|null $now + * @return array + */ + public function calculateProgressions(array $data, ?\DateTime $now = null): array + { + $periods = [ + '7j' => '-7 days', + '30j' => '-30 days', + '6m' => '-6 months', + '1a' => '-1 year', + ]; + $progressions = []; + if ($now === null) $now = new \DateTime(); + $calculateDelta = function(array $data, \DateTime $refDate) { + if (empty($data)) return null; + $last = end($data)['value']; + $exactRef = null; + foreach (array_reverse($data) as $point) { + $pointDate = new \DateTime($point['date']); + if ($pointDate <= $refDate) { + $exactRef = $point['value']; + break; + } + } + if ($exactRef !== null) return $last - $exactRef; + $beforeRef = null; $afterRef = null; $beforeDate = null; $afterDate = null; + foreach (array_reverse($data) as $point) { + $pointDate = new \DateTime($point['date']); + if ($pointDate < $refDate) { $beforeRef = $point['value']; $beforeDate = $pointDate; break; } + } + foreach ($data as $point) { + $pointDate = new \DateTime($point['date']); + if ($pointDate > $refDate) { $afterRef = $point['value']; $afterDate = $pointDate; break; } + } + if ($beforeRef !== null && $afterRef !== null && $beforeDate && $afterDate) { + $timeDiff = $afterDate->getTimestamp() - $beforeDate->getTimestamp(); + $refTimeDiff = $refDate->getTimestamp() - $beforeDate->getTimestamp(); + $ratio = $refTimeDiff / $timeDiff; + $interpolatedRef = $beforeRef + ($afterRef - $beforeRef) * $ratio; + return $last - $interpolatedRef; + } + if ($beforeRef !== null) return $last - $beforeRef; + if ($afterRef !== null) return $last - $afterRef; + return null; + }; + foreach ($periods as $label => $mod) { + $refDate = (clone $now)->modify($mod); + $progressions[$label] = $calculateDelta($data, $refDate); + } + return $progressions; + } } \ No newline at end of file diff --git a/templates/admin/followup_embed_graph.html.twig b/templates/admin/followup_embed_graph.html.twig index cea0bfd..2f43194 100644 --- a/templates/admin/followup_embed_graph.html.twig +++ b/templates/admin/followup_embed_graph.html.twig @@ -13,7 +13,7 @@ Graphe détaillé - + OSM Mon Commerce diff --git a/templates/admin/followup_theme_graph.html.twig b/templates/admin/followup_theme_graph.html.twig index 442f5b1..653cc70 100644 --- a/templates/admin/followup_theme_graph.html.twig +++ b/templates/admin/followup_theme_graph.html.twig @@ -172,6 +172,14 @@ {% endif %}
+ {% if overpass_query is defined %} +
+ + Voir la requête sur Overpass Turbo + +
+ {% endif %} +
-
@@ -191,21 +199,34 @@
-
- - -
- -
-
- +
+
+ {#
+
Progression
+ + + + + + + + +
PériodeNombreComplétion (%)
7 jours{{ progressions.count['7j'] is not null ? '%+d'|format(progressions.count['7j']) : '–' }}{{ progressions.completion['7j'] is not null ? '%+.1f'|format(progressions.completion['7j']) : '–' }}
30 jours{{ progressions.count['30j'] is not null ? '%+d'|format(progressions.count['30j']) : '–' }}{{ progressions.completion['30j'] is not null ? '%+.1f'|format(progressions.completion['30j']) : '–' }}
6 mois{{ progressions.count['6m'] is not null ? '%+d'|format(progressions.count['6m']) : '–' }}{{ progressions.completion['6m'] is not null ? '%+.1f'|format(progressions.completion['6m']) : '–' }}
1 an{{ progressions.count['1a'] is not null ? '%+d'|format(progressions.count['1a']) : '–' }}{{ progressions.completion['1a'] is not null ? '%+.1f'|format(progressions.completion['1a']) : '–' }}
+
#} +
+
+
+ + +
-
-
- -
+
+
{% endblock %} @@ -224,34 +245,78 @@ console.log('[DEBUG] mapCenter:', mapCenter); if (mapToken && geojson && geojson.features && geojson.features.length > 0) { console.log('[DEBUG] Initialisation de la carte Maplibre...'); - const map = new maplibregl.Map({ - container: 'themeMap', - style: `https://api.maptiler.com/maps/streets/style.json?key=${mapToken}`, - center: mapCenter || geojson.features[0].geometry.coordinates, - zoom: 13 - }); - map.addControl(new maplibregl.NavigationControl()); - geojson.features.forEach(f => { - let color = f.properties.is_complete ? '#198754' : '#adb5bd'; - if (!f.properties.is_complete && (f.properties.tags && (f.properties.tags.name || f.properties.tags.operator))) { - color = '#ffc107'; // partiel + let mapInstance = null; + function getStyleUrl(style) { + if (style === 'streets') { + return `https://api.maptiler.com/maps/streets/style.json?key=${mapToken}`; + } else if (style === 'satellite') { + // BD Ortho IGN WMTS (clé publique, usage limité) + return { + version: 8, + sources: { + "bdortho": { + "type": "raster", + "tiles": [ + "https://wxs.ign.fr/essentiels/geoportail/wmts?layer=ORTHOIMAGERY.ORTHOPHOTOS&style=normal&tilematrixset=PM&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/jpeg&TileMatrix={z}&TileCol={x}&TileRow={y}" + ], + "tileSize": 256, + "attribution": "Données © IGN BD Ortho" + } + }, + layers: [ + { + "id": "bdortho", + "type": "raster", + "source": "bdortho", + "minzoom": 0, + "maxzoom": 19 + } + ] + }; } - const marker = new maplibregl.Marker({ color: color }) - .setLngLat(f.geometry.coordinates) - .setPopup(new maplibregl.Popup({ offset: 18 }) - .setHTML(` -
- ${f.properties.name || '(sans nom)'}
- ${f.properties.osm_kind} ${f.properties.id}
- - ${Object.entries(f.properties.tags).map(([k,v]) => `${k}: ${v}`).join('
')} -
- Voir sur OSM -
- `) - ) - .addTo(map); - }); + return null; + } + function initMap(style) { + if (mapInstance) { mapInstance.remove(); } + const styleUrl = getStyleUrl(style); + mapInstance = new maplibregl.Map({ + container: 'themeMap', + style: styleUrl, + center: mapCenter || (geojson.features && geojson.features[0] ? geojson.features[0].geometry.coordinates : [2,48]), + zoom: 13 + }); + mapInstance.addControl(new maplibregl.NavigationControl()); + geojson.features.forEach(f => { + let color = f.properties.is_complete ? '#198754' : '#adb5bd'; + if (!f.properties.is_complete && (f.properties.tags && (f.properties.tags.name || f.properties.tags.operator))) { + color = '#ffc107'; + } + const marker = new maplibregl.Marker({ color: color }) + .setLngLat(f.geometry.coordinates) + .setPopup(new maplibregl.Popup({ offset: 18 }) + .setHTML(` +
+ ${f.properties.name || '(sans nom)'}
+ ${f.properties.osm_kind} ${f.properties.id}
+ + ${Object.entries(f.properties.tags).map(([k,v]) => `${k}: ${v}`).join('
')} +
+ Voir sur OSM +
+ `) + ) + .addTo(mapInstance); + }); + } + // Initialisation par défaut + initMap('streets'); + // Sélecteur de fond de carte + const basemapSelect = document.getElementById('basemapSelect'); + if (basemapSelect) { + basemapSelect.addEventListener('change', function() { + initMap(this.value); + }); + } } else { console.warn('[DEBUG] Carte non initialisée : conditions non remplies.'); if (!mapToken) { @@ -335,88 +400,74 @@ } }; - // Graphique du nombre d'objets - const countCtx = document.getElementById('countChart').getContext('2d'); - const countChart = new Chart(countCtx, { + // Graphique fusionné + const ctx = document.getElementById('themeChart').getContext('2d'); + new Chart(ctx, { type: 'line', data: { - datasets: [{ - label: 'Nombre d\'objets', - data: countData.map(d => ({ - x: new Date(d.date), - y: d.value - })), - borderColor: '#0d6efd', - backgroundColor: 'rgba(13, 110, 253, 0.1)', - borderWidth: 2, - fill: true, - tension: 0.1 - }] - }, - options: { - ...commonOptions, - scales: { - ...commonOptions.scales, - y: { - ...commonOptions.scales.y, - title: { - display: true, - text: 'Nombre d\'objets' - } + datasets: [ + { + label: "Nombre d'objets", + data: countData.map(d => ({ x: new Date(d.date), y: d.value })), + borderColor: '#0d6efd', + backgroundColor: 'rgba(13, 110, 253, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.1, + yAxisID: 'y1', + }, + { + label: 'Pourcentage de complétion', + data: completionData.map(d => ({ x: new Date(d.date), y: d.value })), + borderColor: '#198754', + backgroundColor: 'rgba(25, 135, 84, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.1, + yAxisID: 'y2', } - } - } - }); - - // Graphique de la complétion - const completionCtx = document.getElementById('completionChart').getContext('2d'); - const completionChart = new Chart(completionCtx, { - type: 'line', - data: { - datasets: [{ - label: 'Pourcentage de complétion', - data: completionData.map(d => ({ - x: new Date(d.date), - y: d.value - })), - borderColor: '#198754', - backgroundColor: 'rgba(25, 135, 84, 0.1)', - borderWidth: 2, - fill: true, - tension: 0.1 - }] + ] }, options: { - ...commonOptions, + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: true }, + tooltip: { mode: 'index', intersect: false } + }, + interaction: { mode: 'nearest', axis: 'x', intersect: false }, scales: { - ...commonOptions.scales, - y: { - ...commonOptions.scales.y, + x: { + type: 'time', + time: { unit: 'day', displayFormats: { day: 'dd/MM/yyyy' } }, + title: { display: true, text: 'Date' } + }, + y1: { + type: 'linear', + position: 'left', + title: { display: true, text: "Nombre d'objets" }, + beginAtZero: true + }, + y2: { + type: 'linear', + position: 'right', + title: { display: true, text: 'Complétion (%)' }, + min: 0, max: 100, - title: { - display: true, - text: 'Pourcentage de complétion (%)' - } + grid: { drawOnChartArea: false } } } } }); - // Gestion des onglets - document.querySelectorAll('.chart-tab').forEach(tab => { - tab.addEventListener('click', function() { - // Retirer la classe active de tous les onglets - document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.chart-content').forEach(c => c.classList.remove('active')); - - // Ajouter la classe active à l'onglet cliqué - this.classList.add('active'); - const chartId = this.getAttribute('data-chart') + '-chart'; - document.getElementById(chartId).classList.add('active'); - }); - }); - // Initialiser les statistiques updateStats(); + + // Ajout debug JS requête Overpass + {% if overpass_query is defined %} + console.log('[DEBUG][Overpass] Requête envoyée à Overpass :', `{{ overpass_query|e('js') }}`); + {% else %} + console.log('[DEBUG][Overpass] Aucune requête Overpass transmise à la page.'); + {% endif %} {% endblock %} \ No newline at end of file