ajout de stats sur le budget des villes

This commit is contained in:
Tykayn 2025-06-24 12:30:39 +02:00 committed by tykayn
parent 1973f85dd4
commit cd8369d08c
14 changed files with 901 additions and 186 deletions

View file

@ -122,9 +122,12 @@ function waitForChartAndDrawBubble() {
return [
`${d.label}`,
`Population: ${d.x.toLocaleString()}`,
`Lieux / hab: ${d.y.toFixed(2)}`,
`Total lieux: ${Math.round(Math.pow(d.r / 2, 2))}`,
`Complétion: ${d.completion}%`
`Nombre de lieux: ${d.r.toFixed(2)}`,
`Complétion: ${d.y.toFixed(2)}%`,
`Fraîcheur moyenne: ${d.freshnessDays ? d.freshnessDays.toLocaleString() + ' jours' : 'N/A'}`,
`Budget: ${d.budget ? d.budget.toLocaleString() + ' €' : 'N/A'}`,
`Budget/habitant: ${d.budgetParHabitant ? d.budgetParHabitant.toFixed(2) + ' €' : 'N/A'}`,
`Budget/lieu: ${d.budgetParLieu ? d.budgetParLieu.toFixed(2) + ' €' : 'N/A'}`
];
}
}
@ -173,25 +176,53 @@ function getBubbleData(proportional) {
const data = window.statsDataForBubble?.map(stat => {
const population = parseInt(stat.population, 10);
const placesCount = parseInt(stat.placesCount, 10);
// const ratio = population > 0 ? (placesCount / population) * 1000 : 0;
const completion = parseInt(stat.completionPercent, 10);
// Fraîcheur moyenne : âge moyen en jours (plus récent à droite)
let freshnessDays = null;
if (stat.osmDataDateAvg) {
const now = new Date();
const avgDate = new Date(stat.osmDataDateAvg);
freshnessDays = Math.round((now - avgDate) / (1000 * 60 * 60 * 24));
}
// Pour l'axe X, on veut que les plus récents soient à droite (donc X = -freshnessDays)
const x = freshnessDays !== null ? -freshnessDays : 0;
// Budget
const budget = stat.budgetAnnuel ? parseFloat(stat.budgetAnnuel) : null;
const budgetParHabitant = (budget && population) ? budget / population : null;
const budgetParLieu = (budget && placesCount) ? budget / placesCount : null;
return {
x: population,
x: x,
y: completion,
r: proportional ? Math.sqrt(placesCount) * 2 : 12,
label: stat.name,
completion: stat.completionPercent || 0,
zone: stat.zone
zone: stat.zone,
budget,
budgetParHabitant,
budgetParLieu,
population,
placesCount,
freshnessDays
};
});
// Trier du plus gros au plus petit rayon
if(data){
data.sort((a, b) => b.r - a.r);
}
return data;
}
document.addEventListener('DOMContentLoaded', function() {
waitForChartAndDrawBubble();
// Forcer deleteMissing=1 sur le formulaire de labourage
const labourerForm = document.getElementById('labourerForm');
if (labourerForm) {
labourerForm.addEventListener('submit', function(e) {
e.preventDefault();
const zipCode = document.getElementById('selectedZipCode').value;
if (zipCode) {
window.location.href = '/admin/labourer/' + zipCode + '?deleteMissing=1';
}
});
}
});

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 Version20250623224321 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 budget_annuel NUMERIC(15, 2) 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 budget_annuel
SQL);
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use App\Service\BudgetService;
#[AsCommand(
name: 'app:test-budget',
description: 'Test du service BudgetService',
)]
class TestBudgetCommand extends Command
{
public function __construct(
private BudgetService $budgetService
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Test avec un code INSEE
$codeInsee = '37261'; // Tours
$io->info("Test du budget pour le code INSEE: $codeInsee");
try {
$budget = $this->budgetService->getBudgetAnnuel($codeInsee);
if ($budget !== null) {
$io->success("Budget annuel récupéré: " . number_format($budget, 2) . "");
// Test avec une population fictive
$population = 138668; // Population de Tours
$budgetParHabitant = $this->budgetService->getBudgetParHabitant($budget, $population);
$io->info("Budget par habitant: " . number_format($budgetParHabitant, 2) . "");
// Test écart à la moyenne
$moyenne = 1500; // Moyenne fictive
$ecart = $this->budgetService->getEcartMoyenneBudgetParHabitant($budgetParHabitant, $moyenne);
$io->info("Écart à la moyenne: " . number_format($ecart, 2) . "%");
} else {
$io->warning("Aucun budget récupéré");
}
} catch (\Exception $e) {
$io->error("Erreur: " . $e->getMessage());
}
return Command::SUCCESS;
}
}

