infos de fraicheur de données

This commit is contained in:
Tykayn 2025-06-24 00:29:15 +02:00 committed by tykayn
parent 4eb95d5b95
commit b41bbc9696
6 changed files with 573 additions and 0 deletions

View file

@ -13,6 +13,8 @@ use App\Service\Motocultrice;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use function uuid_create; use function uuid_create;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\JsonResponse;
final class AdminController extends AbstractController final class AdminController extends AbstractController
{ {
@ -781,4 +783,158 @@ final class AdminController extends AbstractController
$this->addFlash('success', 'Email envoyé avec succès à ' . $place->getName() . ' le ' . $place->getLastContactAttemptDate()->format('d/m/Y H:i:s')); $this->addFlash('success', 'Email envoyé avec succès à ' . $place->getName() . ' le ' . $place->getLastContactAttemptDate()->format('d/m/Y H:i:s'));
return $this->redirectToRoute('app_public_index'); return $this->redirectToRoute('app_public_index');
} }
#[Route('/admin/fraicheur/histogramme', name: 'admin_fraicheur_histogramme')]
public function showFraicheurHistogramme(): Response
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/fraicheur_osm.json';
if (!file_exists($jsonPath)) {
// Générer le fichier si absent
$this->calculateFraicheur();
}
return $this->render('admin/fraicheur_histogramme.html.twig');
}
#[Route('/admin/fraicheur/calculate', name: 'admin_fraicheur_calculate')]
public function calculateFraicheur(): Response
{
$filesystem = new Filesystem();
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/fraicheur_osm.json';
$now = new \DateTime();
// Si le fichier existe et a moins de 12h, on ne régénère pas
if ($filesystem->exists($jsonPath)) {
$fileMTime = filemtime($jsonPath);
if ($fileMTime && ($now->getTimestamp() - $fileMTime) < 43200) { // 12h = 43200s
return $this->redirectToRoute('admin_fraicheur_histogramme');
}
}
$places = $this->entityManager->getRepository(Place::class)->findAll();
$histogram = [];
$total = 0;
foreach ($places as $place) {
$date = $place->getOsmDataDate();
if ($date) {
$key = $date->format('Y-m');
if (!isset($histogram[$key])) {
$histogram[$key] = 0;
}
$histogram[$key]++;
$total++;
}
}
ksort($histogram);
$data = [
'generated_at' => $now->format('c'),
'total' => $total,
'histogram' => $histogram
];
$filesystem->dumpFile($jsonPath, json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
// --- Distribution villes selon lieux/habitants ---
$distJsonPath = $this->getParameter('kernel.project_dir') . '/var/distribution_villes_lieux_par_habitant.json';
// Toujours régénérer
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
$histogram_lieux_par_habitant = [];
$histogram_habitants_par_lieu = [];
$totalVilles = 0;
foreach ($allStats as $stat) {
$places = $stat->getPlacesCount();
$population = $stat->getPopulation();
if ($places && $population && $population > 0) {
// lieux par habitant (pas de 0.01)
$ratio_lph = round($places / $population, 4);
$bin_lph = round(floor($ratio_lph / 0.01) * 0.01, 2);
if (!isset($histogram_lieux_par_habitant[$bin_lph])) $histogram_lieux_par_habitant[$bin_lph] = 0;
$histogram_lieux_par_habitant[$bin_lph]++;
// habitants par lieu (pas de 10)
$ratio_hpl = ceil($population / $places);
$bin_hpl = ceil($ratio_hpl / 10) * 10;
if (!isset($histogram_habitants_par_lieu[$bin_hpl])) $histogram_habitants_par_lieu[$bin_hpl] = 0;
$histogram_habitants_par_lieu[$bin_hpl]++;
$totalVilles++;
}
}
ksort($histogram_lieux_par_habitant);
ksort($histogram_habitants_par_lieu);
$distData = [
'generated_at' => $now->format('c'),
'total_villes' => $totalVilles,
'histogram_001' => $histogram_lieux_par_habitant,
'histogram_10' => $histogram_habitants_par_lieu
];
$filesystem->dumpFile($distJsonPath, json_encode($distData, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
return $this->redirectToRoute('admin_fraicheur_histogramme');
}
#[Route('/admin/fraicheur/download', name: 'admin_fraicheur_download')]
public function downloadFraicheur(): JsonResponse
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/fraicheur_osm.json';
if (!file_exists($jsonPath)) {
return new JsonResponse(['error' => 'Fichier non généré'], 404);
}
$content = file_get_contents($jsonPath);
$data = json_decode($content, true);
return new JsonResponse($data);
}
#[Route('/admin/distribution_villes_lieux_par_habitant_download', name: 'admin_distribution_villes_lieux_par_habitant_download')]
public function downloadDistributionVillesLieuxParHabitant(): JsonResponse
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/distribution_villes_lieux_par_habitant.json';
if (!file_exists($jsonPath)) {
// Générer à la volée si absent
$now = new \DateTime();
$filesystem = new \Symfony\Component\Filesystem\Filesystem();
$statsRepo = $this->entityManager->getRepository(\App\Entity\Stats::class);
$allStats = $statsRepo->findAll();
$distribution = [];
$histogram = [];
$totalVilles = 0;
foreach ($allStats as $stat) {
$places = $stat->getPlacesCount();
$population = $stat->getPopulation();
if ($places && $population && $population > 0) {
$ratio = round($places / $population, 4); // lieux par habitant
$bin = round(floor($ratio / 0.01) * 0.01, 2); // pas de 0.01
if (!isset($histogram[$bin])) $histogram[$bin] = 0;
$histogram[$bin]++;
$totalVilles++;
}
}
ksort($histogram);
$distData = [
'generated_at' => $now->format('c'),
'total_villes' => $totalVilles,
'histogram_001' => $histogram
];
$filesystem->dumpFile($jsonPath, json_encode($distData, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
}
$content = file_get_contents($jsonPath);
$data = json_decode($content, true);
return new JsonResponse($data);
}
#[Route('/admin/distribution_villes_lieux_par_habitant_villes', name: 'admin_distribution_villes_lieux_par_habitant_villes')]
public function downloadDistributionVillesLieuxParHabitantVilles(): JsonResponse
{
$statsRepo = $this->entityManager->getRepository(\App\Entity\Stats::class);
$allStats = $statsRepo->findAll();
$villesByBin = [];
foreach ($allStats as $stat) {
$places = $stat->getPlacesCount();
$population = $stat->getPopulation();
$name = $stat->getName();
if ($places && $population && $population > 0 && $name) {
$ratio = round($places / $population, 4); // lieux par habitant
$bin = round(floor($ratio / 0.01) * 0.01, 2); // pas de 0.01
if (!isset($villesByBin[$bin])) $villesByBin[$bin] = [];
$villesByBin[$bin][] = $name;
}
}
ksort($villesByBin);
return new JsonResponse(['villes_by_bin' => $villesByBin]);
}
} }

View file

@ -0,0 +1,143 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Filesystem\Filesystem;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Place;
use App\Entity\Stats;
class FraicheurController extends AbstractController
{
public function __construct(private EntityManagerInterface $entityManager) {}
#[Route('/admin/fraicheur/histogramme', name: 'admin_fraicheur_histogramme')]
public function showFraicheurHistogramme(): Response
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/fraicheur_osm.json';
if (!file_exists($jsonPath)) {
$this->calculateFraicheur();
}
return $this->render('admin/fraicheur_histogramme.html.twig');
}
#[Route('/admin/fraicheur/calculate', name: 'admin_fraicheur_calculate')]
public function calculateFraicheur(): Response
{
$filesystem = new Filesystem();
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/fraicheur_osm.json';
$now = new \DateTime();
$places = $this->entityManager->getRepository(Place::class)->findAll();
$histogram = [];
$total = 0;
foreach ($places as $place) {
$date = $place->getOsmDataDate();
if ($date) {
$key = $date->format('Y-m');
if (!isset($histogram[$key])) {
$histogram[$key] = 0;
}
$histogram[$key]++;
$total++;
}
}
ksort($histogram);
$data = [
'generated_at' => $now->format('c'),
'total' => $total,
'histogram' => $histogram
];
$filesystem->dumpFile($jsonPath, json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
// --- Distribution villes selon lieux/habitants ---
$distJsonPath = $this->getParameter('kernel.project_dir') . '/var/distribution_villes_lieux_par_habitant.json';
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
$histogram_lieux_par_habitant = [];
$histogram_habitants_par_lieu = [];
$villesByBinLph = [];
$villesByBinHpl = [];
$totalVilles = 0;
foreach ($allStats as $stat) {
$places = $stat->getPlacesCount();
$population = $stat->getPopulation();
$name = $stat->getName();
if ($places && $population && $population > 0 && $name) {
// lieux par habitant (pas de 0.01)
$ratio_lph = $places / $population;
$bin_lph = number_format(floor($ratio_lph / 0.01) * 0.01, 2, '.', '');
if (!isset($histogram_lieux_par_habitant[$bin_lph])) $histogram_lieux_par_habitant[$bin_lph] = 0;
$histogram_lieux_par_habitant[$bin_lph]++;
if (!isset($villesByBinLph[$bin_lph])) $villesByBinLph[$bin_lph] = [];
$villesByBinLph[$bin_lph][] = $name;
// habitants par lieu (pas de 10)
$ratio_hpl = $population / $places;
$bin_hpl = (string)(ceil($ratio_hpl / 10) * 10);
if (!isset($histogram_habitants_par_lieu[$bin_hpl])) $histogram_habitants_par_lieu[$bin_hpl] = 0;
$histogram_habitants_par_lieu[$bin_hpl]++;
if (!isset($villesByBinHpl[$bin_hpl])) $villesByBinHpl[$bin_hpl] = [];
$villesByBinHpl[$bin_hpl][] = $name;
$totalVilles++;
}
}
ksort($histogram_lieux_par_habitant);
ksort($histogram_habitants_par_lieu);
ksort($villesByBinLph);
ksort($villesByBinHpl);
$distData = [
'generated_at' => $now->format('c'),
'total_villes' => $totalVilles,
'histogram_001' => $histogram_lieux_par_habitant,
'histogram_10' => $histogram_habitants_par_lieu,
'villes_by_bin_001' => $villesByBinLph,
'villes_by_bin_10' => $villesByBinHpl
];
$filesystem->dumpFile($distJsonPath, json_encode($distData, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
return $this->redirectToRoute('admin_fraicheur_histogramme');
}
#[Route('/admin/fraicheur/download', name: 'admin_fraicheur_download')]
public function downloadFraicheur(): JsonResponse
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/fraicheur_osm.json';
if (!file_exists($jsonPath)) {
return new JsonResponse(['error' => 'Fichier non généré'], 404);
}
$content = file_get_contents($jsonPath);
$data = json_decode($content, true);
return new JsonResponse($data);
}
#[Route('/admin/distribution_villes_lieux_par_habitant_download', name: 'admin_distribution_villes_lieux_par_habitant_download')]
public function downloadDistributionVillesLieuxParHabitant(): JsonResponse
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/distribution_villes_lieux_par_habitant.json';
if (!file_exists($jsonPath)) {
$this->calculateFraicheur();
}
$content = file_get_contents($jsonPath);
$data = json_decode($content, true);
return new JsonResponse($data);
}
#[Route('/admin/distribution_villes_lieux_par_habitant_villes', name: 'admin_distribution_villes_lieux_par_habitant_villes')]
public function downloadDistributionVillesLieuxParHabitantVilles(): JsonResponse
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/distribution_villes_lieux_par_habitant.json';
if (!file_exists($jsonPath)) {
$this->calculateFraicheur();
}
$content = file_get_contents($jsonPath);
$data = json_decode($content, true);
// On renvoie les deux listes de villes par bin
return new JsonResponse([
'villes_by_bin_001' => $data['villes_by_bin_001'] ?? [],
'villes_by_bin_10' => $data['villes_by_bin_10'] ?? []
]);
}
}

