diff --git a/assets/js/map-utils.js b/assets/js/map-utils.js new file mode 100644 index 0000000..0172253 --- /dev/null +++ b/assets/js/map-utils.js @@ -0,0 +1,161 @@ +// Fonctions utilitaires pour la gestion des marqueurs et popups sur la carte + +function getCompletionColor(completion) { + if (completion === undefined || completion === null) { + return '#808080'; // Gris pour pas d'information + } + // Convertir le pourcentage en couleur verte (0% = blanc, 100% = vert foncé) + const intensity = Math.floor((completion / 100) * 255); + return `rgb(0, ${intensity}, 0)`; +} + +function calculateCompletion(element) { + let completionCount = 0; + let totalFields = 0; + let missingFields = []; + + const fieldsToCheck = [ + { name: 'name', label: 'Nom du commerce' }, + { name: 'contact:street', label: 'Rue' }, + { name: 'contact:housenumber', label: 'Numéro' }, + { name: 'opening_hours', label: 'Horaires d\'ouverture' }, + { name: 'contact:website', label: 'Site web' }, + { name: 'contact:phone', label: 'Téléphone' }, + { name: 'wheelchair', label: 'Accessibilité PMR' } + ]; + + fieldsToCheck.forEach(field => { + totalFields++; + if (element.tags && element.tags[field.name]) { + completionCount++; + } else { + missingFields.push(field.label); + } + }); + + return { + percentage: (completionCount / totalFields) * 100, + missingFields: missingFields + }; +} + +function createPopupContent(element) { + const completion = calculateCompletion(element); + let content = ` +
+
${element.tags?.name || 'Sans nom'}
+
+ + Éditer + + + OSM + +
+
+ `; + + if (completion.percentage < 100) { + content += ` +
+
Informations manquantes :
+ +
+ `; + } + + content += ''; + + // Ajouter tous les tags + if (element.tags) { + for (const tag in element.tags) { + content += ``; + } + } + + content += '
${tag}${element.tags[tag]}
'; + return content; +} + +function updateMarkers(features, map, currentMarkerType, dropMarkers, overpassData) { + // Supprimer tous les marqueurs existants + dropMarkers.forEach(marker => marker.remove()); + dropMarkers = []; + + features.forEach(feature => { + if (currentMarkerType === 'drop') { + const el = document.createElement('div'); + el.className = 'marker'; + el.style.backgroundColor = getCompletionColor(feature.properties.completion); + el.style.width = '15px'; + el.style.height = '15px'; + el.style.borderRadius = '50%'; + el.style.border = '2px solid white'; + el.style.cursor = 'pointer'; + + const marker = new maplibregl.Marker(el) + .setLngLat(feature.geometry.coordinates) + .addTo(map); + + // Ajouter l'événement de clic + el.addEventListener('click', () => { + const element = overpassData[feature.properties.id]; + if (element) { + const popup = new maplibregl.Popup() + .setLngLat(feature.geometry.coordinates) + .setHTML(createPopupContent(element)); + popup.addTo(map); + } + }); + + dropMarkers.push(marker); + } else { + // Créer un cercle pour chaque feature + const circle = turf.circle( + feature.geometry.coordinates, + 0.5, // rayon en kilomètres + { steps: 64, units: 'kilometers' } + ); + + // Ajouter la source et la couche pour le cercle + if (!map.getSource(feature.id)) { + map.addSource(feature.id, { + type: 'geojson', + data: circle + }); + + map.addLayer({ + id: feature.id, + type: 'fill', + source: feature.id, + paint: { + 'fill-color': getCompletionColor(feature.properties.completion), + 'fill-opacity': 0.6, + 'fill-outline-color': '#fff' + } + }); + + // Ajouter l'événement de clic sur le cercle + map.on('click', feature.id, () => { + const element = overpassData[feature.properties.id]; + if (element) { + const popup = new maplibregl.Popup() + .setLngLat(feature.geometry.coordinates) + .setHTML(createPopupContent(element)); + popup.addTo(map); + } + }); + } + } + }); + + return dropMarkers; +} + +// Exporter les fonctions +window.getCompletionColor = getCompletionColor; +window.calculateCompletion = calculateCompletion; +window.createPopupContent = createPopupContent; +window.updateMarkers = updateMarkers; \ No newline at end of file diff --git a/assets/styles/app.css b/assets/styles/app.css index 49ed4be..954bfb9 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -20,7 +20,11 @@ body { } .filled { - background-color: #b0dfa0; + background-color: #b0dfa0 !important; +} + +.filled:hover { + background-color: #8abb7a !important; } .no-name { diff --git a/migrations/Version20250617154118.php b/migrations/Version20250617154118.php new file mode 100644 index 0000000..53ace28 --- /dev/null +++ b/migrations/Version20250617154118.php @@ -0,0 +1,41 @@ +addSql(<<<'SQL' + ALTER TABLE stats ADD siren SMALLINT DEFAULT NULL + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE stats ADD code_epci SMALLINT 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 siren + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE stats DROP code_epci + SQL); + } +} diff --git a/migrations/Version20250617154309.php b/migrations/Version20250617154309.php new file mode 100644 index 0000000..4236a43 --- /dev/null +++ b/migrations/Version20250617154309.php @@ -0,0 +1,35 @@ +addSql(<<<'SQL' + ALTER TABLE stats ADD codes_postaux VARCHAR(255) 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 codes_postaux + SQL); + } +} diff --git a/migrations/Version20250617160626.php b/migrations/Version20250617160626.php new file mode 100644 index 0000000..eadf46d --- /dev/null +++ b/migrations/Version20250617160626.php @@ -0,0 +1,44 @@ +addSql(<<<'SQL' + CREATE TABLE stats_history (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, places_count INT DEFAULT NULL, emails_count INT DEFAULT NULL, completion_percent REAL DEFAULT NULL, emails_sent INT DEFAULT NULL, stats_id INT DEFAULT NULL, PRIMARY KEY(id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_BE00311670AA3482 ON stats_history (stats_id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE stats_history ADD CONSTRAINT FK_BE00311670AA3482 FOREIGN KEY (stats_id) REFERENCES stats (id) NOT DEFERRABLE INITIALLY IMMEDIATE + 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_history DROP CONSTRAINT FK_BE00311670AA3482 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE stats_history + SQL); + } +} diff --git a/migrations/Version20250617161207.php b/migrations/Version20250617161207.php new file mode 100644 index 0000000..2d983bb --- /dev/null +++ b/migrations/Version20250617161207.php @@ -0,0 +1,35 @@ +addSql(<<<'SQL' + ALTER TABLE stats ALTER zone TYPE BIGINT + 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 ALTER zone TYPE VARCHAR(255) + SQL); + } +} diff --git a/public/assets/img/Panoramax.svg b/public/assets/img/Panoramax.svg new file mode 100644 index 0000000..8e16ec0 --- /dev/null +++ b/public/assets/img/Panoramax.svg @@ -0,0 +1,13 @@ + + +image/svg+xml + + + + + + \ No newline at end of file diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 0b3b30f..0dafc3e 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -30,18 +30,29 @@ final class AdminController extends AbstractController ]); } - #[Route('/admin/stats/{zip_code}', name: 'app_admin_stats')] - public function calculer_stats(string $zip_code): Response + #[Route('/admin/stats/{insee_code}', name: 'app_admin_stats')] + public function calculer_stats(string $insee_code): Response { // Récupérer tous les commerces de la zone - $commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $zip_code]); + $commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]); // Récupérer les stats existantes pour la zone - $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zip_code]); + $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); + + + $statsHistory = $this->entityManager->getRepository(StatsHistory::class) + ->createQueryBuilder('sh') + ->where('sh.stats = :stats') + ->setParameter('stats', $stats) + ->orderBy('sh.id', 'DESC') + ->setMaxResults(365) + ->getQuery() + ->getResult(); + if(!$stats) { $stats = new Stats(); - $stats->setZone($zip_code); + $stats->setZone($insee_code); } // Calculer les statistiques @@ -72,11 +83,12 @@ final class AdminController extends AbstractController return $this->render('admin/stats.html.twig', [ 'stats' => $stats, - 'zip_code' => $zip_code, - 'query_places' => $this->motocultrice->get_query_places($zip_code), + 'insee_code' => $insee_code, + 'query_places' => $this->motocultrice->get_query_places($insee_code), 'counters' => $calculatedStats['counters'], 'maptiler_token' => $_ENV['MAPTILER_TOKEN'], 'mapbox_token' => $_ENV['MAPBOX_TOKEN'], + 'statsHistory' => $statsHistory, ]); } @@ -122,19 +134,19 @@ final class AdminController extends AbstractController /** - * récupérer les commerces de la zone, créer les nouveaux lieux, et mettre à jour les existants + * récupérer les commerces de la zone selon le code INSEE, créer les nouveaux lieux, et mettre à jour les existants */ - #[Route('/admin/labourer/{zip_code}', name: 'app_admin_labourer')] - public function labourer(string $zip_code, bool $updateExisting = true): Response + #[Route('/admin/labourer/{insee_code}', name: 'app_admin_labourer')] + public function labourer(string $insee_code, bool $updateExisting = true): Response { try { // Récupérer ou créer les stats pour cette zone - $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zip_code]); + $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); - $city = $this->motocultrice->get_city_osm_from_zip_code($zip_code); + $city = $this->motocultrice->get_city_osm_from_zip_code($insee_code); if (!$stats) { $stats = new Stats(); - $stats->setZone($zip_code) + $stats->setZone($insee_code) ->setPlacesCount(0) ->setAvecHoraires(0) ->setAvecAdresse(0) @@ -150,7 +162,7 @@ final class AdminController extends AbstractController // Récupérer la population via l'API $population = null; try { - $apiUrl = 'https://geo.api.gouv.fr/communes/' . $zip_code . '?fields=population'; + $apiUrl = 'https://geo.api.gouv.fr/communes/' . $insee_code; $response = file_get_contents($apiUrl); if ($response !== false) { $data = json_decode($response, true); @@ -158,13 +170,23 @@ final class AdminController extends AbstractController $population = (int)$data['population']; $stats->setPopulation($population); } + if (isset($data['siren'])) { + $stats->setSiren((int)$data['siren']); + } + if (isset($data['codeEpci'])) { + $stats->setCodeEpci((int)$data['codeEpci']); + } + if (isset($data['codesPostaux'])) { + $stats->setCodesPostaux(implode(';', $data['codesPostaux'])); + } } } catch (\Exception $e) { - // Ne rien faire si l'API échoue + $this->addFlash('error', 'Erreur lors de la récupération des données de l\'API : ' . $e->getMessage()); + } // Récupérer toutes les données - $places = $this->motocultrice->labourer($zip_code); + $places = $this->motocultrice->labourer($insee_code); $processedCount = 0; $updatedCount = 0; @@ -177,7 +199,7 @@ final class AdminController extends AbstractController $place = new Place(); $place->setOsmId($placeData['id']) ->setOsmKind($placeData['type']) - ->setZipCode($zip_code) + ->setZipCode($insee_code) ->setUuidForUrl($this->motocultrice->uuid_create()) ->setModifiedDate(new \DateTime()) ->setStats($stats) @@ -212,6 +234,14 @@ final class AdminController extends AbstractController // Mettre à jour les statistiques finales $stats->computeCompletionPercent(); + // Créer un historique des statistiques + $statsHistory = new StatsHistory(); + $statsHistory->setPlacesCount($stats->getPlaces()->count()) + ->setCompletionPercent($stats->getCompletionPercent()) + ->setStats($stats); + + $this->entityManager->persist($statsHistory); + $this->entityManager->persist($stats); $this->entityManager->flush(); @@ -225,7 +255,8 @@ final class AdminController extends AbstractController $this->addFlash('error', 'Erreur lors du labourage : ' . $e->getMessage()); } - return $this->redirectToRoute('app_admin_stats', ['zip_code' => $zip_code]); + return $this->redirectToRoute('app_public_dashboard'); + // return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]); } #[Route('/admin/delete/{id}', name: 'app_admin_delete')] @@ -244,11 +275,11 @@ final class AdminController extends AbstractController return $this->redirectToRoute('app_public_dashboard'); } - #[Route('/admin/delete_by_zone/{zip_code}', name: 'app_admin_delete_by_zone')] - public function delete_by_zone(string $zip_code): Response + #[Route('/admin/delete_by_zone/{insee_code}', name: 'app_admin_delete_by_zone')] + public function delete_by_zone(string $insee_code): Response { - $commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $zip_code]); - $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zip_code]); + $commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]); + $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); foreach ($commerces as $commerce) { $this->entityManager->remove($commerce); @@ -256,7 +287,7 @@ final class AdminController extends AbstractController $this->entityManager->remove($stats); $this->entityManager->flush(); - $this->addFlash('success', 'Tous les commerces de la zone '.$zip_code.' ont été supprimés avec succès de OSM Mes commerces, mais pas dans OpenStreetMap.'); + $this->addFlash('success', 'Tous les commerces de la zone '.$insee_code.' ont été supprimés avec succès de OSM Mes commerces, mais pas dans OpenStreetMap.'); return $this->redirectToRoute('app_public_dashboard'); } @@ -322,16 +353,16 @@ final class AdminController extends AbstractController return $response; } - #[Route('/admin/export_csv/{zip_code}', name: 'app_admin_export_csv')] - public function export_csv(string $zip_code): Response + #[Route('/admin/export_csv/{insee_code}', name: 'app_admin_export_csv')] + public function export_csv(string $insee_code): Response { - $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zip_code]); - $response = new Response($this->motocultrice->export($zip_code)); + $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); + $response = new Response($this->motocultrice->export($insee_code)); $response->headers->set('Content-Type', 'text/csv'); $slug_name = str_replace(' ', '-', $stats->getName()); - $response->headers->set('Content-Disposition', 'attachment; filename="osm-commerces-export_' . $zip_code . '_' . $slug_name . '_' . date('Y-m-d_H-i-s') . '.csv"'); + $response->headers->set('Content-Disposition', 'attachment; filename="osm-commerces-export_' . $insee_code . '_' . $slug_name . '_' . date('Y-m-d_H-i-s') . '.csv"'); return $response; } diff --git a/src/Entity/History.php b/src/Entity/History.php index 5c7b3f9..50f3992 100644 --- a/src/Entity/History.php +++ b/src/Entity/History.php @@ -16,9 +16,6 @@ class History #[ORM\Column] private ?int $id = null; - - - #[ORM\Column(type: Types::SMALLINT, nullable: true)] private ?int $completion_percent = null; diff --git a/src/Entity/Stats.php b/src/Entity/Stats.php index 6db0de4..70acfe5 100644 --- a/src/Entity/Stats.php +++ b/src/Entity/Stats.php @@ -59,6 +59,21 @@ class Stats #[ORM\Column(type: Types::INTEGER, nullable: true)] private ?int $population = null; + #[ORM\Column(type: Types::SMALLINT, nullable: true)] + private ?int $siren = null; + + #[ORM\Column(type: Types::SMALLINT, nullable: true)] + private ?int $codeEpci = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $codesPostaux = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: StatsHistory::class, mappedBy: 'stats')] + private Collection $statsHistories; + // calcule le pourcentage de complétion de la zone public function computeCompletionPercent(): ?int { @@ -115,6 +130,7 @@ class Stats public function __construct() { $this->places = new ArrayCollection(); + $this->statsHistories = new ArrayCollection(); } public function getId(): ?int @@ -270,6 +286,72 @@ class Stats $this->population = $population; return $this; } + + public function getSiren(): ?int + { + return $this->siren; + } + + public function setSiren(?int $siren): static + { + $this->siren = $siren; + + return $this; + } + + public function getCodeEpci(): ?int + { + return $this->codeEpci; + } + + public function setCodeEpci(?int $codeEpci): static + { + $this->codeEpci = $codeEpci; + + return $this; + } + + public function getCodesPostaux(): ?string + { + return $this->codesPostaux; + } + + public function setCodesPostaux(?string $codesPostaux): static + { + $this->codesPostaux = $codesPostaux; + + return $this; + } + + /** + * @return Collection + */ + public function getStatsHistories(): Collection + { + return $this->statsHistories; + } + + public function addStatsHistory(StatsHistory $statsHistory): static + { + if (!$this->statsHistories->contains($statsHistory)) { + $this->statsHistories->add($statsHistory); + $statsHistory->setStats($this); + } + + return $this; + } + + public function removeStatsHistory(StatsHistory $statsHistory): static + { + if ($this->statsHistories->removeElement($statsHistory)) { + // set the owning side to null (unless already changed) + if ($statsHistory->getStats() === $this) { + $statsHistory->setStats(null); + } + } + + return $this; + } } diff --git a/src/Entity/StatsHistory.php b/src/Entity/StatsHistory.php new file mode 100644 index 0000000..688757f --- /dev/null +++ b/src/Entity/StatsHistory.php @@ -0,0 +1,98 @@ +id; + } + + public function getPlacesCount(): ?int + { + return $this->places_count; + } + + public function setPlacesCount(?int $places_count): static + { + $this->places_count = $places_count; + + return $this; + } + + public function getEmailsCount(): ?int + { + return $this->emails_count; + } + + public function setEmailsCount(?int $emails_count): static + { + $this->emails_count = $emails_count; + + return $this; + } + + public function getCompletionPercent(): ?float + { + return $this->completion_percent; + } + + public function setCompletionPercent(?float $completion_percent): static + { + $this->completion_percent = $completion_percent; + + return $this; + } + + public function getEmailsSent(): ?int + { + return $this->emails_sent; + } + + public function setEmailsSent(?int $emails_sent): static + { + $this->emails_sent = $emails_sent; + + return $this; + } + + public function getStats(): ?Stats + { + return $this->stats; + } + + public function setStats(?Stats $stats): static + { + $this->stats = $stats; + + return $this; + } + +} diff --git a/src/Repository/StatsHistoryRepository.php b/src/Repository/StatsHistoryRepository.php new file mode 100644 index 0000000..e53a3d5 --- /dev/null +++ b/src/Repository/StatsHistoryRepository.php @@ -0,0 +1,43 @@ + + */ +class StatsHistoryRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, StatsHistory::class); + } + + // /** + // * @return StatsHistory[] Returns an array of StatsHistory objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('s.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?StatsHistory + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/templates/admin/labourage_results.html.twig b/templates/admin/labourage_results.html.twig index 9794889..93de459 100644 --- a/templates/admin/labourage_results.html.twig +++ b/templates/admin/labourage_results.html.twig @@ -10,8 +10,8 @@

Labourage fait sur la zone "{{ stats.zone }} {{stats.name}}" ✅

- Labourer les mises à jour - Voir les résultats + Labourer les mises à jour + Voir les résultats

lieux trouvés en plus: {{ new_places_counter }} diff --git a/templates/admin/stats.html.twig b/templates/admin/stats.html.twig index a4cf711..ca97e46 100644 --- a/templates/admin/stats.html.twig +++ b/templates/admin/stats.html.twig @@ -22,6 +22,13 @@ {% endblock %} +{% block javascripts %} + {{ parent() }} + + + +{% endblock %} + {% block body %}

@@ -31,7 +38,7 @@ {{ stats.name }} - {{ stats.completionPercent }}% complété
- Labourer les mises à jour + Labourer les mises à jour @@ -103,28 +110,7 @@
-
-
-
- -

Comment est calculé le score de complétion ?

- -
- -
-
+
@@ -156,7 +142,7 @@

Tableau des {{ stats.places |length }} lieux

- + Exporter en CSV @@ -185,15 +171,54 @@
-
+
+

Historique des {{ statsHistory|length }} stats

+ + + + + + + + + + {% for stat in statsHistory %} + + + + + + {% endfor %} + +
DatePlacesComplétion
{{ stat.date|date('d/m/Y') }}{{ stat.placesCount }}{{ stat.completionPercent }}%
+
+
+
+
+ +

Comment est calculé le score de complétion ?

+ +
+ +
+
+ - - - -