ajout stat hist

This commit is contained in:
Tykayn 2025-06-17 18:27:19 +02:00 committed by tykayn
parent 918527e15e
commit 7fb0c9c8c2
19 changed files with 695 additions and 314 deletions

161
assets/js/map-utils.js Normal file
View file

@ -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 = `
<div class="mb-2">
<h5>${element.tags?.name || 'Sans nom'}</h5>
<div class="d-flex gap-2">
<a class="btn btn-primary btn-sm" href="/admin/placeType/${element.type}/${element.id}">
<i class="bi bi-pencil"></i> Éditer
</a>
<a class="btn btn-secondary btn-sm" href="https://openstreetmap.org/${element.type}/${element.id}" target="_blank">
<i class="bi bi-map"></i> OSM
</a>
</div>
</div>
`;
if (completion.percentage < 100) {
content += `
<div class="alert alert-warning mt-2">
<h6>Informations manquantes :</h6>
<ul class="list-unstyled mb-0">
${completion.missingFields.map(field => `<li><i class="bi bi-x-circle text-danger"></i> ${field}</li>`).join('')}
</ul>
</div>
`;
}
content += '<table class="table table-sm mt-2">';
// Ajouter tous les tags
if (element.tags) {
for (const tag in element.tags) {
content += `<tr><td><strong>${tag}</strong></td><td>${element.tags[tag]}</td></tr>`;
}
}
content += '</table>';
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;

View file

@ -20,7 +20,11 @@ body {
}
.filled {
background-color: #b0dfa0;
background-color: #b0dfa0 !important;
}
.filled:hover {
background-color: #8abb7a !important;
}
.no-name {

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250617154118 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250617154309 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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);
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250617160626 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250617161207 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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);
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -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;
}

View file

@ -16,9 +16,6 @@ class History
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $completion_percent = null;

View file

@ -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<int, StatsHistory>
*/
#[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<int, StatsHistory>
*/
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;
}
}

View file

@ -0,0 +1,98 @@
<?php
namespace App\Entity;
use App\Repository\StatsHistoryRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: StatsHistoryRepository::class)]
class StatsHistory
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(nullable: true)]
private ?int $places_count = null;
#[ORM\Column(nullable: true)]
private ?int $emails_count = null;
#[ORM\Column(type: Types::SMALLFLOAT, nullable: true)]
private ?float $completion_percent = null;
#[ORM\Column(nullable: true)]
private ?int $emails_sent = null;
#[ORM\ManyToOne(inversedBy: 'statsHistories')]
private ?Stats $stats = null;
public function getId(): ?int
{
return $this->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;
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\StatsHistory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<StatsHistory>
*/
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()
// ;
// }
}

View file

@ -10,8 +10,8 @@
<div class="example-wrapper">
<h1>Labourage fait sur la zone "{{ stats.zone }} {{stats.name}}" ✅</h1>
<a href="{{ path('app_admin_labourer', {'zip_code': stats.zone}) }}" class="btn btn-primary" id="labourer">Labourer les mises à jour</a>
<a href="{{ path('app_admin_stats', {'zip_code': stats.zone}) }}" class="btn btn-primary" id="labourer">Voir les résultats</a>
<a href="{{ path('app_admin_labourer', {'insee_code': stats.zone}) }}" class="btn btn-primary" id="labourer">Labourer les mises à jour</a>
<a href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}" class="btn btn-primary" id="labourer">Voir les résultats</a>
<p>
lieux trouvés en plus: {{ new_places_counter }}

View file

