documentation wiki osm, ajout dashboard issues osmose

This commit is contained in:
Tykayn 2025-08-31 11:06:54 +02:00 committed by tykayn
parent b28f8eac63
commit 7665f1d99c
12 changed files with 1758 additions and 76 deletions

View file

@ -0,0 +1,230 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:extract-insee-zones',
description: 'Extrait les données OSM pour chaque zone INSEE à partir du fichier france-latest.osm.pbf',
)]
class ExtractInseeZonesCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function configure(): void
{
$this
->addArgument('insee-code', InputArgument::OPTIONAL, 'Code INSEE spécifique à traiter')
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limite le nombre de villes à traiter', null)
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force l\'extraction même si le fichier JSON existe déjà')
->addOption('keep-pbf', 'k', InputOption::VALUE_NONE, 'Conserve les fichiers PBF intermédiaires')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$inseeCode = $input->getArgument('insee-code');
$limit = $input->getOption('limit');
$force = $input->getOption('force');
$keepPbf = $input->getOption('keep-pbf');
// Créer le dossier oss_data s'il n'existe pas
$ossDataDir = __DIR__ . '/../../oss_data';
if (!is_dir($ossDataDir)) {
$io->note('Création du dossier oss_data');
mkdir($ossDataDir, 0755, true);
}
// Vérifier que le fichier france-latest.osm.pbf existe
$francePbfFile = $ossDataDir . '/france-latest.osm.pbf';
if (!file_exists($francePbfFile)) {
$io->note('Le fichier france-latest.osm.pbf n\'existe pas. Téléchargement en cours depuis Geofabrik...');
// URL de téléchargement
$downloadUrl = 'https://download.geofabrik.de/europe/france-latest.osm.pbf';
// Télécharger le fichier
try {
$context = stream_context_create([
'http' => [
'header' => "User-Agent: OSM-Commerces/1.0\r\n"
]
]);
// Utiliser file_get_contents pour télécharger le fichier
$io->section('Téléchargement du fichier france-latest.osm.pbf');
$io->progressStart(100);
// Téléchargement par morceaux pour pouvoir afficher une progression
$fileHandle = fopen($francePbfFile, 'w');
$curlHandle = curl_init($downloadUrl);
curl_setopt($curlHandle, CURLOPT_FILE, $fileHandle);
curl_setopt($curlHandle, CURLOPT_HEADER, 0);
curl_setopt($curlHandle, CURLOPT_USERAGENT, 'OSM-Commerces/1.0');
curl_setopt($curlHandle, CURLOPT_NOPROGRESS, false);
// Use a simpler progress reporting approach
curl_setopt($curlHandle, CURLOPT_PROGRESSFUNCTION, function($resource, $downloadSize, $downloaded) use ($io) {
static $lastProgress = 0;
if ($downloadSize > 0) {
$progress = round(($downloaded / $downloadSize) * 100);
if ($progress > $lastProgress) {
$io->progressAdvance($progress - $lastProgress);
$lastProgress = $progress;
}
}
});
$success = curl_exec($curlHandle);
curl_close($curlHandle);
fclose($fileHandle);
$io->progressFinish();
if (!$success) {
throw new \Exception('Échec du téléchargement');
}
$io->success('Le fichier france-latest.osm.pbf a été téléchargé avec succès.');
} catch (\Exception $e) {
$io->error('Erreur lors du téléchargement du fichier france-latest.osm.pbf: ' . $e->getMessage());
return Command::FAILURE;
}
}
// Vérifier que le dossier polygons existe
$polygonsDir = __DIR__ . '/../../counting_osm_objects/polygons';
if (!is_dir($polygonsDir)) {
$io->error('Le dossier des polygones n\'existe pas. Veuillez d\'abord exécuter la commande app:retrieve-city-polygons.');
return Command::FAILURE;
}
// Créer le dossier pour les extractions JSON si nécessaire
$extractsDir = __DIR__ . '/../../insee_extracts';
if (!is_dir($extractsDir)) {
$io->note('Création du dossier insee_extracts');
mkdir($extractsDir, 0755, true);
}
// Récupérer les Stats à traiter
$statsRepo = $this->entityManager->getRepository(Stats::class);
if ($inseeCode) {
$io->note(sprintf('Traitement du code INSEE spécifique: %s', $inseeCode));
$allStats = $statsRepo->findBy(['zone' => $inseeCode]);
if (empty($allStats)) {
$io->error(sprintf('Aucune ville trouvée avec le code INSEE %s', $inseeCode));
return Command::FAILURE;
}
} else {
$io->note('Traitement de toutes les villes');
$criteria = [];
$orderBy = ['id' => 'ASC'];
$limitValue = $limit ? (int)$limit : null;
$allStats = $statsRepo->findBy($criteria, $orderBy, $limitValue);
}
$totalCount = count($allStats);
$existingCount = 0;
$createdCount = 0;
$errorCount = 0;
$io->progressStart($totalCount);
// Pour chaque Stats, extraire les données si nécessaire
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode) {
$io->progressAdvance();
continue;
}
$polygonFile = $polygonsDir . '/commune_' . $inseeCode . '.poly';
$extractPbfFile = $extractsDir . '/commune_' . $inseeCode . '.osm.pbf';
$extractJsonFile = $extractsDir . '/commune_' . $inseeCode . '.json';
// Vérifier si le polygone existe
if (!file_exists($polygonFile)) {
$io->debug(sprintf('Polygone manquant pour %s', $inseeCode));
$errorCount++;
$io->progressAdvance();
continue;
}
// Vérifier si l'extraction JSON existe déjà
if (file_exists($extractJsonFile) && !$force) {
$existingCount++;
$io->progressAdvance();
continue;
}
try {
// Étape 1: Extraire les données de france-latest.osm.pbf vers un fichier PBF pour la zone
$extractCommand = 'osmium extract -p ' . $polygonFile . ' ' . $francePbfFile . ' -o ' . $extractPbfFile;
$outputLines = [];
$returnVar = 0;
exec($extractCommand, $outputLines, $returnVar);
if ($returnVar !== 0 || !file_exists($extractPbfFile)) {
$io->debug(sprintf('Erreur lors de l\'extraction PBF pour %s: %s', $inseeCode, implode("\n", $outputLines)));
$errorCount++;
$io->progressAdvance();
continue;
}
// Étape 2: Convertir le fichier PBF en JSON
$exportCommand = 'osmium export ' . $extractPbfFile . ' -f json -o ' . $extractJsonFile;
$outputLines = [];
$returnVar = 0;
exec($exportCommand, $outputLines, $returnVar);
if ($returnVar === 0 && file_exists($extractJsonFile)) {
$createdCount++;
} else {
$io->debug(sprintf('Erreur lors de l\'export JSON pour %s: %s', $inseeCode, implode("\n", $outputLines)));
$errorCount++;
}
// Supprimer le fichier PBF intermédiaire pour économiser de l'espace
if (!$keepPbf && file_exists($extractPbfFile)) {
unlink($extractPbfFile);
}
} catch (\Exception $e) {
$errorCount++;
$io->debug(sprintf('Exception pour %s: %s', $inseeCode, $e->getMessage()));
}
$io->progressAdvance();
}
$io->progressFinish();
$io->success(sprintf(
"Extraction des zones INSEE terminée : %d extractions créées, %d déjà existantes, %d erreurs sur un total de %d communes.",
$createdCount,
$existingCount,
$errorCount,
$totalCount
));
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,149 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use App\Service\Motocultrice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:process-insee-extracts',
description: 'Traite les extraits JSON des zones INSEE pour calculer les mesures de thèmes',
)]
class ProcessInseeExtractsCommand extends Command
{
private EntityManagerInterface $entityManager;
private Motocultrice $motocultrice;
public function __construct(EntityManagerInterface $entityManager, Motocultrice $motocultrice)
{
parent::__construct();
$this->entityManager = $entityManager;
$this->motocultrice = $motocultrice;
}
protected function configure(): void
{
$this
->addArgument('insee-code', InputArgument::OPTIONAL, 'Code INSEE spécifique à traiter')
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limite le nombre de villes à traiter', null)
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force le traitement même si déjà effectué')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$inseeCode = $input->getArgument('insee-code');
$limit = $input->getOption('limit');
$force = $input->getOption('force');
// Vérifier que le dossier des extractions existe
$extractsDir = __DIR__ . '/../../insee_extracts';
if (!is_dir($extractsDir)) {
$io->error('Le dossier des extractions n\'existe pas. Veuillez d\'abord exécuter la commande app:extract-insee-zones.');
return Command::FAILURE;
}
// Récupérer les Stats à traiter
$statsRepo = $this->entityManager->getRepository(Stats::class);
if ($inseeCode) {
$io->note(sprintf('Traitement du code INSEE spécifique: %s', $inseeCode));
$allStats = $statsRepo->findBy(['zone' => $inseeCode]);
if (empty($allStats)) {
$io->error(sprintf('Aucune ville trouvée avec le code INSEE %s', $inseeCode));
return Command::FAILURE;
}
} else {
$io->note('Traitement de toutes les villes');
$criteria = [];
$orderBy = ['id' => 'ASC'];
$limitValue = $limit ? (int)$limit : null;
$allStats = $statsRepo->findBy($criteria, $orderBy, $limitValue);
}
$totalCount = count($allStats);
$processedCount = 0;
$skippedCount = 0;
$errorCount = 0;
$io->progressStart($totalCount);
// Pour chaque Stats, traiter les données si nécessaire
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode) {
$io->progressAdvance();
continue;
}
$extractJsonFile = $extractsDir . '/commune_' . $inseeCode . '.json';
// Vérifier si l'extraction JSON existe
if (!file_exists($extractJsonFile)) {
$io->debug(sprintf('Fichier JSON manquant pour %s', $inseeCode));
$errorCount++;
$io->progressAdvance();
continue;
}
// Vérifier si le traitement a déjà été effectué
if (!$force && $stat->getDateLabourageDone() !== null) {
$skippedCount++;
$io->progressAdvance();
continue;
}
try {
// Utiliser la Motocultrice pour traiter les données
$result = $this->motocultrice->labourer($inseeCode);
if ($result) {
// Mettre à jour la date de labourage
$stat->setDateLabourageDone(new \DateTime());
$this->entityManager->persist($stat);
$processedCount++;
// Flush tous les 10 objets pour éviter de surcharger la mémoire
if ($processedCount % 10 === 0) {
$this->entityManager->flush();
$io->debug(sprintf('Flush après %d traitements', $processedCount));
}
} else {
$io->debug(sprintf('Échec du traitement pour %s', $inseeCode));
$errorCount++;
}
} catch (\Exception $e) {
$errorCount++;
$io->debug(sprintf('Exception pour %s: %s', $inseeCode, $e->getMessage()));
}
$io->progressAdvance();
}
// Flush les derniers objets
$this->entityManager->flush();
$io->progressFinish();
$io->success(sprintf(
"Traitement des extractions INSEE terminé : %d communes traitées, %d ignorées, %d erreurs sur un total de %d communes.",
$processedCount,
$skippedCount,
$errorCount,
$totalCount
));
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:retrieve-city-polygons',
description: 'Récupère les polygones des villes selon leur zone donnée par le code INSEE',
)]
class RetrieveCityPolygonsCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function configure(): void
{
$this
->addArgument('insee-code', InputArgument::OPTIONAL, 'Code INSEE spécifique à traiter')
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limite le nombre de villes à traiter', null)
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force la récupération même si le polygone existe déjà')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$inseeCode = $input->getArgument('insee-code');
$limit = $input->getOption('limit');
$force = $input->getOption('force');
// Vérifier que le dossier polygons existe, sinon le créer
$polygonsDir = __DIR__ . '/../../counting_osm_objects/polygons';
if (!is_dir($polygonsDir)) {
$io->note('Création du dossier polygons');
mkdir($polygonsDir, 0755, true);
}
// Récupérer les Stats à traiter
$statsRepo = $this->entityManager->getRepository(Stats::class);
if ($inseeCode) {
$io->note(sprintf('Traitement du code INSEE spécifique: %s', $inseeCode));
$allStats = $statsRepo->findBy(['zone' => $inseeCode]);
if (empty($allStats)) {
$io->error(sprintf('Aucune ville trouvée avec le code INSEE %s', $inseeCode));
return Command::FAILURE;
}
} else {
$io->note('Traitement de toutes les villes');
$criteria = [];
$orderBy = ['id' => 'ASC'];
$limitValue = $limit ? (int)$limit : null;
$allStats = $statsRepo->findBy($criteria, $orderBy, $limitValue);
}
$totalCount = count($allStats);
$existingCount = 0;
$createdCount = 0;
$errorCount = 0;
$io->progressStart($totalCount);
// Pour chaque Stats, récupérer le polygone si nécessaire
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode) {
$io->progressAdvance();
continue;
}
$polygonFile = $polygonsDir . '/commune_' . $inseeCode . '.poly';
// Vérifier si le polygone existe déjà
if (file_exists($polygonFile) && !$force) {
$existingCount++;
$io->progressAdvance();
continue;
}
try {
// Utiliser le script Python existant pour récupérer le polygone
$command = 'cd ' . __DIR__ . '/../../counting_osm_objects && python3 get_poly.py ' . $inseeCode;
$output = [];
$returnVar = 0;
exec($command, $output, $returnVar);
if ($returnVar === 0 && file_exists($polygonFile)) {
$createdCount++;
} else {
$errorCount++;
if ($output) {
$io->debug(sprintf('Erreur pour %s: %s', $inseeCode, implode("\n", $output)));
}
}
} catch (\Exception $e) {
$errorCount++;
$io->debug(sprintf('Exception pour %s: %s', $inseeCode, $e->getMessage()));
}
$io->progressAdvance();
}
$io->progressFinish();
$io->success(sprintf(
"Récupération des polygones terminée : %d polygones créés, %d déjà existants, %d erreurs sur un total de %d communes.",
$createdCount,
$existingCount,
$errorCount,
$totalCount
));
return Command::SUCCESS;
}
}