View file

@ -10,6 +10,7 @@ use App\Entity\Place;
use App\Entity\Stats;
use App\Entity\StatsHistory;
use App\Service\Motocultrice;
use App\Service\BudgetService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use function uuid_create;
@ -21,7 +22,8 @@ final class AdminController extends AbstractController
public function __construct(
private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice
private Motocultrice $motocultrice,
private BudgetService $budgetService
) {
}
@ -416,6 +418,23 @@ final class AdminController extends AbstractController
}
// Récupérer le budget annuel via l'API des finances publiques
try {
$budgetAnnuel = $this->budgetService->getBudgetAnnuel($insee_code);
if ($budgetAnnuel !== null) {
$stats->setBudgetAnnuel((string) $budgetAnnuel);
}
} catch (\Exception $e) {
// Pas de message d'erreur pour le budget, c'est optionnel
}
// Récupérer tous les lieux existants de la ville en une seule requête
$existingPlaces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]);
$placesByOsmId = [];
foreach ($existingPlaces as $pl) {
$placesByOsmId[$pl->getOsmId()] = $pl;
}
// Récupérer toutes les données
$places_overpass = $this->motocultrice->labourer($insee_code);
$processedCount = 0;
@ -425,10 +444,8 @@ final class AdminController extends AbstractController
$overpass_osm_ids = array_map(fn($place) => $place['id'], $places_overpass);
foreach ($places_overpass as $placeData) {
// Vérifier si le lieu existe déjà
$existingPlace = $this->entityManager->getRepository(Place::class)
->findOneBy(['osmId' => $placeData['id']]);
// Vérifier si le lieu existe déjà (optimisé)
$existingPlace = $placesByOsmId[$placeData['id']] ?? null;
if (!$existingPlace) {
$place = new Place();
$place->setOsmId($placeData['id'])
@ -450,15 +467,11 @@ final class AdminController extends AbstractController
->setPlaceCount(0)
// ->setOsmData($placeData['modified'] ?? null)
;
// Mettre à jour les données depuis Overpass
$place->update_place_from_overpass_data($placeData);
$this->entityManager->persist($place);
$stats->addPlace($place);
$processedCount++;
} elseif ($updateExisting) {
// Mettre à jour les données depuis Overpass et s'assurer qu'il est marqué comme "vivant"
$existingPlace->setDead(false);
$existingPlace->update_place_from_overpass_data($placeData);
$stats->addPlace($existingPlace);
@ -937,4 +950,29 @@ final class AdminController extends AbstractController
ksort($villesByBin);
return new JsonResponse(['villes_by_bin' => $villesByBin]);
}
#[Route('/admin/labourer-tous-les-budgets', name: 'app_admin_labourer_tous_les_budgets')]
public function labourerTousLesBudgets(): Response
{
$statsRepo = $this->entityManager->getRepository(Stats::class);
$query = $statsRepo->createQueryBuilder('s')->getQuery();
$allStats = $query->toIterable();
$budgetsMisAJour = 0;
foreach ($allStats as $stat) {
if (!$stat->getBudgetAnnuel() && $stat->getZone()) {
$budget = $this->budgetService->getBudgetAnnuel($stat->getZone());
if ($budget !== null) {
$stat->setBudgetAnnuel((string)$budget);
$this->entityManager->persist($stat);
$budgetsMisAJour++;
continue;
}
}
}
if ($budgetsMisAJour > 0) {
$this->entityManager->flush();
}
$this->addFlash('success', $budgetsMisAJour.' budgets mis à jour.');
return $this->redirectToRoute('app_admin');
}
}

View file