@ -22,6 +22,13 @@
</style>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="{{ asset('js/maplibre/maplibre-gl.js') }}"></script>
<script src="{{ asset('js/turf/turf.min.js') }}"></script>
<script src="{{ asset('js/map-utils.js') }}"></script>
{% endblock %}
{% block body %}
<div class="container">
<div class="mt-4 p-4">
@ -31,7 +38,7 @@
{{ stats.name }} - {{ stats.completionPercent }}% complété</h1>
</div>
<div class="col-md-6 col-12">
<a href="{{ path('app_admin_labourer', {'zip_code': stats.zone}) }}" class="btn btn-primary" id="labourer">Labourer les mises à jour</a>
<a href="{{ path('app_admin_labourer', {'insee_code': stats.zone}) }}" class="btn btn-primary" id="labourer">Labourer les mises à jour</a>
<button id="openInJOSM" class="btn btn-secondary ms-2">
<i class="bi bi-map"></i> Ouvrir dans JOSM
</button>
@ -103,28 +110,7 @@
</div>
</div>
<div class="completion-info mt-4">
<div class="alert alert-info">
<div class="d-flex align-items-center" style="cursor: pointer;" onclick="toggleCompletionInfo()">
<i class="bi bi-info-circle me-2"></i>
<h4 class="alert-heading mb-0">Comment est calculé le score de complétion ?</h4>
<i class="bi bi-chevron-down ms-auto" id="completionInfoIcon"></i>
</div>
<div id="completionInfoContent" style="display: none;" class="mt-3">
<p>Le score de complétion est calculé en fonction de plusieurs critères :</p>
<ul>
<li>Nom du commerce (obligatoire)</li>
<li>Adresse complète (numéro, rue, code postal)</li>
<li>Horaires d'ouverture</li>
<li>Site web</li>
<li>Numéro de téléphone</li>
<li>Accessibilité PMR</li>
<li>Note descriptive</li>
</ul>
<p>Chaque critère rempli augmente le score de complétion. Un commerce parfaitement renseigné aura un score de 100%.</p>
</div>
</div>
</div>
<div id="maploader">
<div class="spinner-border" role="status">
@ -156,7 +142,7 @@
<h1 class="card-title p-4">Tableau des {{ stats.places |length }} lieux</h1>
</div>
<div class="col-md-6 col-12">
<a class="btn btn-primary pull-right mt-4" href="{{ path('app_admin_export_csv', {'zip_code': stats.zone}) }}" class="btn btn-primary">
<a class="btn btn-primary pull-right mt-4" href="{{ path('app_admin_export_csv', {'insee_code': stats.zone}) }}" class="btn btn-primary">
<i class="bi bi-filetype-csv"></i>
Exporter en CSV
</a>
@ -185,15 +171,54 @@
</a>
</div>
</div>
</div>
<div id="history">
<h2>Historique des {{ statsHistory|length }} stats</h2>
<table class="table table-bordered table-striped table-hover table-responsive js-sort-table">
<thead>
<tr>
<th>Date</th>
<th>Places</th>
<th>Complétion</th>
</tr>
</thead>
<tbody>
{% for stat in statsHistory %}
<tr>
<td>{{ stat.date|date('d/m/Y') }}</td>
<td>{{ stat.placesCount }}</td>
<td>{{ stat.completionPercent }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="completion-info mt-4">
<div class="alert alert-info">
<div class="d-flex align-items-center" style="cursor: pointer;" onclick="toggleCompletionInfo()">
<i class="bi bi-info-circle me-2"></i>
<p class="mb-0">Comment est calculé le score de complétion ?</p>
<i class="bi bi-chevron-down ms-auto" id="completionInfoIcon"></i>
</div>
<div id="completionInfoContent" style="display: none;" class="mt-3">
<p>Le score de complétion est calculé en fonction de plusieurs critères :</p>
<ul>
<li>Nom du commerce (obligatoire)</li>
<li>Adresse complète (numéro, rue, code postal)</li>
<li>Horaires d'ouverture</li>
<li>Site web</li>
<li>Numéro de téléphone</li>
<li>Accessibilité PMR</li>
<li>Note descriptive</li>
</ul>
<p>Chaque critère rempli augmente le score de complétion. Un commerce parfaitement renseigné aura un score de 100%.</p>
</div>
</div>
</div>
</div>
<!-- Bouton caché pour JOSM -->
<a id="josmButton" style="display: none;"></a>
<script src='{{ asset('js/maplibre/maplibre-gl.js') }}'></script>
<script src='{{ asset('js/turf/turf.min.js') }}'></script>
<script src="{{ asset('js/chartjs/chart.umd.js') }}"></script>
<script src="{{ asset('js/app.js') }}"></script>
<script>
const request = `{{query_places|raw}}`;
const zip_code = `{{stats.zone}}`;
@ -207,99 +232,6 @@
let dropMarkers = []; // Tableau pour stocker les marqueurs en goutte
let contextMenu = null; // Menu contextuel
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 showMissingFieldsPopup(element) {
const completion = calculateCompletion(element);
if (completion.percentage < 100) {
const popup = new maplibregl.Popup()
.setLngLat(element.geometry.coordinates)
.setHTML(`
<div class="p-2">
<h5>Informations manquantes pour ${element.tags?.name || 'ce commerce'}</h5>
<ul class="list-unstyled">
${completion.missingFields.map(field => `<li><i class="bi bi-x-circle text-danger"></i> ${field}</li>`).join('')}
</ul>
</div>
`);
popup.addTo(map);
}
}
function createPopupContent(element) {
const completion = calculateCompletion(element);
let content = `
<div class="mb-2">
<a class="btn btn-primary" href="/admin/placeType/${element.type}/${element.id}">
<i class="bi bi-pencil"></i> ${element.tags?.name || 'Sans nom'}
</a>
<a class="btn btn-secondary ms-2" href="https://openstreetmap.org/${element.type}/${element.id}">
<i class="bi bi-map"></i> OSM
</a>
</div>
`;
if (completion.percentage < 100) {
content += `
<div class="alert alert-warning mt-2">
<h6>Informations manquantes :</h6>
<ul class="list-unstyled mb-0">
${completion.missingFields.map(field => `<li><i class="bi bi-x-circle text-danger"></i> ${field}</li>`).join('')}
</ul>
</div>
`;
}
content += '<table class="table table-sm">';
// Ajouter tous les tags
if (element.tags) {
for (const tag in element.tags) {
content += `<tr><td><strong>${tag}</strong></td><td>${element.tags[tag]}</td></tr>`;
}
}
content += '</table>';
return content;
}
function calculateCompletionDistribution(elements) {
// Créer des buckets de 10% (0-10%, 10-20%, etc.)
const buckets = Array(11).fill(0);
@ -426,7 +358,7 @@
});
// Afficher les marqueurs selon le type actuel
updateMarkers();
dropMarkers = updateMarkers(features, map, currentMarkerType, dropMarkers, overpassData);
// Ajuster la vue pour inclure tous les points
const points = features.map(f => f.properties.center);
@ -454,109 +386,6 @@
}
}
function updateMarkers() {
// Supprimer tous les marqueurs existants
dropMarkers.forEach(marker => marker.remove());
dropMarkers = [];
features.forEach(feature => {
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) {
showMissingFieldsPopup(element);
}
});
dropMarkers.push(marker);
});
}
function draw_circle_containing_all_features(map) {
if (features.length === 0) return;
// Calculer le centre à partir de toutes les features
const center = features.reduce((acc, feature) => {
return [
acc[0] + feature.properties.center[0],
acc[1] + feature.properties.center[1]
];
}, [0, 0]).map(coord => coord / features.length);
// Calculer le rayon nécessaire pour couvrir tous les points
const radius = features.reduce((maxRadius, feature) => {
const distance = turf.distance(
center,
feature.properties.center,
{ units: 'kilometers' }
);
return Math.max(maxRadius, distance);
}, 0);
const circle = turf.circle(center, radius, { steps: 64, units: 'kilometers' });
map.addSource('circle', { type: 'geojson', data: circle });
map.addLayer({
id: 'circle',
type: 'fill',
source: 'circle',
paint: {
'fill-color': 'blue',
'fill-opacity': 0.25
}
});
// Ajouter un marqueur au centre du cercle
new maplibregl.Marker()
.setLngLat(center)
.addTo(map);
}
function updateCircles(map) {
if (!map) return;
const zoom = map.getZoom();
// Ajuster la taille de base en fonction du zoom
// Plus le zoom est faible (loin), plus le rayon est grand
const baseRadius = Math.min(100, 200 / Math.pow(1.2, zoom));
features.forEach(feature => {
const source = map.getSource(feature.id);
if (!source) return;
const completion = feature.properties.completion;
const radius = baseRadius * (1 + (completion / 100));
// Mettre à jour le rayon du cercle
const circle = turf.circle(
feature.properties.center,
0.5* radius / 1000, // Convertir en kilomètres
{ steps: 64, units: 'kilometers' }
);
// Mettre à jour la source avec le nouveau cercle
source.setData(circle);
// Mettre à jour la couleur du cercle
const layer = map.getLayer(feature.id);
if (layer) {
map.setPaintProperty(feature.id, 'fill-color', getCompletionColor(completion));
}
});
}
function openJOSMQuery(map, query) {
const bounds = map.getBounds();
@ -639,58 +468,20 @@
contextMenu.style.top = `${y}px`;
contextMenu.innerHTML = '<div class="spinner-border spinner-border-sm" role="status"></div> Recherche du code INSEE...';
try {
// Récupérer les coordonnées du clic
const { lng, lat } = e.lngLat;
// Appeler l'API pour obtenir le code INSEE
const response = await fetch(`https://api-adresse.data.gouv.fr/reverse/?lon=${lng}&lat=${lat}`);
const data = await response.json();
if (data.features && data.features.length > 0) {
const feature = data.features[0];
const city = feature.properties.city;
const inseeCode = feature.properties.citycode;
contextMenu.innerHTML = `
<div class="mb-2">
<strong>Ville :</strong> ${city}<br>
<strong>Code INSEE :</strong> ${inseeCode}
</div>
<a href="/admin/labourer/${inseeCode}" class="btn btn-primary btn-sm">
<i class="bi bi-tractor"></i> Labourer cette zone
</a>
`;
} else {
contextMenu.innerHTML = 'Aucune information trouvée pour cette position';
}
} catch (error) {
console.error('Erreur lors de la recherche du code INSEE:', error);
contextMenu.innerHTML = 'Erreur lors de la recherche du code INSEE';
}
});
// Fermer le menu contextuel lors d'un clic ailleurs
document.addEventListener('click', function(e) {
if (contextMenu && !contextMenu.contains(e.target)) {
contextMenu.style.display = 'none';
}
});
// Gestionnaires d'événements pour les boutons de type de marqueur
document.getElementById('circleMarkersBtn').addEventListener('click', function() {
currentMarkerType = 'circle';
this.classList.add('active');
document.getElementById('dropMarkersBtn').classList.remove('active');
updateMarkers();
dropMarkers = updateMarkers(features, map, currentMarkerType, dropMarkers, overpassData);
});
document.getElementById('dropMarkersBtn').addEventListener('click', function() {
currentMarkerType = 'drop';
this.classList.add('active');
document.getElementById('circleMarkersBtn').classList.remove('active');
updateMarkers();
dropMarkers = updateMarkers(features, map, currentMarkerType, dropMarkers, overpassData);
});
document.getElementById('openInJOSM').addEventListener('click', openInJOSM);
@ -720,8 +511,6 @@
// Charger les lieux
loadPlaces(map);
draw_circle_containing_all_features(map);
});
sortTable();