View file

@ -0,0 +1,260 @@
{% extends 'base.html.twig' %}
{% block title %}Histogramme de fraîcheur OSM{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Histogramme de fraîcheur des données OSM</h1>
<h2>Par année</h2>
<canvas id="fraicheurHistogrammeAnnee" width="800" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
<h2>Par trimestre</h2>
<canvas id="fraicheurHistogrammeTrimestre" width="800" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
<h2>Par mois</h2>
<canvas id="fraicheurHistogramme" width="800" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
<h2>Distribution des villes selon le nombre d'habitants par lieu (par pas de 10)</h2>
<canvas id="distributionHabitantsParLieu" width="800" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
<div id="fraicheurMeta" class="mb-3"></div>
<a href="{{ path('admin_fraicheur_calculate') }}" class="btn btn-primary">Régénérer les statistiques</a>
<a href="{{ path('admin_fraicheur_download') }}" class="btn btn-secondary">Télécharger le JSON des lieux</a>
<a href="{{ path('admin_distribution_villes_lieux_par_habitant_download') }}" class="btn btn-secondary">Télécharger le JSON villes/lieux/habitant</a>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
fetch('/admin/fraicheur/download')
.then(r => r.json())
.then(data => {
if(data.error){
document.getElementById('fraicheurMeta').innerHTML = '<div class="alert alert-danger">'+data.error+'</div>';
return;
}
const ctx = document.getElementById('fraicheurHistogramme').getContext('2d');
const labels = Object.keys(data.histogram);
const values = Object.values(data.histogram);
document.getElementById('fraicheurMeta').innerHTML =
'<b>Date de génération :</b> ' + data.generated_at + '<br>' +
'<b>Total lieux :</b> ' + data.total;
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Nombre de lieux modifiés ce mois',
data: values,
backgroundColor: 'rgba(54, 162, 235, 0.7)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
title: { display: true, text: 'Fraîcheur des données OSM (par mois)' }
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de lieux' } },
x: { title: { display: true, text: 'Mois' } }
}
}
});
// Agrégation par trimestre
const trimestreAgg = {};
for(const key in data.histogram){
// key = YYYY-MM
const [year, month] = key.split('-');
const m = parseInt(month, 10);
let trimestre = 1;
if(m >= 4 && m <= 6) trimestre = 2;
else if(m >= 7 && m <= 9) trimestre = 3;
else if(m >= 10 && m <= 12) trimestre = 4;
const trimestreKey = `${year}-T${trimestre}`;
if(!trimestreAgg[trimestreKey]) trimestreAgg[trimestreKey] = 0;
trimestreAgg[trimestreKey] += data.histogram[key];
}
const trimestreLabels = Object.keys(trimestreAgg).sort();
const trimestreValues = trimestreLabels.map(k => trimestreAgg[k]);
const ctxTrim = document.getElementById('fraicheurHistogrammeTrimestre').getContext('2d');
new Chart(ctxTrim, {
type: 'bar',
data: {
labels: trimestreLabels,
datasets: [{
label: 'Nombre de lieux modifiés ce trimestre',
data: trimestreValues,
backgroundColor: 'rgba(255, 159, 64, 0.7)',
borderColor: 'rgba(255, 159, 64, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
title: { display: true, text: 'Fraîcheur des données OSM (par trimestre)' }
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de lieux' } },
x: { title: { display: true, text: 'Trimestre' } }
}
}
});
// Agrégation par année
const anneeAgg = {};
for(const key in data.histogram){
// key = YYYY-MM
const [year] = key.split('-');
if(!anneeAgg[year]) anneeAgg[year] = 0;
anneeAgg[year] += data.histogram[key];
}
const anneeLabels = Object.keys(anneeAgg).sort();
const anneeValues = anneeLabels.map(k => anneeAgg[k]);
const ctxAnnee = document.getElementById('fraicheurHistogrammeAnnee').getContext('2d');
new Chart(ctxAnnee, {
type: 'bar',
data: {
labels: anneeLabels,
datasets: [{
label: 'Nombre de lieux modifiés cette année',
data: anneeValues,
backgroundColor: 'rgba(75, 192, 192, 0.7)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
title: { display: true, text: 'Fraîcheur des données OSM (par année)' }
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de lieux' } },
x: { title: { display: true, text: 'Année' } }
}
}
});
// Histogramme distribution villes/lieux/habitant
fetch('/admin/distribution_villes_lieux_par_habitant_download')
.then(r => r.json())
.then(data => {
if(data.error){
document.getElementById('distributionVillesLieuxParHabitant').insertAdjacentHTML('afterend', '<div class="alert alert-danger">Erreur de chargement des données : '+data.error+'</div>');
return;
}
// Histogramme habitants par lieu (pas de 10)
fetch('/admin/distribution_villes_lieux_par_habitant_villes')
.then(r2 => r2.json())
.then(villesData => {
// Histogramme habitants par lieu
if(!data.histogram_10){
document.getElementById('distributionHabitantsParLieu').insertAdjacentHTML('afterend', '<div class="alert alert-danger">Erreur : données histogram_10 absentes. Cliquez sur "Régénérer les statistiques".</div>');
return;
}
const ctxHPL = document.getElementById('distributionHabitantsParLieu').getContext('2d');
const labelsHPL = Object.keys(data.histogram_10);
const valuesHPL = Object.values(data.histogram_10);
const villesByBin10 = villesData.villes_by_bin_10 || {};
console.log('HPL', labelsHPL, villesByBin10);
new Chart(ctxHPL, {
type: 'bar',
data: {
labels: labelsHPL,
datasets: [{
label: "Nombre de villes",
data: valuesHPL,
backgroundColor: 'rgba(255, 99, 132, 0.7)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
title: { display: true, text: "Distribution des villes par habitants/lieu (par pas de 10)" },
tooltip: {
callbacks: {
afterBody: function(context) {
const bin = context[0].label;
const villes = villesByBin10[bin] || [];
if(villes.length > 0){
return ['Villes :'].concat(villes.map(v => '• ' + v));
}
return [];
}
}
}
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de villes' } },
x: { title: { display: true, text: 'Habitants par lieu (arrondi à 10)' } }
}
}
});
// Histogramme lieux/habitant (pas de 0.01)
if(!data.histogram_001){
document.getElementById('distributionVillesLieuxParHabitant').insertAdjacentHTML('afterend', '<div class="alert alert-danger">Erreur : données histogram_001 absentes. Cliquez sur "Régénérer les statistiques".</div>');
return;
}
const ctx = document.getElementById('distributionVillesLieuxParHabitant').getContext('2d');
const labels = Object.keys(data.histogram_001);
const values = Object.values(data.histogram_001);
const villesByBin001 = villesData.villes_by_bin_001 || {};
console.log('LPH', labels, villesByBin001);
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: "Nombre de villes",
data: values,
backgroundColor: 'rgba(153, 102, 255, 0.7)',
borderColor: 'rgba(153, 102, 255, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
title: { display: true, text: "Distribution des villes par lieux/habitant (par pas de 0,01)" },
tooltip: {
callbacks: {
afterBody: function(context) {
const bin = context[0].label;
const villes = villesByBin001[bin] || [];
if(villes.length > 0){
return ['Villes :'].concat(villes.map(v => '• ' + v));
}
return [];
}
}
}
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de villes' } },
x: { title: { display: true, text: 'Lieux par habitant (arrondi à 0,01)' } }
}
}
});
});
});
});
});
</script>
{% endblock %}

View file

@ -513,6 +513,7 @@
x: { title: { display: true, text: 'Trimestre' } } x: { title: { display: true, text: 'Trimestre' } }
} }
} }
}); });
} else if (modifCanvas) { } else if (modifCanvas) {
modifCanvas.parentNode.innerHTML = '<div class="alert alert-info">Aucune donnée de modification disponible pour cette ville.</div>'; modifCanvas.parentNode.innerHTML = '<div class="alert alert-info">Aucune donnée de modification disponible pour cette ville.</div>';

View file

@ -100,6 +100,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row">
<div class="col-12">
<p class="mb-2">
<a href="https://osm-commerces.cipherbliss.com/api/v1/stats_geojson" target="_blank">Documentation de l'API (GeoJSON)</a>
</p>
</div>
</div>
</div> </div>
</footer> </footer>

View file

@ -35,6 +35,12 @@
<i class="bi bi-clock-fill"></i> <i class="bi bi-clock-fill"></i>
{{ 'display.latest_changes'|trans }}</a> {{ 'display.latest_changes'|trans }}</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{{ path('admin_fraicheur_histogramme') }}">
<i class="bi bi-clock-history"></i>
Fraîcheur de la donnée
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>