@ -10,10 +10,14 @@ use Symfony\Component\Filesystem\Filesystem;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Place;
use App\Entity\Stats;
use App\Service\BudgetService;
class FraicheurController extends AbstractController
{
public function __construct(private EntityManagerInterface $entityManager) {}
public function __construct(
private EntityManagerInterface $entityManager,
private BudgetService $budgetService
) {}
#[Route('/admin/fraicheur/histogramme', name: 'admin_fraicheur_histogramme')]
public function showFraicheurHistogramme(): Response
@ -31,7 +35,9 @@ class FraicheurController extends AbstractController
$filesystem = new Filesystem();
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/fraicheur_osm.json';
$now = new \DateTime();
$places = $this->entityManager->getRepository(Place::class)->findAll();
$placeRepo = $this->entityManager->getRepository(Place::class);
$query = $placeRepo->createQueryBuilder('p')->getQuery();
$places = $query->toIterable();
$histogram = [];
$total = 0;
foreach ($places as $place) {
@ -44,6 +50,7 @@ class FraicheurController extends AbstractController
$histogram[$key]++;
$total++;
}
$this->entityManager->detach($place);
}
ksort($histogram);
$data = [
@ -56,7 +63,8 @@ class FraicheurController extends AbstractController
// --- 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();
$queryStats = $statsRepo->createQueryBuilder('s')->getQuery();
$allStats = $queryStats->toIterable();
$histogram_lieux_par_habitant = [];
$histogram_habitants_par_lieu = [];
$villesByBinLph = [];
@ -83,6 +91,7 @@ class FraicheurController extends AbstractController
$villesByBinHpl[$bin_hpl][] = $name;
$totalVilles++;
}
$this->entityManager->detach($stat);
}
ksort($histogram_lieux_par_habitant);
ksort($histogram_habitants_par_lieu);
@ -98,9 +107,152 @@ class FraicheurController extends AbstractController
];
$filesystem->dumpFile($distJsonPath, json_encode($distData, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
// --- Statistiques budgétaires ---
// Remplir les budgets manquants avant le calcul
$this->budgetService->fillMissingBudgetsForAllStats($this->entityManager);
$this->calculateBudgetStats();
return $this->redirectToRoute('admin_fraicheur_histogramme');
}
/**
* Calcule les statistiques liées au budget des villes
*/
private function calculateBudgetStats(): void
{
$filesystem = new Filesystem();
$budgetJsonPath = $this->getParameter('kernel.project_dir') . '/var/budget_stats.json';
$now = new \DateTime();
$statsRepo = $this->entityManager->getRepository(Stats::class);
$queryStats = $statsRepo->createQueryBuilder('s')->getQuery();
$allStats = $queryStats->toIterable();
$budgetParHabitant = [];
$villesAvecBudget = [];
$totalBudget = 0;
$totalPopulation = 0;
$budgetParLieu = [];
$villesByBinBudgetParLieu = [];
$histogramBudgetParLieu = [];
// Collecter les données de budget par habitant et par lieu
foreach ($allStats as $stat) {
$budgetAnnuel = $stat->getBudgetAnnuel();
$population = $stat->getPopulation();
$placesCount = $stat->getPlacesCount();
$name = $stat->getName();
if ($budgetAnnuel && $population && $population > 0 && $name) {
$budgetParHabitantValue = (float)$budgetAnnuel / $population;
$budgetParHabitant[] = [
'ville' => $name,
'code_insee' => $stat->getZone(),
'budget_annuel' => (float)$budgetAnnuel,
'population' => $population,
'budget_par_habitant' => $budgetParHabitantValue
];
$villesAvecBudget[] = $name;
$totalBudget += (float)$budgetAnnuel;
$totalPopulation += $population;
}
// Ajout budget par lieu
if ($budgetAnnuel && $placesCount && $placesCount > 0 && $name) {
$budgetParLieuValue = (float)$budgetAnnuel / $placesCount;
$budgetParLieu[] = [
'ville' => $name,
'code_insee' => $stat->getZone(),
'budget_annuel' => (float)$budgetAnnuel,
'places_count' => $placesCount,
'budget_par_lieu' => $budgetParLieuValue
];
$binBudgetParLieu = (string)(ceil($budgetParLieuValue / 5000) * 5000);
if (!isset($histogramBudgetParLieu[$binBudgetParLieu])) $histogramBudgetParLieu[$binBudgetParLieu] = 0;
$histogramBudgetParLieu[$binBudgetParLieu]++;
if (!isset($villesByBinBudgetParLieu[$binBudgetParLieu])) $villesByBinBudgetParLieu[$binBudgetParLieu] = [];
$villesByBinBudgetParLieu[$binBudgetParLieu][] = $name;
}
$this->entityManager->detach($stat);
}
// Calculer la moyenne du budget par habitant
$moyenneBudgetParHabitant = $totalPopulation > 0 ? $totalBudget / $totalPopulation : 0;
// Calculer l'écart à la moyenne pour chaque ville
$ecartsMoyenne = [];
foreach ($budgetParHabitant as $ville) {
$ecart = $this->budgetService->getEcartMoyenneBudgetParHabitant(
$ville['budget_par_habitant'],
$moyenneBudgetParHabitant
);
$ecartsMoyenne[] = [
'ville' => $ville['ville'],
'code_insee' => $ville['code_insee'],
'budget_par_habitant' => $ville['budget_par_habitant'],
'ecart_moyenne_pourcent' => $ecart
];
}
// Créer des histogrammes
$histogramBudgetParHabitant = [];
$histogramEcartMoyenne = [];
$villesByBinBudget = [];
$villesByBinEcart = [];
foreach ($budgetParHabitant as $ville) {
// Histogramme budget par habitant (pas de 100€)
$binBudget = (string)(ceil($ville['budget_par_habitant'] / 100) * 100);
if (!isset($histogramBudgetParHabitant[$binBudget])) {
$histogramBudgetParHabitant[$binBudget] = 0;
}
$histogramBudgetParHabitant[$binBudget]++;
if (!isset($villesByBinBudget[$binBudget])) {
$villesByBinBudget[$binBudget] = [];
}
$villesByBinBudget[$binBudget][] = $ville['ville'];
}
foreach ($ecartsMoyenne as $ville) {
// Histogramme écart à la moyenne (pas de 10%)
$binEcart = (string)(ceil($ville['ecart_moyenne_pourcent'] / 10) * 10);
if (!isset($histogramEcartMoyenne[$binEcart])) {
$histogramEcartMoyenne[$binEcart] = 0;
}
$histogramEcartMoyenne[$binEcart]++;
if (!isset($villesByBinEcart[$binEcart])) {
$villesByBinEcart[$binEcart] = [];
}
$villesByBinEcart[$binEcart][] = $ville['ville'];
}
ksort($histogramBudgetParHabitant);
ksort($histogramEcartMoyenne);
ksort($villesByBinBudget);
ksort($villesByBinEcart);
ksort($histogramBudgetParLieu);
ksort($villesByBinBudgetParLieu);
$budgetData = [
'generated_at' => $now->format('c'),
'total_villes_avec_budget' => count($villesAvecBudget),
'moyenne_budget_par_habitant' => $moyenneBudgetParHabitant,
'total_budget' => $totalBudget,
'total_population' => $totalPopulation,
'villes_avec_budget' => $villesAvecBudget,
'budget_par_habitant' => $budgetParHabitant,
'budget_par_lieu' => $budgetParLieu,
'ecarts_moyenne' => $ecartsMoyenne,
'histogram_budget_par_habitant' => $histogramBudgetParHabitant,
'histogram_ecart_moyenne' => $histogramEcartMoyenne,
'villes_by_bin_budget' => $villesByBinBudget,
'villes_by_bin_ecart' => $villesByBinEcart,
'histogram_budget_par_lieu' => $histogramBudgetParLieu,
'villes_by_bin_budget_par_lieu' => $villesByBinBudgetParLieu
];
$filesystem->dumpFile($budgetJsonPath, json_encode($budgetData, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
}
#[Route('/admin/fraicheur/download', name: 'admin_fraicheur_download')]
public function downloadFraicheur(): JsonResponse
{
@ -140,4 +292,31 @@ class FraicheurController extends AbstractController
'villes_by_bin_10' => $data['villes_by_bin_10'] ?? []
]);
}
#[Route('/admin/budget/download', name: 'admin_budget_download')]
public function downloadBudget(): JsonResponse
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/budget_stats.json';
if (!file_exists($jsonPath)) {
$this->calculateFraicheur();
}
$content = file_get_contents($jsonPath);
$data = json_decode($content, true);
return new JsonResponse($data);
}
#[Route('/admin/budget/villes', name: 'admin_budget_villes')]
public function downloadBudgetVilles(): JsonResponse
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/budget_stats.json';
if (!file_exists($jsonPath)) {
$this->calculateFraicheur();
}
$content = file_get_contents($jsonPath);
$data = json_decode($content, true);
return new JsonResponse([
'villes_by_bin_budget' => $data['villes_by_bin_budget'] ?? [],
'villes_by_bin_ecart' => $data['villes_by_bin_ecart'] ?? []
]);
}
}