View file

@ -86,9 +86,9 @@
<td class="{{ commerce.noteContent ? 'filled' : '' }}">{{ commerce.noteContent }}</td>
<td class="{{ commerce.siret ? 'filled' : '' }}"> <a href="https://annuaire-entreprises.data.gouv.fr/etablissement/{{ commerce.siret }}" > {{ commerce.siret }}</a></td>
<td>
<a href="https://www.openstreetmap.org/{{ commerce.osmKind }}/{{ commerce.osmId }}" >
<a href="https://www.openstreetmap.org/{{ commerce.osmKind }}/{{ commerce.osmId }}" title="{{ commerce.osmKind }} - {{ commerce.osmId }} " >
<i class="bi bi-globe"></i>
{{ commerce.osmId }}
</a>
</td>
<td>

View file

@ -21,8 +21,10 @@
<i class="bi bi-house-fill"></i>
Rue
</th>
<th>
<i class="bi bi-globe"></i>
Site web ({{ stats.getAvecSite() }} / {{ stats.places|length }})</th>
Site web ({{ stats.getAvecSite() }} / {{ stats.places|length }})
</th>
<th>
<i class="bi bi-wheelchair"></i>
<i class="bi bi-person-fill-slash"></i>

View file

@ -129,25 +129,29 @@
<tbody>
{% for stat in stats %}
<tr>
<td><a href="{{ path('app_admin_stats', {'zip_code': stat.zone}) }}" title="Voir les statistiques de cette ville">
<td><a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}" title="Voir les statistiques de cette ville">
{{ stat.name }}
{% if not stat.name and stat.zone starts with '751' %}
Paris {{ stat.zone|slice(-2) }}e.
{% endif %}
</a></td>
<td>{{ stat.zone }}</td>
<td>{{ stat.completionPercent }}%</td>
<td>{{ stat.places|length }}</td>
<td>
<div class="btn-group" role="group">
<a href="{{ path('app_admin_stats', {'zip_code': stat.zone}) }}" class="btn btn-sm btn-primary" title="Voir les statistiques de cette ville">
<a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}" class="btn btn-sm btn-primary" title="Voir les statistiques de cette ville">
<i class="bi bi-eye"></i>
</a>
<a href="{{ path('app_admin_labourer', {'zip_code': stat.zone}) }}"
<a href="{{ path('app_admin_labourer', {'insee_code': stat.zone}) }}"
class="btn btn-sm btn-success btn-labourer"
data-zip-code="{{ stat.zone }}"
title="Labourer cette ville"
>
<i class="bi bi-recycle"></i>
</a>
<a href="{{ path('app_admin_delete_by_zone', {'zip_code': stat.zone}) }}"
<a href="{{ path('app_admin_delete_by_zone', {'insee_code': stat.zone}) }}"
class="btn btn-sm btn-danger"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette zone ?')"
title="Supprimer cette ville"

