From a2936841f97f66d010ab5d02a04920ac68c74ea1 Mon Sep 17 00:00:00 2001 From: Tykayn Date: Sat, 5 Jul 2025 12:55:33 +0200 Subject: [PATCH] carte des villes et ajout de ville sur accueil --- migrations/Version20250705104136.php | 41 ++++ src/Command/UpdateCityCoordinatesCommand.php | 210 +++++++++++++++++++ src/Controller/PublicController.php | 93 +++++++- src/Entity/Stats.php | 28 +++ templates/admin/stats.html.twig | 4 +- templates/public/add_city.html.twig | 28 +++ templates/public/followup_graph.html.twig | 153 ++++++++++++++ templates/public/home.html.twig | 74 +++++-- 8 files changed, 601 insertions(+), 30 deletions(-) create mode 100644 migrations/Version20250705104136.php create mode 100644 src/Command/UpdateCityCoordinatesCommand.php create mode 100644 templates/public/add_city.html.twig create mode 100644 templates/public/followup_graph.html.twig diff --git a/migrations/Version20250705104136.php b/migrations/Version20250705104136.php new file mode 100644 index 0000000..00e7fd3 --- /dev/null +++ b/migrations/Version20250705104136.php @@ -0,0 +1,41 @@ +addSql(<<<'SQL' + ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE email_content email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci` + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE stats ADD lat NUMERIC(10, 7) DEFAULT NULL, ADD lon NUMERIC(10, 7) DEFAULT NULL + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE stats DROP lat, DROP lon + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note VARCHAR(255) DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL, CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL, CHANGE email_content email_content LONGTEXT DEFAULT NULL + SQL); + } +} diff --git a/src/Command/UpdateCityCoordinatesCommand.php b/src/Command/UpdateCityCoordinatesCommand.php new file mode 100644 index 0000000..ef259a5 --- /dev/null +++ b/src/Command/UpdateCityCoordinatesCommand.php @@ -0,0 +1,210 @@ +entityManager->getRepository(Stats::class)->findAll(); + $total = count($stats); + $updated = 0; + $errors = 0; + + $io->progressStart($total); + + foreach ($stats as $stat) { + if ($stat->getZone() && $stat->getZone() !== 'undefined' && preg_match('/^\d+$/', $stat->getZone())) { + $cityName = $stat->getName() ?: $stat->getZone(); + + // Vérifier si les coordonnées sont déjà présentes + if (!$stat->getLat() || !$stat->getLon()) { + $coordinates = $this->getCityCoordinates($cityName, $stat->getZone()); + + if ($coordinates) { + $stat->setLat((string) $coordinates['lat']); + $stat->setLon((string) $coordinates['lon']); + $this->entityManager->persist($stat); + $updated++; + $io->text("✓ $cityName ({$stat->getZone()}): " . $coordinates['lat'] . ", " . $coordinates['lon']); + } else { + $errors++; + $io->warning("✗ Impossible de récupérer les coordonnées pour $cityName ({$stat->getZone()})"); + } + } + } + + $io->progressAdvance(); + } + + $this->entityManager->flush(); + $io->progressFinish(); + + $io->success("Mise à jour terminée : $updated villes mises à jour, $errors erreurs"); + + return Command::SUCCESS; + } + + private function getCityCoordinates(string $cityName, string $inseeCode): ?array + { + // Stratégie 1: Nominatim + $coordinates = $this->getCoordinatesFromNominatim($cityName); + if ($coordinates) { + return $coordinates; + } + + // Stratégie 2: Premier objet Place townhall + $coordinates = $this->getCoordinatesFromPlaceTownhall($inseeCode); + if ($coordinates) { + return $coordinates; + } + + // Stratégie 3: N'importe quel objet Place + $coordinates = $this->getCoordinatesFromAnyPlace($inseeCode); + if ($coordinates) { + return $coordinates; + } + + // Stratégie 4: Addok + return $this->getCoordinatesFromAddok($cityName, $inseeCode); + } + + private function getCoordinatesFromNominatim(string $cityName): ?array + { + $query = urlencode($cityName . ', France'); + $url = "https://nominatim.openstreetmap.org/search?q={$query}&format=json&limit=1&countrycodes=fr"; + + try { + usleep(100000); // 0.1 seconde entre les appels + + $context = stream_context_create([ + 'http' => [ + 'timeout' => 5, + 'user_agent' => 'OSM-Commerces/1.0' + ] + ]); + + $response = file_get_contents($url, false, $context); + + if ($response === false) { + return null; + } + + $data = json_decode($response, true); + + if (!empty($data) && isset($data[0]['lat']) && isset($data[0]['lon'])) { + return [ + 'lat' => (float) $data[0]['lat'], + 'lon' => (float) $data[0]['lon'] + ]; + } + } catch (\Exception $e) { + // En cas d'erreur, on retourne null + } + + return null; + } + + private function getCoordinatesFromPlaceTownhall(string $inseeCode): ?array + { + $places = $this->entityManager->getRepository(\App\Entity\Place::class) + ->createQueryBuilder('p') + ->where('p.zip_code = :inseeCode') + ->andWhere('p.lat IS NOT NULL') + ->andWhere('p.lon IS NOT NULL') + ->andWhere('p.main_tag = :mainTag') + ->setParameter('inseeCode', $inseeCode) + ->setParameter('mainTag', 'townhall') + ->setMaxResults(1) + ->getQuery() + ->getResult(); + + if (!empty($places)) { + $place = $places[0]; + return [ + 'lat' => (float) $place->getLat(), + 'lon' => (float) $place->getLon() + ]; + } + + return null; + } + + private function getCoordinatesFromAnyPlace(string $inseeCode): ?array + { + $places = $this->entityManager->getRepository(\App\Entity\Place::class) + ->createQueryBuilder('p') + ->where('p.zip_code = :inseeCode') + ->andWhere('p.lat IS NOT NULL') + ->andWhere('p.lon IS NOT NULL') + ->setParameter('inseeCode', $inseeCode) + ->setMaxResults(1) + ->getQuery() + ->getResult(); + + if (!empty($places)) { + $place = $places[0]; + return [ + 'lat' => (float) $place->getLat(), + 'lon' => (float) $place->getLon() + ]; + } + + return null; + } + + private function getCoordinatesFromAddok(string $cityName, string $inseeCode): ?array + { + $query = urlencode($cityName . ', France'); + $url = "https://demo.addok.xyz/search?q={$query}&limit=1"; + + try { + $context = stream_context_create([ + 'http' => [ + 'timeout' => 5, + 'user_agent' => 'OSM-Commerces/1.0' + ] + ]); + + $response = file_get_contents($url, false, $context); + + if ($response === false) { + return null; + } + + $data = json_decode($response, true); + + if (!empty($data['features']) && isset($data['features'][0]['geometry']['coordinates'])) { + $coordinates = $data['features'][0]['geometry']['coordinates']; + return [ + 'lat' => (float) $coordinates[1], // Addok retourne [lon, lat] + 'lon' => (float) $coordinates[0] + ]; + } + } catch (\Exception $e) { + // En cas d'erreur, on retourne null + } + + return null; + } +} \ No newline at end of file diff --git a/src/Controller/PublicController.php b/src/Controller/PublicController.php index fcbf9ab..92ee286 100644 --- a/src/Controller/PublicController.php +++ b/src/Controller/PublicController.php @@ -122,17 +122,20 @@ class PublicController extends AbstractController // Préparer les données pour la carte $citiesForMap = []; + foreach ($stats as $stat) { - if ($stat->getZone() && $stat->getZone() !== 'undefined' && preg_match('/^\d+$/', $stat->getZone())) { - // Récupérer les coordonnées de la ville via l'API Nominatim + if ($stat->getZone() && $stat->getZone() !== 'undefined' && preg_match('/^\d+$/', $stat->getZone()) && $stat->getZone() !== '00000') { $cityName = $stat->getName() ?: $stat->getZone(); - $coordinates = $this->getCityCoordinates($cityName, $stat->getZone()); - if ($coordinates) { + // Utiliser les coordonnées stockées si disponibles + if ($stat->getLat() && $stat->getLon()) { $citiesForMap[] = [ 'name' => $cityName, 'zone' => $stat->getZone(), - 'coordinates' => $coordinates, + 'coordinates' => [ + 'lat' => (float) $stat->getLat(), + 'lon' => (float) $stat->getLon() + ], 'placesCount' => $stat->getPlacesCount(), 'completionPercent' => $stat->getCompletionPercent(), 'population' => $stat->getPopulation(), @@ -165,17 +168,36 @@ class PublicController extends AbstractController $url = "https://nominatim.openstreetmap.org/search?q={$query}&format=json&limit=1&countrycodes=fr"; try { - $response = file_get_contents($url); + // Ajouter un délai pour respecter les limites de l'API Nominatim + usleep(100000); // 0.1 seconde entre les appels + + $context = stream_context_create([ + 'http' => [ + 'timeout' => 5, // Timeout de 5 secondes + 'user_agent' => 'OSM-Commerces/1.0' + ] + ]); + + $response = file_get_contents($url, false, $context); + + if ($response === false) { + error_log("DEBUG: Échec de récupération des coordonnées pour $cityName ($inseeCode)"); + return null; + } + $data = json_decode($response, true); if (!empty($data) && isset($data[0]['lat']) && isset($data[0]['lon'])) { + error_log("DEBUG: Coordonnées trouvées pour $cityName ($inseeCode): " . $data[0]['lat'] . ", " . $data[0]['lon']); return [ 'lat' => (float) $data[0]['lat'], 'lon' => (float) $data[0]['lon'] ]; + } else { + error_log("DEBUG: Aucune coordonnée trouvée pour $cityName ($inseeCode)"); } } catch (\Exception $e) { - // En cas d'erreur, on retourne null + error_log("DEBUG: Exception lors de la récupération des coordonnées pour $cityName ($inseeCode): " . $e->getMessage()); } return null; @@ -601,4 +623,61 @@ class PublicController extends AbstractController 'logs' => $logs ]); } + + #[Route('/add-city', name: 'app_public_add_city')] + public function addCity(): Response + { + return $this->render('public/add_city.html.twig', [ + 'controller_name' => 'PublicController', + ]); + } + + #[Route('/stats/{insee_code}/followup-graph/{theme}', name: 'app_public_followup_graph', requirements: ['insee_code' => '\\d+', 'theme' => '[a-zA-Z0-9_]+'])] + public function publicFollowupGraph(string $insee_code, string $theme): Response + { + $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); + if (!$stats) { + $this->addFlash('error', 'Aucune stats trouvée pour ce code INSEE.'); + return $this->redirectToRoute('app_public_index'); + } + + $themes = \App\Service\FollowUpService::getFollowUpThemes(); + if (!isset($themes[$theme])) { + $this->addFlash('error', 'Thème non reconnu.'); + return $this->redirectToRoute('app_public_index'); + } + + // Récupérer toutes les données de followup pour ce thème + $followups = $stats->getCityFollowUps(); + $countData = []; + $completionData = []; + + foreach ($followups as $fu) { + if ($fu->getName() === $theme . '_count') { + $countData[] = [ + 'date' => $fu->getDate()->format('Y-m-d'), + 'value' => $fu->getMeasure() + ]; + } + if ($fu->getName() === $theme . '_completion') { + $completionData[] = [ + 'date' => $fu->getDate()->format('Y-m-d'), + 'value' => $fu->getMeasure() + ]; + } + } + + // Trier par date + usort($countData, fn($a, $b) => $a['date'] <=> $b['date']); + usort($completionData, fn($a, $b) => $a['date'] <=> $b['date']); + + return $this->render('public/followup_graph.html.twig', [ + 'stats' => $stats, + 'theme' => $theme, + 'theme_label' => $themes[$theme], + 'count_data' => json_encode($countData), + 'completion_data' => json_encode($completionData), + 'icons' => \App\Service\FollowUpService::getFollowUpIcons(), + ]); + } } diff --git a/src/Entity/Stats.php b/src/Entity/Stats.php index 29fb7ce..21f71e1 100644 --- a/src/Entity/Stats.php +++ b/src/Entity/Stats.php @@ -98,6 +98,12 @@ class Stats #[ORM\Column(type: Types::DECIMAL, precision: 15, scale: 2, nullable: true)] private ?string $budget_annuel = null; + #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7, nullable: true)] + private ?string $lat = null; + + #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7, nullable: true)] + private ?string $lon = null; + /** * @var Collection */ @@ -599,6 +605,28 @@ class Stats } return $this; } + + public function getLat(): ?string + { + return $this->lat; + } + + public function setLat(?string $lat): static + { + $this->lat = $lat; + return $this; + } + + public function getLon(): ?string + { + return $this->lon; + } + + public function setLon(?string $lon): static + { + $this->lon = $lon; + return $this; + } } diff --git a/templates/admin/stats.html.twig b/templates/admin/stats.html.twig index 1af1185..24fe1f8 100644 --- a/templates/admin/stats.html.twig +++ b/templates/admin/stats.html.twig @@ -484,9 +484,9 @@ ['has', 'completion'], [ 'rgb', - ['-', 255, ['*', ['get', 'completion'], 2.55]], + 0, ['*', ['get', 'completion'], 2.55], - 80 + 0 ], '#cccccc' ], diff --git a/templates/public/add_city.html.twig b/templates/public/add_city.html.twig new file mode 100644 index 0000000..a8424b9 --- /dev/null +++ b/templates/public/add_city.html.twig @@ -0,0 +1,28 @@ +{% extends 'base.html.twig' %} + +{% block title %}Ajouter ma ville{% endblock %} + +{% block body %} +
+
+
+
+

Ajouter ma ville

+ + Retour à l'accueil + +
+ +
+
+

Labourage d'une nouvelle ville

+

Entrez le code INSEE de votre ville pour l'ajouter à notre base de données

+
+
+ {% include 'public/labourage-form.html.twig' %} +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/public/followup_graph.html.twig b/templates/public/followup_graph.html.twig new file mode 100644 index 0000000..d9530a9 --- /dev/null +++ b/templates/public/followup_graph.html.twig @@ -0,0 +1,153 @@ +{% extends 'base.html.twig' %} + +{% block title %}Graphe thématique : {{ theme_label }} - {{ stats.name }}{% endblock %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock %} + +{% block body %} +
+
+
+
+
+

{{ theme_label }}

+

{{ stats.name }} ({{ stats.zone }})

+
+ +
+ +
+
+

Évolution du {{ theme_label|lower }}

+

Suivi de la progression du nombre d'objets et du taux de complétion

+
+
+
+
+
Nombre d'objets
+ +
+
+
Taux de complétion (%)
+ +
+
+ +
+
+ + Informations : Ces graphiques montrent l'évolution du {{ theme_label|lower }} dans {{ stats.name }} au fil du temps. + Le taux de complétion indique le pourcentage d'objets correctement renseignés. +
+
+
+
+
+
+
+{% endblock %} + +{% block javascripts %} + {{ parent() }} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/public/home.html.twig b/templates/public/home.html.twig index 1bf4933..6ec3709 100644 --- a/templates/public/home.html.twig +++ b/templates/public/home.html.twig @@ -130,14 +130,38 @@
Nous vous enverrons un lien unique pour cela par email, et si vous en avez besoin, nous pouvons vous aider.

+
+
+ + +
+
+ +
+ +
+ + +
+
+ {% if citiesForMap is not empty %}
-
-

Carte des villes disponibles

-

Cliquez sur un marqueur pour voir les statistiques de la ville

+
+
+

Carte des villes disponibles

+

Cliquez sur un marqueur pour voir les statistiques de la ville

+
+ + Ajouter ma ville +
@@ -163,24 +187,7 @@
{% endif %} -
-
- - -
-
- -
- -
- - -
-
+
@@ -311,6 +318,31 @@ `) ) .addTo(map); + + // Ajouter le nom de la ville comme label + const label = new maplibregl.Marker({ + element: (() => { + const el = document.createElement('div'); + el.className = 'city-label'; + el.style.cssText = ` + background: rgba(255, 255, 255, 0.9); + border: 1px solid #ccc; + border-radius: 4px; + padding: 2px 6px; + font-size: 11px; + font-weight: bold; + color: #333; + white-space: nowrap; + pointer-events: none; + margin-top: -25px; + margin-left: 15px; + `; + el.textContent = properties.name; + return el; + })() + }) + .setLngLat(feature.geometry.coordinates) + .addTo(map); }); // Ajouter les contrôles de navigation