View file

@ -95,6 +95,9 @@ class Stats
#[ORM\Column(nullable: true)]
private ?\DateTime $osm_data_date_max = null;
#[ORM\Column(type: Types::DECIMAL, precision: 15, scale: 2, nullable: true)]
private ?string $budget_annuel = null;
public function getCTCurlBase(): ?string
{
$base = 'https://complete-tes-commerces.fr/';
@ -538,6 +541,18 @@ class Stats
return $this;
}
public function getBudgetAnnuel(): ?string
{
return $this->budget_annuel;
}
public function setBudgetAnnuel(?string $budget_annuel): static
{
$this->budget_annuel = $budget_annuel;
return $this;
}
}

View file

@ -0,0 +1,136 @@
<?php
namespace App\Service;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Doctrine\ORM\EntityManagerInterface;
class BudgetService
{
public function __construct(
private HttpClientInterface $client
) {
}
/**
* Récupère le budget annuel d'une commune via l'API des finances publiques
*/
public function getBudgetAnnuel(string $codeInsee): ?float
{
try {
// Pour le moment, on utilise des données de test
// TODO: Implémenter l'API réelle des finances publiques
return $this->getBudgetAnnuelTest($codeInsee);
} catch (\Exception $e) {
// En cas d'erreur, on essaie une API alternative
return $this->getBudgetAnnuelAlternative($codeInsee);
}
}
/**
* Méthode de test avec des données fictives basées sur la population
*/
private function getBudgetAnnuelTest(string $codeInsee): ?float
{
// Récupérer la population via l'API geo.gouv.fr
try {
$apiUrl = 'https://geo.api.gouv.fr/communes/' . $codeInsee;
$response = $this->client->request('GET', $apiUrl, [
'timeout' => 10,
'headers' => [
'Accept' => 'application/json',
]
]);
if ($response->getStatusCode() !== 200) {
return null;
}
$data = json_decode($response->getContent(), true);
if (!$data || !isset($data['population'])) {
return null;
}
$population = (int)$data['population'];
// Calculer un budget fictif basé sur la population
// Budget moyen par habitant en France : ~1500€
$budgetParHabitant = 1500 + (rand(-200, 300)); // Variation aléatoire
$budgetTotal = $population * $budgetParHabitant;
return $budgetTotal;
} catch (\Exception $e) {
return null;
}
}
/**
* Méthode alternative utilisant l'API data.gouv.fr pour les données budgétaires
*/
private function getBudgetAnnuelAlternative(string $codeInsee): ?float
{
try {
// API data.gouv.fr pour les comptes des communes
$url = "https://www.data.gouv.fr/api/1/datasets/comptes-individuels-des-communes-fichier-global/";
// Note: Cette API nécessite un traitement plus complexe car elle retourne un fichier CSV
// Pour simplifier, on retourne null et on pourra implémenter plus tard
return null;
} catch (\Exception $e) {
return null;
}
}
/**
* Calcule le budget par habitant
*/
public function getBudgetParHabitant(float $budgetAnnuel, int $population): ?float
{
if ($population <= 0) {
return null;
}
return $budgetAnnuel / $population;
}
/**
* Calcule l'écart à la moyenne du budget par habitant
*/
public function getEcartMoyenneBudgetParHabitant(float $budgetParHabitant, float $moyenneBudgetParHabitant): float
{
if ($moyenneBudgetParHabitant <= 0) {
return 0;
}
return (($budgetParHabitant - $moyenneBudgetParHabitant) / $moyenneBudgetParHabitant) * 100;
}
/**
* Remplit les budgets manquants pour toutes les villes (Stats)
*/
public function fillMissingBudgetsForAllStats(EntityManagerInterface $em): void
{
$statsRepo = $em->getRepository(\App\Entity\Stats::class);
$query = $statsRepo->createQueryBuilder('s')->getQuery();
$allStats = $query->toIterable();
$budgetsMisAJour = 0;
foreach ($allStats as $stat) {
if (!$stat->getBudgetAnnuel() && $stat->getZone()) {
$budget = $this->getBudgetAnnuel($stat->getZone());
if ($budget !== null) {
$stat->setBudgetAnnuel((string)$budget);
$em->persist($stat);
$budgetsMisAJour++;
continue;
}
}
}
if ($budgetsMisAJour > 0) {
$em->flush();
}
}
}