View file

@ -126,22 +126,24 @@
<div class="lien-OpenStreetMap">
<a href="https://www.openstreetmap.org/{{commerce.osmKind}}/{{ commerce_overpass['@attributes'].id }}" class="btn btn-outline-secondary">
<i class="bi bi-globe"></i> {{ 'display.view_on_osm'|trans }}
{{ asset('img/logo-osm.png') }}
<img src="{{ asset('img/logo-osm.png') }}" alt="OpenStreetMap" class="img-fluid thumbnail">
</a>
<button onclick="openInJOSM('{{commerce.osmKind}}', '{{ commerce_overpass['@attributes'].id }}')"
title="Ouvrir dans JOSM"
class="btn btn-outline-secondary ms-2">
<i class="bi bi-pencil"></i>
{{ asset('img/josm.png') }}
<img src="{{ asset('img/josm.png') }}" alt="JOSM" class="img-fluid thumbnail">
</button>
<button onclick="openInPanoramax()" title="Ouvrir dans Panoramax" class="btn btn-outline-secondary ms-2">
<i class="bi bi-camera"></i>
{{ asset('img/logo-panoramax.png') }}
<img src="{{ asset('img/panoramax.svg') }}" alt="Panoramax" class="img-fluid thumbnail">
</button>
</div>
{% if commerce.stats %}
<a href="{{ path('app_admin_stats', {'zip_code': commerce.stats.zone}) }}" class="btn btn-outline-secondary">
<a href="{{ path('app_admin_stats', {'insee_code': commerce.stats.zone}) }}" class="btn btn-outline-secondary">
<i class="bi bi-bar-chart"></i> zone {{commerce.stats.zone}}
</a>
{% endif %}

View file

@ -108,7 +108,7 @@
</div>
{% for stat in stats %}
<a href="{{ path('app_admin_stats', {'zip_code': stat.zone}) }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<div class="d-flex flex-column">
<span class="zone">{{ stat.zone }}</span>
<span class="name">{{ stat.name }}</span>