View file

@ -1826,11 +1826,48 @@ final class AdminController extends AbstractController
{
$this->actionLogger->log('admin/extract_insee_zones', []);
// Créer le dossier oss_data s'il n'existe pas
$ossDataDir = __DIR__ . '/../../oss_data';
if (!is_dir($ossDataDir)) {
mkdir($ossDataDir, 0755, true);
}
// Vérifier que le fichier france-latest.osm.pbf existe
$francePbfFile = __DIR__ . '/../../france-latest.osm.pbf';
$francePbfFile = $ossDataDir . '/france-latest.osm.pbf';
if (!file_exists($francePbfFile)) {
$this->addFlash('error', 'Le fichier france-latest.osm.pbf n\'existe pas. Veuillez le télécharger depuis https://download.geofabrik.de/europe/france.html');
return $this->redirectToRoute('app_admin');
$this->actionLogger->log('admin/download_france_pbf', []);
$this->addFlash('info', 'Le fichier france-latest.osm.pbf n\'existe pas. Téléchargement en cours depuis Geofabrik...');
// URL de téléchargement
$downloadUrl = 'https://download.geofabrik.de/europe/france-latest.osm.pbf';
// Télécharger le fichier
try {
$context = stream_context_create([
'http' => [
'header' => "User-Agent: OSM-Commerces/1.0\r\n"
]
]);
// Utiliser file_get_contents pour télécharger le fichier
$fileContent = file_get_contents($downloadUrl, false, $context);
if ($fileContent === false) {
throw new \Exception('Échec du téléchargement');
}
// Sauvegarder le fichier
if (file_put_contents($francePbfFile, $fileContent) === false) {
throw new \Exception('Échec de l\'écriture du fichier');
}
$this->addFlash('success', 'Le fichier france-latest.osm.pbf a été téléchargé avec succès.');
} catch (\Exception $e) {
$this->actionLogger->log('error_download_france_pbf', [
'error' => $e->getMessage()
]);
$this->addFlash('error', 'Erreur lors du téléchargement du fichier france-latest.osm.pbf: ' . $e->getMessage());
return $this->redirectToRoute('app_admin');
}
}
// Vérifier que le dossier polygons existe
@ -2008,6 +2045,571 @@ final class AdminController extends AbstractController
return $this->redirectToRoute('app_admin');
}
#[Route('/admin/completion-statistics', name: 'app_admin_completion_statistics')]
public function completionStatistics(Request $request): Response
{
$this->actionLogger->log('admin/completion_statistics', []);
// Récupérer le thème sélectionné (par défaut: 'places')
$theme = $request->query->get('theme', 'places');
// Récupérer le niveau géographique sélectionné (par défaut: 'department')
$level = $request->query->get('level', 'department');
// Récupérer tous les thèmes disponibles
$themes = $this->followUpService->getFollowUpThemes();
// Récupérer les données de complétion selon le niveau géographique
$completionData = [];
$chartLabels = [];
$chartData = [];
switch ($level) {
case 'department':
// Agréger les données par département
$completionData = $this->aggregateCompletionByDepartment($theme);
break;
case 'region':
// Agréger les données par région
$completionData = $this->aggregateCompletionByRegion($theme);
break;
case 'country':
// Agréger les données pour la France entière
$completionData = $this->aggregateCompletionForCountry($theme);
break;
case 'city':
// Récupérer les données pour les villes (limité aux 50 plus grandes)
$completionData = $this->aggregateCompletionForTopCities($theme, 50);
break;
}
// Préparer les données pour les graphiques
foreach ($completionData as $key => $value) {
$chartLabels[] = $key;
$chartData[] = $value;
}
return $this->render('admin/completion_statistics.html.twig', [
'theme' => $theme,
'level' => $level,
'themes' => $themes,
'chartLabels' => json_encode($chartLabels),
'chartData' => json_encode($chartData),
'completionData' => $completionData
]);
}
/**
* Agrège les données de complétion par département
*/
private function aggregateCompletionByDepartment(string $theme): array
{
$result = [];
// Récupérer toutes les Stats
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
// Grouper par département (2 premiers chiffres du code INSEE)
$departmentData = [];
$departmentCounts = [];
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode || strlen($inseeCode) < 2) {
continue;
}
// Extraire le code département (2 premiers chiffres du code INSEE)
$departmentCode = substr($inseeCode, 0, 2);
// Cas particuliers pour la Corse
if ($departmentCode === '2A' || $departmentCode === '2B') {
$departmentCode = substr($inseeCode, 0, 3);
}
// Récupérer les mesures pour le thème spécifié
$latestMeasure = $this->getLatestMeasureForTheme($stat, $theme);
if ($latestMeasure !== null) {
if (!isset($departmentData[$departmentCode])) {
$departmentData[$departmentCode] = 0;
$departmentCounts[$departmentCode] = 0;
}
$departmentData[$departmentCode] += $latestMeasure;
$departmentCounts[$departmentCode]++;
}
}
// Calculer les moyennes par département
foreach ($departmentData as $departmentCode => $total) {
if ($departmentCounts[$departmentCode] > 0) {
$result[$departmentCode] = round($total / $departmentCounts[$departmentCode], 2);
}
}
// Trier par code département
ksort($result);
return $result;
}
/**
* Agrège les données de complétion par région
*/
private function aggregateCompletionByRegion(string $theme): array
{
$result = [];
// Mapping des départements vers les régions
$departmentToRegion = $this->getDepartmentToRegionMapping();
// Récupérer les données par département
$departmentData = $this->aggregateCompletionByDepartment($theme);
// Grouper par région
$regionData = [];
$regionCounts = [];
foreach ($departmentData as $departmentCode => $value) {
// Trouver la région correspondante
$region = $departmentToRegion[$departmentCode] ?? 'Autre';
if (!isset($regionData[$region])) {
$regionData[$region] = 0;
$regionCounts[$region] = 0;
}
$regionData[$region] += $value;
$regionCounts[$region]++;
}
// Calculer les moyennes par région
foreach ($regionData as $region => $total) {
if ($regionCounts[$region] > 0) {
$result[$region] = round($total / $regionCounts[$region], 2);
}
}
// Trier par nom de région
ksort($result);
return $result;
}
/**
* Agrège les données de complétion pour la France entière
*/
private function aggregateCompletionForCountry(string $theme): array
{
$result = [];
// Récupérer toutes les Stats
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
// Récupérer les mesures pour le thème spécifié
$totalMeasure = 0;
$count = 0;
foreach ($allStats as $stat) {
$latestMeasure = $this->getLatestMeasureForTheme($stat, $theme);
if ($latestMeasure !== null) {
$totalMeasure += $latestMeasure;
$count++;
}
}
// Calculer la moyenne nationale
if ($count > 0) {
$result['France'] = round($totalMeasure / $count, 2);
}
return $result;
}
/**
* Agrège les données de complétion pour les principales villes
*/
private function aggregateCompletionForTopCities(string $theme, int $limit = 50): array
{
$result = [];
// Récupérer les Stats triées par population (descendant)
$statsRepo = $this->entityManager->getRepository(Stats::class);
$topCities = $statsRepo->findBy(
['population' => null], // Critère (tous)
['population' => 'DESC'], // Tri par population descendante
$limit // Limite
);
// Récupérer les mesures pour le thème spécifié
foreach ($topCities as $stat) {
$cityName = $stat->getName() ?? $stat->getZone();
$latestMeasure = $this->getLatestMeasureForTheme($stat, $theme);
if ($latestMeasure !== null) {
$result[$cityName] = $latestMeasure;
}
}
// Trier par valeur décroissante
arsort($result);
return $result;
}
/**
* Récupère la dernière mesure pour un thème donné
*/
private function getLatestMeasureForTheme(Stats $stat, string $theme): ?float
{
$cityFollowUps = $stat->getCityFollowUps();
// Filtrer les CityFollowUp par thème et trier par date (descendant)
$filteredFollowUps = $cityFollowUps->filter(function(CityFollowUp $followUp) use ($theme) {
return $followUp->getName() === $theme;
});
if ($filteredFollowUps->isEmpty()) {
return null;
}
// Trier par date (du plus récent au plus ancien)
$iterator = $filteredFollowUps->getIterator();
$iterator->uasort(function(CityFollowUp $a, CityFollowUp $b) {
return $b->getDate() <=> $a->getDate();
});
// Récupérer la mesure la plus récente
$latestFollowUp = $iterator->current();
return $latestFollowUp->getMeasure();
}
#[Route('/admin/osmose-issues-map/{inseeCode}', name: 'app_admin_osmose_issues_map')]
public function osmoseIssuesMap(Request $request, string $inseeCode = null): Response
{
$this->actionLogger->log('admin/osmose_issues_map', [
'insee_code' => $inseeCode
]);
// Si aucun code INSEE n'est fourni, rediriger vers la liste des villes
if (!$inseeCode) {
$this->addFlash('info', 'Veuillez sélectionner une ville pour afficher les problèmes Osmose.');
return $this->redirectToRoute('app_admin');
}
// Récupérer la ville correspondante
$statsRepo = $this->entityManager->getRepository(Stats::class);
$city = $statsRepo->findOneBy(['zone' => $inseeCode]);
if (!$city) {
$this->addFlash('error', 'Ville non trouvée pour le code INSEE ' . $inseeCode);
return $this->redirectToRoute('app_admin');
}
// Récupérer le thème sélectionné (par défaut: tous)
$theme = $request->query->get('theme', 'all');
// Récupérer tous les thèmes disponibles
$themes = $this->followUpService->getFollowUpThemes();
// Récupérer les problèmes Osmose pour cette ville
$osmoseIssues = $this->getOsmoseIssuesForCity($city, $theme);
return $this->render('admin/osmose_issues_map.html.twig', [
'city' => $city,
'theme' => $theme,
'themes' => $themes,
'osmoseIssues' => $osmoseIssues
]);
}
/**
* Récupère les problèmes Osmose pour une ville donnée
*/
private function getOsmoseIssuesForCity(Stats $city, string $theme = 'all'): array
{
$issues = [];
// Coordonnées de la ville
$lat = $city->getLat();
$lon = $city->getLon();
if (!$lat || !$lon) {
return $issues;
}
// Construire l'URL de l'API Osmose
$bbox = $this->calculateBoundingBox($lat, $lon, 5); // 5km autour du centre de la ville
$osmoseApiUrl = sprintf(
'https://osmose.openstreetmap.fr/api/0.3/issues?bbox=%f,%f,%f,%f&item=xxxx&limit=500',
$bbox['min_lon'],
$bbox['min_lat'],
$bbox['max_lon'],
$bbox['max_lat']
);
// Récupérer les items Osmose correspondant aux thèmes
$itemIds = $this->getOsmoseItemIdsForTheme($theme);
if (!empty($itemIds)) {
$osmoseApiUrl = str_replace('xxxx', implode(',', $itemIds), $osmoseApiUrl);
} else {
$osmoseApiUrl = str_replace('&item=xxxx', '', $osmoseApiUrl);
}
try {
// Appeler l'API Osmose
$response = file_get_contents($osmoseApiUrl);
if ($response === false) {
throw new \Exception('Échec de la récupération des données Osmose');
}
$data = json_decode($response, true);
if (isset($data['issues'])) {
foreach ($data['issues'] as $issue) {
// Vérifier si l'issue est dans les limites de la ville (approximativement)
if ($this->isPointInCity($issue['lat'], $issue['lon'], $lat, $lon, 5)) {
$issues[] = [
'id' => $issue['id'],
'title' => $issue['title'] ?? 'Problème sans titre',
'subtitle' => $issue['subtitle'] ?? '',
'lat' => $issue['lat'],
'lon' => $issue['lon'],
'item' => $issue['item'],
'class' => $issue['class'],
'level' => $issue['level'] ?? 2,
'update_timestamp' => $issue['update_timestamp'] ?? null,
'url' => sprintf('https://osmose.openstreetmap.fr/fr/issue/%s', $issue['uuid'])
];
}
}
}
} catch (\Exception $e) {
$this->actionLogger->log('error_osmose_api', [
'insee_code' => $city->getZone(),
'error' => $e->getMessage()
]);
}
return $issues;
}
/**
* Calcule une bounding box autour d'un point
*/
private function calculateBoundingBox(string $lat, string $lon, int $distanceKm = 5): array
{
$lat = (float)$lat;
$lon = (float)$lon;
// Approximation: 1 degré de latitude = 111 km
$latDelta = $distanceKm / 111.0;
// Approximation: 1 degré de longitude = 111 * cos(latitude) km
$lonDelta = $distanceKm / (111.0 * cos(deg2rad($lat)));
return [
'min_lat' => $lat - $latDelta,
'max_lat' => $lat + $latDelta,
'min_lon' => $lon - $lonDelta,
'max_lon' => $lon + $lonDelta
];
}
/**
* Vérifie si un point est dans un rayon donné autour d'une ville
*/
private function isPointInCity(float $pointLat, float $pointLon, string $cityLat, string $cityLon, int $radiusKm = 5): bool
{
$cityLat = (float)$cityLat;
$cityLon = (float)$cityLon;
// Calcul de la distance en km (formule de Haversine)
$earthRadius = 6371; // Rayon de la Terre en km
$dLat = deg2rad($pointLat - $cityLat);
$dLon = deg2rad($pointLon - $cityLon);
$a = sin($dLat/2) * sin($dLat/2) +
cos(deg2rad($cityLat)) * cos(deg2rad($pointLat)) *
sin($dLon/2) * sin($dLon/2);
$c = 2 * atan2(sqrt($a), sqrt(1-$a));
$distance = $earthRadius * $c;
return $distance <= $radiusKm;
}
/**
* Retourne les IDs des items Osmose correspondant à un thème
*/
private function getOsmoseItemIdsForTheme(string $theme): array
{
// Mapping des thèmes vers les items Osmose
$themeToItemsMapping = [
'places' => [8230, 8240, 8250, 8260], // Commerces et services
'restaurants' => [8030, 8031, 8032], // Restaurants et cafés
'hotels' => [8040, 8041, 8042], // Hébergements
'tourism' => [8010, 8011, 8012, 8013], // Tourisme
'leisure' => [8050, 8051, 8052], // Loisirs
'healthcare' => [8060, 8061, 8062], // Santé
'education' => [8070, 8071, 8072], // Éducation
'transportation' => [4010, 4020, 4030, 4040], // Transport
'amenities' => [8080, 8081, 8082], // Équipements
// Si d'autres thèmes sont nécessaires, ajoutez-les ici
];
// Si le thème est 'all' ou n'existe pas dans le mapping, retourner un tableau vide
if ($theme === 'all' || !isset($themeToItemsMapping[$theme])) {
return [];
}
return $themeToItemsMapping[$theme];
}
/**
* Retourne un mapping des départements vers les régions
*/
private function getDepartmentToRegionMapping(): array
{
return [
// Auvergne-Rhône-Alpes
'01' => 'Auvergne-Rhône-Alpes', // Ain
'03' => 'Auvergne-Rhône-Alpes', // Allier
'07' => 'Auvergne-Rhône-Alpes', // Ardèche
'15' => 'Auvergne-Rhône-Alpes', // Cantal
'26' => 'Auvergne-Rhône-Alpes', // Drôme
'38' => 'Auvergne-Rhône-Alpes', // Isère
'42' => 'Auvergne-Rhône-Alpes', // Loire
'43' => 'Auvergne-Rhône-Alpes', // Haute-Loire
'63' => 'Auvergne-Rhône-Alpes', // Puy-de-Dôme
'69' => 'Auvergne-Rhône-Alpes', // Rhône
'73' => 'Auvergne-Rhône-Alpes', // Savoie
'74' => 'Auvergne-Rhône-Alpes', // Haute-Savoie
// Bourgogne-Franche-Comté
'21' => 'Bourgogne-Franche-Comté', // Côte-d'Or
'25' => 'Bourgogne-Franche-Comté', // Doubs
'39' => 'Bourgogne-Franche-Comté', // Jura
'58' => 'Bourgogne-Franche-Comté', // Nièvre
'70' => 'Bourgogne-Franche-Comté', // Haute-Saône
'71' => 'Bourgogne-Franche-Comté', // Saône-et-Loire
'89' => 'Bourgogne-Franche-Comté', // Yonne
'90' => 'Bourgogne-Franche-Comté', // Territoire de Belfort
// Bretagne
'22' => 'Bretagne', // Côtes-d'Armor
'29' => 'Bretagne', // Finistère
'35' => 'Bretagne', // Ille-et-Vilaine
'56' => 'Bretagne', // Morbihan
// Centre-Val de Loire
'18' => 'Centre-Val de Loire', // Cher
'28' => 'Centre-Val de Loire', // Eure-et-Loir
'36' => 'Centre-Val de Loire', // Indre
'37' => 'Centre-Val de Loire', // Indre-et-Loire
'41' => 'Centre-Val de Loire', // Loir-et-Cher
'45' => 'Centre-Val de Loire', // Loiret
// Corse
'2A' => 'Corse', // Corse-du-Sud
'2B' => 'Corse', // Haute-Corse
// Grand Est
'08' => 'Grand Est', // Ardennes
'10' => 'Grand Est', // Aube
'51' => 'Grand Est', // Marne
'52' => 'Grand Est', // Haute-Marne
'54' => 'Grand Est', // Meurthe-et-Moselle
'55' => 'Grand Est', // Meuse
'57' => 'Grand Est', // Moselle
'67' => 'Grand Est', // Bas-Rhin
'68' => 'Grand Est', // Haut-Rhin
'88' => 'Grand Est', // Vosges
// Hauts-de-France
'02' => 'Hauts-de-France', // Aisne
'59' => 'Hauts-de-France', // Nord
'60' => 'Hauts-de-France', // Oise
'62' => 'Hauts-de-France', // Pas-de-Calais
'80' => 'Hauts-de-France', // Somme
// Île-de-France
'75' => 'Île-de-France', // Paris
'77' => 'Île-de-France', // Seine-et-Marne
'78' => 'Île-de-France', // Yvelines
'91' => 'Île-de-France', // Essonne
'92' => 'Île-de-France', // Hauts-de-Seine
'93' => 'Île-de-France', // Seine-Saint-Denis
'94' => 'Île-de-France', // Val-de-Marne
'95' => 'Île-de-France', // Val-d'Oise
// Normandie
'14' => 'Normandie', // Calvados
'27' => 'Normandie', // Eure
'50' => 'Normandie', // Manche
'61' => 'Normandie', // Orne
'76' => 'Normandie', // Seine-Maritime
// Nouvelle-Aquitaine
'16' => 'Nouvelle-Aquitaine', // Charente
'17' => 'Nouvelle-Aquitaine', // Charente-Maritime
'19' => 'Nouvelle-Aquitaine', // Corrèze
'23' => 'Nouvelle-Aquitaine', // Creuse
'24' => 'Nouvelle-Aquitaine', // Dordogne
'33' => 'Nouvelle-Aquitaine', // Gironde
'40' => 'Nouvelle-Aquitaine', // Landes
'47' => 'Nouvelle-Aquitaine', // Lot-et-Garonne
'64' => 'Nouvelle-Aquitaine', // Pyrénées-Atlantiques
'79' => 'Nouvelle-Aquitaine', // Deux-Sèvres
'86' => 'Nouvelle-Aquitaine', // Vienne
'87' => 'Nouvelle-Aquitaine', // Haute-Vienne
// Occitanie
'09' => 'Occitanie', // Ariège
'11' => 'Occitanie', // Aude
'12' => 'Occitanie', // Aveyron
'30' => 'Occitanie', // Gard
'31' => 'Occitanie', // Haute-Garonne
'32' => 'Occitanie', // Gers
'34' => 'Occitanie', // Hérault
'46' => 'Occitanie', // Lot
'48' => 'Occitanie', // Lozère
'65' => 'Occitanie', // Hautes-Pyrénées
'66' => 'Occitanie', // Pyrénées-Orientales
'81' => 'Occitanie', // Tarn
'82' => 'Occitanie', // Tarn-et-Garonne
// Pays de la Loire
'44' => 'Pays de la Loire', // Loire-Atlantique
'49' => 'Pays de la Loire', // Maine-et-Loire
'53' => 'Pays de la Loire', // Mayenne
'72' => 'Pays de la Loire', // Sarthe
'85' => 'Pays de la Loire', // Vendée
// Provence-Alpes-Côte d'Azur
'04' => 'Provence-Alpes-Côte d\'Azur', // Alpes-de-Haute-Provence
'05' => 'Provence-Alpes-Côte d\'Azur', // Hautes-Alpes
'06' => 'Provence-Alpes-Côte d\'Azur', // Alpes-Maritimes
'13' => 'Provence-Alpes-Côte d\'Azur', // Bouches-du-Rhône
'83' => 'Provence-Alpes-Côte d\'Azur', // Var
'84' => 'Provence-Alpes-Côte d\'Azur', // Vaucluse
// Départements d'outre-mer
'971' => 'Guadeloupe',
'972' => 'Martinique',
'973' => 'Guyane',
'974' => 'La Réunion',
'976' => 'Mayotte'
];
}
/**
* Complète les données manquantes d'un objet Stats (coordonnées, budget, etc.)
*/