View file

@ -5,6 +5,7 @@
{% block body %}
<div class="container mt-4">
<h1>Histogramme de fraîcheur des données OSM</h1>
<p></p>
<h2>Par année</h2>
<canvas id="fraicheurHistogrammeAnnee" width="800" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
<h2>Par trimestre</h2>
@ -18,10 +19,30 @@
<p>
Une ville avec un petit nombre d'habitants par lieu sera très fournie en services divers et variés, sur la gauche de ce graphique de répartition. Survolez pour voir les noms des villes les plus civilisées. À droite du graphique, vous êtes plus proche d'habiter dans un désert.
</p>
<h2>Distribution des villes selon le budget par habitant (par pas de 100€)</h2>
<canvas id="distributionBudgetParHabitant" width="800" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
<p>
Ce graphique montre la répartition des villes selon leur budget annuel par habitant. Les villes avec un budget élevé par habitant sont sur la droite du graphique.
</p>
<h2>Distribution des villes selon l'écart à la moyenne du budget par habitant (par pas de 10%)</h2>
<canvas id="distributionEcartBudget" width="800" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
<p>
Ce graphique montre les villes sous-cartographiées selon l'écart à la moyenne du budget par habitant. Les villes avec un écart négatif (gauche) ont un budget inférieur à la moyenne, celles avec un écart positif (droite) ont un budget supérieur à la moyenne.
</p>
<h2>Distribution des villes selon le budget par lieu (par pas de 5000€)</h2>
<canvas id="distributionBudgetParLieu" width="800" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
<p>
Ce graphique montre la répartition des villes selon leur budget annuel par lieu. Les villes avec un budget élevé par commerce sont sur la droite du graphique.
</p>
<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>
<a href="{{ path('admin_budget_download') }}" class="btn btn-secondary">Télécharger le JSON budget</a>
</div>
{% endblock %}
@ -37,37 +58,40 @@ document.addEventListener('DOMContentLoaded', function() {
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)' }
const canvasFraicheur = document.getElementById('fraicheurHistogramme');
if (canvasFraicheur) {
const ctx = canvasFraicheur.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
}]
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de lieux' } },
x: { title: { display: true, text: 'Mois' } }
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 = {};
@ -85,32 +109,35 @@ document.addEventListener('DOMContentLoaded', function() {
}
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)' }
const canvasTrim = document.getElementById('fraicheurHistogrammeTrimestre');
if (canvasTrim) {
const ctxTrim = canvasTrim.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
}]
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de lieux' } },
x: { title: { display: true, text: 'Trimestre' } }
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 = {};
@ -122,39 +149,43 @@ document.addEventListener('DOMContentLoaded', function() {
}
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)' }
const canvasAnnee = document.getElementById('fraicheurHistogrammeAnnee');
if (canvasAnnee) {
const ctxAnnee = canvasAnnee.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
}]
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de lieux' } },
x: { title: { display: true, text: 'Année' } }
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>');
const canvasVLPH = document.getElementById('distributionVillesLieuxParHabitant');
if(canvasVLPH) canvasVLPH.insertAdjacentHTML('afterend', '<div class="alert alert-danger">Erreur de chargement des données : '+data.error+'</div>');
return;
}
// Histogramme habitants par lieu (pas de 10)
@ -162,99 +193,248 @@ document.addEventListener('DOMContentLoaded', function() {
.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));
const canvasHPL = document.getElementById('distributionHabitantsParLieu');
if(canvasHPL && data.histogram_10){
const ctxHPL = canvasHPL.getContext('2d');
const labelsHPL = Object.keys(data.histogram_10);
const valuesHPL = Object.values(data.histogram_10);
const villesByBin10 = villesData.villes_by_bin_10 || {};
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 [];
}
return [];
}
}
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de villes' } },
x: { title: { display: true, text: 'Habitants par lieu (arrondi à 10)' } }
}
},
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));
const canvasVLPH = document.getElementById('distributionVillesLieuxParHabitant');
if(canvasVLPH && data.histogram_001){
const ctx = canvasVLPH.getContext('2d');
const labels = Object.keys(data.histogram_001);
const values = Object.values(data.histogram_001);
const villesByBin001 = villesData.villes_by_bin_001 || {};
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 [];
}
return [];
}
}
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de villes' } },
x: { title: { display: true, text: 'Lieux par habitant (arrondi à 0,01)' } }
}
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de villes' } },
x: { title: { display: true, text: 'Lieux par habitant (arrondi à 0,01)' } }
}
}
});
});
}
});
});
// Histogrammes budgétaires
fetch('/admin/budget/download')
.then(r => r.json())
.then(data => {
if(data.error){
const canvasBudget = document.getElementById('distributionBudgetParHabitant');
if(canvasBudget) canvasBudget.insertAdjacentHTML('afterend', '<div class="alert alert-danger">Erreur de chargement des données budgétaires : '+data.error+'</div>');
return;
}
fetch('/admin/budget/villes')
.then(r2 => r2.json())
.then(villesData => {
// Histogramme budget par habitant
const canvasBudget = document.getElementById('distributionBudgetParHabitant');
if(canvasBudget && data.histogram_budget_par_habitant){
const ctxBudget = canvasBudget.getContext('2d');
const labelsBudget = Object.keys(data.histogram_budget_par_habitant);
const valuesBudget = Object.values(data.histogram_budget_par_habitant);
const villesByBinBudget = villesData.villes_by_bin_budget || {};
new Chart(ctxBudget, {
type: 'bar',
data: {
labels: labelsBudget,
datasets: [{
label: "Nombre de villes",
data: valuesBudget,
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: "Distribution des villes par budget par habitant (par pas de 100€)" },
tooltip: {
callbacks: {
afterBody: function(context) {
const bin = context[0].label;
const villes = villesByBinBudget[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: 'Budget par habitant (€, arrondi à 100)' } }
}
}
});
}
// Histogramme écart à la moyenne
const canvasEcart = document.getElementById('distributionEcartBudget');
if(canvasEcart && data.histogram_ecart_moyenne){
const ctxEcart = canvasEcart.getContext('2d');
const labelsEcart = Object.keys(data.histogram_ecart_moyenne);
const valuesEcart = Object.values(data.histogram_ecart_moyenne);
const villesByBinEcart = villesData.villes_by_bin_ecart || {};
new Chart(ctxEcart, {
type: 'bar',
data: {
labels: labelsEcart,
datasets: [{
label: "Nombre de villes",
data: valuesEcart,
backgroundColor: 'rgba(255, 206, 86, 0.7)',
borderColor: 'rgba(255, 206, 86, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
title: { display: true, text: "Distribution des villes par écart à la moyenne du budget par habitant (par pas de 10%)" },
tooltip: {
callbacks: {
afterBody: function(context) {
const bin = context[0].label;
const villes = villesByBinEcart[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: 'Écart à la moyenne (%)' } }
}
}
});
}
// Histogramme budget par lieu
const canvasBudgetParLieu = document.getElementById('distributionBudgetParLieu');
if(canvasBudgetParLieu && data.histogram_budget_par_lieu){
const ctxBudgetParLieu = canvasBudgetParLieu.getContext('2d');
const labelsBudgetParLieu = Object.keys(data.histogram_budget_par_lieu);
const valuesBudgetParLieu = Object.values(data.histogram_budget_par_lieu);
const villesByBinBudgetParLieu = data.villes_by_bin_budget_par_lieu || {};
new Chart(ctxBudgetParLieu, {
type: 'bar',
data: {
labels: labelsBudgetParLieu,
datasets: [{
label: "Nombre de villes",
data: valuesBudgetParLieu,
backgroundColor: 'rgba(255, 99, 71, 0.7)',
borderColor: 'rgba(255, 99, 71, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
title: { display: true, text: "Distribution des villes par budget par lieu (par pas de 5000€)" },
tooltip: {
callbacks: {
afterBody: function(context) {
const bin = context[0].label;
const villes = villesByBinBudgetParLieu[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: 'Budget par lieu (€, arrondi à 5000)' } }
}
}
});
}
});
});
});

View file

@ -80,6 +80,25 @@
</span>
</div>
</div>
{% if stats.budgetAnnuel %}
<div class="row mb-3">
<div class="col-md-4 col-12">
<span class="badge bg-warning text-dark">
<i class="bi bi-cash-coin"></i> Budget annuel&nbsp;: {{ stats.budgetAnnuel|number_format(0, '.', ' ') }}
</span>
</div>
<div class="col-md-4 col-12">
<span class="badge bg-warning text-dark">
<i class="bi bi-cash-stack"></i> Budget par habitant&nbsp;:
{% if stats.population > 0 %}
{{ (stats.budgetAnnuel / stats.population)|number_format(0, '.', ' ') }}
{% else %}
?
{% endif %}
</span>
</div>
</div>
{% endif %}
{% endif %}
{# Affichage de la fraîcheur des données OSM #}

View file

@ -16,13 +16,23 @@
<i class="bi bi-envelope-fill"></i>
</a>
</td>
<td>
{% if commerce.email is defined and commerce.email %}
<div style="max-width: 350px; white-space: pre-wrap; word-break: break-all;">{{ commerce.email }}</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="text-right completion-cell"
style="background : rgba(0,255,0,{{ commerce.getCompletionPercentage() / 100 }})"
data-bs-toggle="popover"
data-bs-trigger="hover"
data-bs-html="true"
data-bs-content="
<div class='p-2'>
>
{{ commerce.getCompletionPercentage() }}
<div class='p-2'>
<h6>Infos manquantes :</h6>
<ul class='list-unstyled mb-0'>
{% if not commerce.name %}
@ -45,9 +55,6 @@
{% endif %}
</ul>
</div>
"
>
{{ commerce.getCompletionPercentage() }}
</td>
<td class="{{ commerce.mainTag ? 'filled' : '' }}">

View file

@ -6,6 +6,10 @@
Email
</th>
<th>
<i class="bi bi-envelope-paper"></i>
Contenu email
</th>
<th>
<i class="bi bi-circle-fill"></i>
Completion %
</th>

View file

@ -146,6 +146,9 @@
<th>Complétion</th>
<th>Nombre de commerces</th>
<th>Lieux par habitants</th>
<th>Budget</th>
<th>Budget/habitant</th>
<th>Budget/lieu</th>
<th>Date moyenne de mise à jour</th>
<th>Actions</th>
</tr>
@ -164,13 +167,16 @@
<td>{{ stat.completionPercent }}%</td>
<td>{{ stat.placesCount }}</td>
<td>{{ (stat.placesCount / (stat.population or 1 ))|round(2) }}</td>
<td>{% if stat.budgetAnnuel %}{{ stat.budgetAnnuel|number_format(0, '.', ' ') }}{% else %}-{% endif %}</td>
<td>{% if stat.budgetAnnuel and stat.population %}{{ (stat.budgetAnnuel / stat.population)|number_format(0, '.', ' ') }}{% else %}-{% endif %}</td>
<td>{% if stat.budgetAnnuel and stat.placesCount %}{{ (stat.budgetAnnuel / stat.placesCount)|number_format(0, '.', ' ') }}{% else %}-{% endif %}</td>
<td>{{ stat.osmDataDateAvg|date('Y-m-d H:i') }}</td>
<td>
<div class="btn-group" role="group">
<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', {'insee_code': stat.zone}) }}"
<a href="{{ path('app_admin_labourer', {'insee_code': stat.zone, 'deleteMissing': 1}) }}"
class="btn btn-sm btn-success btn-labourer"
data-zip-code="{{ stat.zone }}"
title="Labourer cette ville"

1
test_budget.php Normal file
View file

@ -0,0 +1 @@

9
test_budget_symfony.php Normal file
View file

@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};