add history for zone command

This commit is contained in:
Tykayn 2025-11-26 17:28:34 +01:00 committed by tykayn
parent 2b07e6a4c6
commit 45df535a35
12 changed files with 1071 additions and 854 deletions

View file

@ -1,247 +0,0 @@
{
"themes": {
"borne-de-recharge": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "borne-de-recharge",
"nombre_total": 0,
"nombre_avec_operator": 0,
"nombre_avec_capacity": 0,
"pourcentage_completion": 0
},
"borne-incendie": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "borne-incendie",
"nombre_total": 0,
"nombre_avec_ref": 0,
"nombre_avec_colour": 0,
"pourcentage_completion": 0
},
"arbres": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "arbres",
"nombre_total": 0,
"nombre_avec_species": 0,
"nombre_avec_leaf_type": 0,
"pourcentage_completion": 0
},
"defibrillator": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "defibrillator",
"nombre_total": 0,
"nombre_avec_operator": 0,
"nombre_avec_access": 0,
"pourcentage_completion": 0
},
"toilets": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "toilets",
"nombre_total": 0,
"nombre_avec_access": 0,
"nombre_avec_wheelchair": 0,
"pourcentage_completion": 0
},
"bus_stop": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "bus_stop",
"nombre_total": 3,
"nombre_avec_name": 3,
"nombre_avec_shelter": 3,
"pourcentage_completion": 100.0
},
"camera": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "camera",
"nombre_total": 0,
"nombre_avec_operator": 0,
"nombre_avec_surveillance": 0,
"pourcentage_completion": 0
},
"recycling": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "recycling",
"nombre_total": 0,
"nombre_avec_recycling_type": 0,
"nombre_avec_operator": 0,
"pourcentage_completion": 0
},
"substation": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "substation",
"nombre_total": 2,
"nombre_avec_operator": 0,
"nombre_avec_voltage": 0,
"pourcentage_completion": 0.0
},
"laboratory": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "laboratory",
"nombre_total": 0,
"nombre_avec_name": 0,
"nombre_avec_operator": 0,
"pourcentage_completion": 0
},
"school": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "school",
"nombre_total": 1,
"nombre_avec_name": 0,
"nombre_avec_operator": 0,
"pourcentage_completion": 0.0
},
"police": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "police",
"nombre_total": 0,
"nombre_avec_name": 0,
"nombre_avec_operator": 0,
"pourcentage_completion": 0
},
"healthcare": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "healthcare",
"nombre_total": 0,
"nombre_avec_name": 0,
"nombre_avec_healthcare": 0,
"pourcentage_completion": 0
},
"bicycle_parking": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "bicycle_parking",
"nombre_total": 0,
"nombre_avec_capacity": 0,
"nombre_avec_covered": 0,
"pourcentage_completion": 0
},
"advertising_board": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "advertising_board",
"nombre_total": 0,
"nombre_avec_operator": 0,
"pourcentage_completion": 0
},
"building": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "building",
"nombre_total": 620,
"nombre_avec_building": 0,
"nombre_avec_addr:housenumber": 0,
"pourcentage_completion": 0.0
},
"email": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "email",
"nombre_total": 2,
"nombre_avec_email": 0,
"nombre_avec_contact:email": 0,
"pourcentage_completion": 0.0
},
"bench": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "bench",
"nombre_total": 0,
"pourcentage_completion": 0
},
"waste_basket": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "waste_basket",
"nombre_total": 0,
"pourcentage_completion": 0
},
"street_lamp": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "street_lamp",
"nombre_total": 0,
"pourcentage_completion": 0
},
"drinking_water": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "drinking_water",
"nombre_total": 0,
"pourcentage_completion": 0
},
"power_pole": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "power_pole",
"nombre_total": 49,
"pourcentage_completion": 0
},
"manhole": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "manhole",
"nombre_total": 0,
"pourcentage_completion": 0
},
"little_free_library": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "little_free_library",
"nombre_total": 0,
"pourcentage_completion": 0
},
"playground": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "playground",
"nombre_total": 0,
"pourcentage_completion": 0
},
"siret": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "siret",
"nombre_total": 2,
"pourcentage_completion": 0
},
"restaurants": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "restaurants",
"nombre_total": 0,
"nombre_avec_opening_hours": 0,
"nombre_avec_contact:street": 0,
"nombre_avec_contact:housenumber": 0,
"nombre_avec_website": 0,
"nombre_avec_contact:phone": 0,
"pourcentage_completion": 0
},
"rnb": {
"date": "2025-08-21",
"zone": "commune_59140",
"theme": "rnb",
"nombre_total": 0,
"pourcentage_completion": 0
}
},
"metadata": {
"insee_code": "59140",
"creation_date": "2025-08-21 11:12:20",
"polygon_file": "commune_59140.poly",
"osm_data_file": "france-latest.osm.pbf",
"total_objects": 679,
"average_completion": 0.4418262150220913,
"theme_count": 7
}
}

View file

@ -1,247 +0,0 @@
{
"themes": {
"borne-de-recharge": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "borne-de-recharge",
"nombre_total": 0,
"nombre_avec_operator": 0,
"nombre_avec_capacity": 0,
"pourcentage_completion": 0
},
"borne-incendie": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "borne-incendie",
"nombre_total": 14,
"nombre_avec_ref": 0,
"nombre_avec_colour": 0,
"pourcentage_completion": 0
},
"arbres": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "arbres",
"nombre_total": 78,
"nombre_avec_species": 0,
"nombre_avec_leaf_type": 2,
"pourcentage_completion": 1.28
},
"defibrillator": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "defibrillator",
"nombre_total": 0,
"nombre_avec_operator": 0,
"nombre_avec_access": 0,
"pourcentage_completion": 0
},
"toilets": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "toilets",
"nombre_total": 2,
"nombre_avec_access": 0,
"nombre_avec_wheelchair": 1,
"pourcentage_completion": 25.0
},
"bus_stop": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "bus_stop",
"nombre_total": 57,
"nombre_avec_name": 0,
"nombre_avec_shelter": 0,
"pourcentage_completion": 0.0
},
"camera": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "camera",
"nombre_total": 3,
"nombre_avec_operator": 0,
"nombre_avec_surveillance": 0,
"pourcentage_completion": 0.0
},
"recycling": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "recycling",
"nombre_total": 199,
"nombre_avec_recycling_type": 0,
"nombre_avec_operator": 0,
"pourcentage_completion": 0.0
},
"substation": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "substation",
"nombre_total": 1,
"nombre_avec_operator": 0,
"nombre_avec_voltage": 0,
"pourcentage_completion": 0.0
},
"laboratory": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "laboratory",
"nombre_total": 1,
"nombre_avec_name": 0,
"nombre_avec_operator": 0,
"pourcentage_completion": 0.0
},
"school": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "school",
"nombre_total": 15,
"nombre_avec_name": 0,
"nombre_avec_operator": 0,
"pourcentage_completion": 0.0
},
"police": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "police",
"nombre_total": 1,
"nombre_avec_name": 0,
"nombre_avec_operator": 0,
"pourcentage_completion": 0.0
},
"healthcare": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "healthcare",
"nombre_total": 7,
"nombre_avec_name": 0,
"nombre_avec_healthcare": 0,
"pourcentage_completion": 0.0
},
"bicycle_parking": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "bicycle_parking",
"nombre_total": 5,
"nombre_avec_capacity": 0,
"nombre_avec_covered": 0,
"pourcentage_completion": 0.0
},
"advertising_board": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "advertising_board",
"nombre_total": 0,
"nombre_avec_operator": 0,
"pourcentage_completion": 0
},
"building": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "building",
"nombre_total": 2713,
"nombre_avec_building": 0,
"nombre_avec_addr:housenumber": 0,
"pourcentage_completion": 0.0
},
"email": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "email",
"nombre_total": 1,
"nombre_avec_email": 0,
"nombre_avec_contact:email": 0,
"pourcentage_completion": 0.0
},
"bench": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "bench",
"nombre_total": 26,
"pourcentage_completion": 0
},
"waste_basket": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "waste_basket",
"nombre_total": 12,
"pourcentage_completion": 0
},
"street_lamp": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "street_lamp",
"nombre_total": 1,
"pourcentage_completion": 0
},
"drinking_water": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "drinking_water",
"nombre_total": 1,
"pourcentage_completion": 0
},
"power_pole": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "power_pole",
"nombre_total": 0,
"pourcentage_completion": 0
},
"manhole": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "manhole",
"nombre_total": 0,
"pourcentage_completion": 0
},
"little_free_library": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "little_free_library",
"nombre_total": 2,
"pourcentage_completion": 0
},
"playground": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "playground",
"nombre_total": 12,
"pourcentage_completion": 0
},
"siret": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "siret",
"nombre_total": 6,
"pourcentage_completion": 0
},
"restaurants": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "restaurants",
"nombre_total": 4,
"nombre_avec_opening_hours": 0,
"nombre_avec_contact:street": 0,
"nombre_avec_contact:housenumber": 0,
"nombre_avec_website": 0,
"nombre_avec_contact:phone": 0,
"pourcentage_completion": 0.0
},
"rnb": {
"date": "2025-08-21",
"zone": "commune_78123",
"theme": "rnb",
"nombre_total": 0,
"pourcentage_completion": 0
}
},
"metadata": {
"insee_code": "78123",
"creation_date": "2025-08-21 11:15:23",
"polygon_file": "commune_78123.poly",
"osm_data_file": "france-latest.osm.pbf",
"total_objects": 3161,
"average_completion": 0.04740272065801961,
"theme_count": 22
}
}

View file

@ -1,247 +0,0 @@
{
"themes": {
"borne-de-recharge": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "borne-de-recharge",
"nombre_total": 12,
"nombre_avec_operator": 0,
"nombre_avec_capacity": 0,
"pourcentage_completion": 0
},
"borne-incendie": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "borne-incendie",
"nombre_total": 46,
"nombre_avec_ref": 12,
"nombre_avec_colour": 7,
"pourcentage_completion": 20.65
},
"arbres": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "arbres",
"nombre_total": 2317,
"nombre_avec_species": 18,
"nombre_avec_leaf_type": 1648,
"pourcentage_completion": 35.95
},
"defibrillator": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "defibrillator",
"nombre_total": 13,
"nombre_avec_operator": 0,
"nombre_avec_access": 0,
"pourcentage_completion": 0
},
"toilets": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "toilets",
"nombre_total": 4,
"nombre_avec_access": 4,
"nombre_avec_wheelchair": 3,
"pourcentage_completion": 87.5
},
"bus_stop": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "bus_stop",
"nombre_total": 49,
"nombre_avec_name": 0,
"nombre_avec_shelter": 0,
"pourcentage_completion": 0
},
"camera": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "camera",
"nombre_total": 22,
"nombre_avec_operator": 6,
"nombre_avec_surveillance": 12,
"pourcentage_completion": 40.91
},
"recycling": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "recycling",
"nombre_total": 33,
"nombre_avec_recycling_type": 0,
"nombre_avec_operator": 6,
"pourcentage_completion": 9.09
},
"substation": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "substation",
"nombre_total": 59,
"nombre_avec_operator": 6,
"nombre_avec_voltage": 0,
"pourcentage_completion": 5.08
},
"laboratory": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "laboratory",
"nombre_total": 3,
"nombre_avec_name": 0,
"nombre_avec_operator": 6,
"pourcentage_completion": 100.0
},
"school": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "school",
"nombre_total": 17,
"nombre_avec_name": 0,
"nombre_avec_operator": 6,
"pourcentage_completion": 17.65
},
"police": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "police",
"nombre_total": 2,
"nombre_avec_name": 0,
"nombre_avec_operator": 6,
"pourcentage_completion": 150.0
},
"healthcare": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "healthcare",
"nombre_total": 77,
"nombre_avec_name": 0,
"nombre_avec_healthcare": 0,
"pourcentage_completion": 0.0
},
"bicycle_parking": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "bicycle_parking",
"nombre_total": 68,
"nombre_avec_capacity": 0,
"nombre_avec_covered": 0,
"pourcentage_completion": 0.0
},
"advertising_board": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "advertising_board",
"nombre_total": 37,
"nombre_avec_operator": 6,
"pourcentage_completion": 16.22
},
"building": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "building",
"nombre_total": 4399,
"nombre_avec_building": 0,
"nombre_avec_addr:housenumber": 0,
"pourcentage_completion": 0.0
},
"email": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "email",
"nombre_total": 39,
"nombre_avec_email": 0,
"nombre_avec_contact:email": 0,
"pourcentage_completion": 0.0
},
"bench": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "bench",
"nombre_total": 439,
"pourcentage_completion": 0
},
"waste_basket": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "waste_basket",
"nombre_total": 260,
"pourcentage_completion": 0
},
"street_lamp": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "street_lamp",
"nombre_total": 1622,
"pourcentage_completion": 0
},
"drinking_water": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "drinking_water",
"nombre_total": 9,
"pourcentage_completion": 0
},
"power_pole": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "power_pole",
"nombre_total": 0,
"pourcentage_completion": 0
},
"manhole": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "manhole",
"nombre_total": 21,
"pourcentage_completion": 0
},
"little_free_library": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "little_free_library",
"nombre_total": 6,
"pourcentage_completion": 0
},
"playground": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "playground",
"nombre_total": 23,
"pourcentage_completion": 0
},
"siret": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "siret",
"nombre_total": 260,
"pourcentage_completion": 0
},
"restaurants": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "restaurants",
"nombre_total": 40,
"nombre_avec_opening_hours": 0,
"nombre_avec_contact:street": 0,
"nombre_avec_contact:housenumber": 0,
"nombre_avec_website": 0,
"nombre_avec_contact:phone": 0,
"pourcentage_completion": 0.0
},
"rnb": {
"date": "2025-08-21",
"zone": "commune_94016",
"theme": "rnb",
"nombre_total": 0,
"pourcentage_completion": 0
}
},
"metadata": {
"insee_code": "94016",
"creation_date": "2025-08-21 11:16:39",
"polygon_file": "commune_94016.poly",
"osm_data_file": "france-latest.osm.pbf",
"total_objects": 9877,
"average_completion": 8.868679761061053,
"theme_count": 26
}
}

View file

@ -238,9 +238,19 @@ def export_to_geojson(
# Ajouter le code INSEE au nom du fichier si disponible # Ajouter le code INSEE au nom du fichier si disponible
insee_suffix = f"_insee_{insee_code}" if insee_code else "" insee_suffix = f"_insee_{insee_code}" if insee_code else ""
# Slug pour identifier la thématique/filtre principal dans le nom du fichier
theme_slug = (
main_tag_filter.replace("=", "_")
.replace(" ", "_")
.replace(":", "_")
.replace("|", "_")
.replace("&", "_")
)
# Définir le chemin du fichier GeoJSON de sortie # Définir le chemin du fichier GeoJSON de sortie
geojson_file = os.path.join( geojson_file = os.path.join(
output_dir, f"export_{date_str.replace(':', '_')}__{insee_suffix}.geojson" output_dir,
f"export_{date_str.replace(':', '_')}__{theme_slug}__{insee_suffix}.geojson",
) )
# Vérifier si le fichier GeoJSON existe déjà # Vérifier si le fichier GeoJSON existe déjà
@ -270,8 +280,18 @@ def export_to_geojson(
filter_cmd = f"osmium tags-filter {temp_file} {main_tag_filter} -f osm.pbf -O -o {filtered_file}" filter_cmd = f"osmium tags-filter {temp_file} {main_tag_filter} -f osm.pbf -O -o {filtered_file}"
run_command_cached(filter_cmd) run_command_cached(filter_cmd)
# Exporter vers GeoJSON # Exporter vers GeoJSON en utilisant un fichier de configuration osmium
export_cmd = f"osmium export {filtered_file} -O -o {geojson_file} -f geojson --geometry-types point,linestring,polygon" # (permet d'activer l'export des IDs OSM même si l'option --add-ids n'existe pas)
config_path = os.path.join(os.path.dirname(__file__), "osmium_export_config.json")
if os.path.exists(config_path):
export_cmd = (
f"osmium export -c {config_path} {filtered_file} -O -o {geojson_file}"
)
else:
# Fallback simple sans config si le fichier n'existe pas
export_cmd = (
f"osmium export {filtered_file} -O -o {geojson_file} -f geojson"
)
run_command_cached(export_cmd) run_command_cached(export_cmd)
return geojson_file return geojson_file

View file

@ -0,0 +1,19 @@
{
"output": {
"format": "geojson"
},
"attributes": {
"type": true,
"id": true,
"version": false,
"timestamp": true,
"changeset": true,
"uid": true,
"user": true
},
"tags": {
"all": true
}
}

View file

@ -0,0 +1,395 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use App\Service\FollowUpService;
use App\Service\Motocultrice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:zoneplaces:explore-history',
description: 'Explore l\'historique des objets OSM pour une ville et alimente les ZonePlaces (2010, 2020, 2024, 2025).'
)]
class ExploreZonePlacesHistoryCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice,
private FollowUpService $followUpService
) {
parent::__construct('app:zoneplaces:explore-history');
}
protected function configure(): void
{
$this
->addArgument('insee_code', InputArgument::REQUIRED, 'Code INSEE de la ville (ex: 91111)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$inseeCode = $input->getArgument('insee_code');
$io->title(sprintf('Exploration historique des ZonePlaces pour la ville %s', $inseeCode));
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $inseeCode]);
if (!$stats) {
$io->error(sprintf('Aucune stats trouvée pour le code INSEE %s.', $inseeCode));
return Command::FAILURE;
}
$io->text(sprintf('Ville : %s (%s)', $stats->getName() ?? '(sans nom)', $stats->getZone()));
// 0) Régénérer les GeoJSON de base pour 2010, 2020 et l'année courante
$this->regenerateBaseGeojsonSnapshots($io, $inseeCode);
// 1) Générer les CityFollowUp historiques à partir des GeoJSON déjà présents (si disponibles)
$projectRoot = dirname(__DIR__, 2); // src/Command -> racine projet
$tempDir = $projectRoot . '/counting_osm_objects/temp';
$files = [];
if (is_dir($tempDir)) {
// Nouveau schéma de nommage: export_DATE__THEME__insee_XXXX.geojson
$patternNew = $tempDir . '/export_*__*__insee_' . $inseeCode . '.geojson';
// Ancien schéma (sans thème dans le nom), pour compatibilité éventuelle
$patternOld = $tempDir . '/export_*___insee_' . $inseeCode . '.geojson';
$files = array_merge(glob($patternNew) ?: [], glob($patternOld) ?: []);
}
if (!empty($files)) {
// Construire la liste des dates déjà présentes dans CityFollowUp (pour éviter les doublons)
$existingDates = [];
foreach ($stats->getCityFollowUps() as $fu) {
$key = $fu->getDate()->format('Y-m-d');
$existingDates[$key] = true;
}
// Trier les fichiers par nom (donc par date croissante)
sort($files);
$io->section('Utilisation des exports GeoJSON temporaires pour générer des CityFollowUp historiques et alimenter les ZonePlaces');
// Regrouper les fichiers par date (toutes thématiques confondues)
$snapshots = [];
foreach ($files as $file) {
$basename = basename($file);
// Nouveau format: export_DATE__THEME__insee_XXXX.geojson
if (preg_match('/^export_([^_]+T[^_]+)__.*__insee_' . $inseeCode . '\.geojson$/', $basename, $m)) {
$dateToken = $m[1];
} elseif (preg_match('/^export_(.+)___insee_' . $inseeCode . '\.geojson$/', $basename, $m)) {
// Ancien format de secours
$dateToken = $m[1];
} else {
continue;
}
$iso = str_replace('_', ':', $dateToken); // 2004-01-01T00:00:00Z
try {
$snapshotDate = new \DateTime($iso);
} catch (\Exception $e) {
continue;
}
$dayKey = $snapshotDate->format('Y-m-d');
$content = file_get_contents($file);
if ($content === false) {
continue;
}
$data = json_decode($content, true);
if (!is_array($data) || empty($data['features'])) {
continue;
}
// Initialiser la structure pour ce snapshot si nécessaire
if (!isset($snapshots[$dayKey])) {
$snapshots[$dayKey] = [
'date' => $snapshotDate,
'elements' => [],
];
}
// Convertir les features GeoJSON en éléments façon Overpass (type + id + tags),
// avec dédoublonnage par (type,id) au niveau du snapshot
foreach ($data['features'] as $feature) {
$props = $feature['properties'] ?? [];
$osmId = $props['@id'] ?? null;
$osmType = $props['@type'] ?? 'node';
if ($osmId === null) {
continue;
}
$key = $osmType . '_' . $osmId;
$snapshots[$dayKey]['elements'][$key] = [
'type' => $osmType,
'id' => $osmId,
'tags' => $props,
];
}
}
// Traiter les snapshots dans l'ordre chronologique
ksort($snapshots);
foreach ($snapshots as $dayKey => $snapshot) {
$snapshotDate = $snapshot['date'];
$elements = array_values($snapshot['elements']);
if (empty($elements)) {
continue;
}
$io->text(sprintf(' - Snapshot %s (%d objets uniques)', $dayKey, count($elements)));
// Générer les CityFollowUp historiques pour ce snapshot (count + completion)
// uniquement si aucune mesure n'existe déjà pour ce jour (éviter les doublons)
if (!isset($existingDates[$dayKey])) {
$this->followUpService->generateCityFollowUpsFromElements(
$stats,
$elements,
$this->entityManager,
$snapshotDate
);
$existingDates[$dayKey] = true;
}
// Appliquer ce snapshot aux ZonePlaces pour détecter les créations/suppressions de façon incrémentale
$this->followUpService->updateZonePlacesFromElementsAtDate(
$stats,
$elements,
$this->entityManager,
$snapshotDate
);
}
}
// 2) Si des snapshots historiques ont été rejoués, faire un dernier passage avec Overpass
// pour comparer le dernier snapshot à l'état actuel.
if (!empty($files)) {
$io->section('Mise à jour finale avec Overpass (état actuel vs dernier snapshot historique)');
$this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager, true);
}
// Recharger les stats depuis l'EntityManager pour s'assurer qu'on a les ZonePlaces à jour
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $inseeCode]);
// Calculer les créations et suppressions par année
$deletionsByYear = [];
$creationsByYear = [];
foreach ($stats->getZonePlaces() as $zonePlace) {
$disappeared = $zonePlace->getDisappearedList() ?? [];
$current = $zonePlace->getCurrentList() ?? [];
// Suppressions : basé sur noticed_deleted_date
foreach ($disappeared as $obj) {
if (!empty($obj['noticed_deleted_date'])) {
$year = substr($obj['noticed_deleted_date'], 0, 4);
if (!ctype_digit($year)) {
continue;
}
$deletionsByYear[$year] = ($deletionsByYear[$year] ?? 0) + 1;
}
}
// Créations/modifications : basé sur timestamp (année de dernière modif connue)
foreach ($current as $obj) {
if (!empty($obj['timestamp'])) {
$year = substr($obj['timestamp'], 0, 4);
if (!ctype_digit($year)) {
continue;
}
$creationsByYear[$year] = ($creationsByYear[$year] ?? 0) + 1;
}
}
}
ksort($deletionsByYear);
ksort($creationsByYear);
$io->section('Récapitulatif des créations et suppressions par année');
$rows = [];
$allYears = array_unique(array_merge(array_keys($deletionsByYear), array_keys($creationsByYear)));
sort($allYears);
foreach ($allYears as $year) {
$rows[] = [
$year,
$creationsByYear[$year] ?? 0,
$deletionsByYear[$year] ?? 0,
];
}
if (!empty($rows)) {
$io->table(['Année', 'Créations/modifications (objets actuels)', 'Suppressions détectées'], $rows);
} else {
$io->text('Aucune création/suppression détectée dans les ZonePlaces pour cette ville.');
}
$io->success('Exploration historique terminée, ZonePlaces mises à jour et récapitulatif affiché.');
return Command::SUCCESS;
}
/**
* Régénère les GeoJSON de base (toutes thématiques confondues) pour 2010, 2020
* et l'année courante dans counting_osm_objects/temp, au format:
* export_YYYY-MM-DDT00_00_00Z___insee_XXXXX.geojson
*
* Ces fichiers seront ensuite relus par la commande comme snapshots historiques.
*/
private function regenerateBaseGeojsonSnapshots(SymfonyStyle $io, string $inseeCode): void
{
$projectRoot = dirname(__DIR__, 2);
$baseDir = $projectRoot . '/counting_osm_objects';
$tempDir = $baseDir . '/temp';
$polygonsDir = $baseDir . '/polygons';
$historyPbf = $baseDir . '/osm_data/france-internal.osh.pbf';
if (!file_exists($historyPbf)) {
$io->warning('Fichier d\'historique OSM introuvable, impossible de régénérer les GeoJSON : ' . $historyPbf);
return;
}
if (!is_dir($tempDir) && !mkdir($tempDir, 0775, true) && !is_dir($tempDir)) {
$io->warning('Impossible de créer le répertoire temp pour les GeoJSON : ' . $tempDir);
return;
}
if (!is_dir($polygonsDir) && !mkdir($polygonsDir, 0775, true) && !is_dir($polygonsDir)) {
$io->warning('Impossible de créer le répertoire polygons : ' . $polygonsDir);
return;
}
// Chercher un fichier polygone existant, sinon essayer de le générer via get_poly.py
$polyPath1 = $polygonsDir . '/commune_' . $inseeCode . '.poly';
$polyPath2 = $polygonsDir . '/' . $inseeCode . '.poly';
$polyPath = null;
if (file_exists($polyPath1)) {
$polyPath = $polyPath1;
} elseif (file_exists($polyPath2)) {
$polyPath = $polyPath2;
} else {
$io->section(sprintf('Génération du polygone pour la commune %s', $inseeCode));
$getPolyScript = $baseDir . '/get_poly.py';
if (!file_exists($getPolyScript)) {
$io->warning('Script get_poly.py introuvable, impossible de générer le polygone : ' . $getPolyScript);
return;
}
$cmd = sprintf(
'cd %s && python3 %s %s 2>&1',
escapeshellarg($baseDir),
escapeshellarg($getPolyScript),
escapeshellarg($inseeCode)
);
exec($cmd, $polyOutput, $polyCode);
if ($polyCode !== 0) {
$io->warning("Échec de la génération du polygone pour $inseeCode :\n" . implode("\n", $polyOutput));
return;
}
if (file_exists($polyPath1)) {
$polyPath = $polyPath1;
} elseif (file_exists($polyPath2)) {
$polyPath = $polyPath2;
} else {
$io->warning('Le polygone n\'a pas été trouvé après exécution de get_poly.py.');
return;
}
}
$io->section('Régénération des GeoJSON de base (2010, 2020, année courante)');
// Dates cibles : 2010-01-01, 2020-01-01, 1er janvier de l'année courante
$currentYear = (int)(new \DateTime('now', new \DateTimeZone('UTC')))->format('Y');
$targetDates = [
'2010-01-01T00:00:00Z',
'2020-01-01T00:00:00Z',
sprintf('%d-01-01T00:00:00Z', $currentYear),
];
// Fichier de configuration osmium (pour inclure les IDs OSM dans le GeoJSON)
$osmiumConfig = $baseDir . '/osmium_export_config.json';
foreach ($targetDates as $isoDate) {
$token = str_replace(':', '_', $isoDate);
$geojsonPath = $tempDir . '/export_' . $token . '___insee_' . $inseeCode . '.geojson';
$io->text(sprintf(' - Snapshot %s → %s', $isoDate, basename($geojsonPath)));
// Fichiers temporaires pour cette date
$tmpDir = sys_get_temp_dir();
$snapshotPbf = $tmpDir . '/france_snapshot_' . $inseeCode . '_' . $token . '.osm.pbf';
$cityPbf = $tmpDir . '/osm_zone_' . $inseeCode . '_' . $token . '.osm.pbf';
// 1. Snapshot temporel depuis le fichier d'historique
if (file_exists($snapshotPbf)) {
@unlink($snapshotPbf);
}
$timeFilterCmd = sprintf(
'osmium time-filter %s %s -o %s --overwrite 2>&1',
escapeshellarg($historyPbf),
escapeshellarg($isoDate),
escapeshellarg($snapshotPbf)
);
exec($timeFilterCmd, $timeOut, $timeCode);
if ($timeCode !== 0 || !file_exists($snapshotPbf)) {
$io->warning('Échec du time-filter pour ' . $isoDate . " :\n" . implode("\n", $timeOut));
continue;
}
// 2. Extraction de la ville à partir du snapshot
if (file_exists($cityPbf)) {
@unlink($cityPbf);
}
$extractCmd = sprintf(
'osmium extract -p %s %s -o %s --overwrite 2>&1',
escapeshellarg($polyPath),
escapeshellarg($snapshotPbf),
escapeshellarg($cityPbf)
);
exec($extractCmd, $extractOut, $extractCode);
if ($extractCode !== 0 || !file_exists($cityPbf)) {
$io->warning('Échec de l\'extraction osmium pour ' . $isoDate . " :\n" . implode("\n", $extractOut));
@unlink($snapshotPbf);
continue;
}
// 3. Export GeoJSON
if (file_exists($osmiumConfig)) {
// Utiliser un fichier de config osmium pour inclure les IDs (@id, @type, etc.)
$exportCmd = sprintf(
'osmium export -c %s %s -o %s --overwrite 2>&1',
escapeshellarg($osmiumConfig),
escapeshellarg($cityPbf),
escapeshellarg($geojsonPath)
);
} else {
// Fallback simple sans config
$exportCmd = sprintf(
'osmium export %s -o %s -f geojson --overwrite 2>&1',
escapeshellarg($cityPbf),
escapeshellarg($geojsonPath)
);
}
exec($exportCmd, $exportOut, $exportCode);
if ($exportCode !== 0 || !file_exists($geojsonPath)) {
$io->warning('Échec de l\'export GeoJSON pour ' . $isoDate . " :\n" . implode("\n", $exportOut));
}
// Nettoyage des fichiers temporaires pour cette date
@unlink($snapshotPbf);
@unlink($cityPbf);
}
}
}

View file

@ -151,11 +151,11 @@ class ProcessLabourageQueueCommand extends Command
$stats->setName($apiCityName); $stats->setName($apiCityName);
} }
$io->info('Récupération des followups de cette ville...'); $io->info('Récupération des followups de cette ville (comptages, complétions, ZonePlaces)...');
// $this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager); // Génère les CityFollowUp et met à jour les ZonePlaces en une seule passe Overpass
$this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager, true);
// update completion (basée sur les lieux)
// update completion
$stats->computeCompletionPercent(); $stats->computeCompletionPercent();
$followups = $stats->getCityFollowUps(); $followups = $stats->getCityFollowUps();
if ($followups) { if ($followups) {

View file

@ -1469,7 +1469,7 @@ class PublicController extends AbstractController
} }
#[Route('/city/{insee_code}/zone-places-history', name: 'app_public_zone_places_history', requirements: ['insee_code' => '\d+'])] #[Route('/city/{insee_code}/zone-places-history', name: 'app_public_zone_places_history', requirements: ['insee_code' => '\d+'])]
public function zonePlacesHistory(string $insee_code): Response public function zonePlacesHistory(string $insee_code, Request $request): Response
{ {
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) { if (!$stats) {
@ -1497,12 +1497,36 @@ class PublicController extends AbstractController
$this->followUpService->refreshAllZonePlaces($stats, $this->motocultrice, $this->entityManager); $this->followUpService->refreshAllZonePlaces($stats, $this->motocultrice, $this->entityManager);
} }
// Récupérer tous les ZonePlaces de cette ville // Période d'historique (3 mois par défaut)
$period = $request->query->get('period', '3m'); // 3m, 6m, 12m, all
$validPeriods = ['3m', '6m', '12m', 'all'];
if (!in_array($period, $validPeriods, true)) {
$period = '3m';
}
$now = new \DateTime();
$cutoffDate = null;
switch ($period) {
case '3m':
$cutoffDate = (clone $now)->modify('-3 months');
break;
case '6m':
$cutoffDate = (clone $now)->modify('-6 months');
break;
case '12m':
$cutoffDate = (clone $now)->modify('-12 months');
break;
case 'all':
default:
$cutoffDate = null;
break;
}
// Récupérer tous les ZonePlaces de cette ville (puis filtrer par date)
$zonePlaces = $stats->getZonePlaces(); $zonePlaces = $stats->getZonePlaces();
$changesByDate = []; $changesByDate = [];
$followupLabels = \App\Service\FollowUpService::getFollowUpThemes(); $followupLabels = \App\Service\FollowUpService::getFollowUpThemes();
$followupIcons = \App\Service\FollowUpService::getFollowUpIcons(); $followupIcons = \App\Service\FollowUpService::getFollowUpIcons();
$thirtyDaysAgo = new \DateTime('-30 days');
$uniqueUsers = []; $uniqueUsers = [];
foreach ($zonePlaces as $zp) { foreach ($zonePlaces as $zp) {
@ -1513,7 +1537,21 @@ class PublicController extends AbstractController
if (is_array($disappearedList)) { if (is_array($disappearedList)) {
foreach ($disappearedList as $obj) { foreach ($disappearedList as $obj) {
if (isset($obj['noticed_deleted_date'])) { if (isset($obj['noticed_deleted_date'])) {
$date = substr($obj['noticed_deleted_date'], 0, 10); $dateString = substr($obj['noticed_deleted_date'], 0, 10);
// Filtre par période si nécessaire
if ($cutoffDate !== null) {
try {
$noticedDate = new \DateTime($dateString);
if ($noticedDate < $cutoffDate) {
continue;
}
} catch (\Exception $e) {
// en cas de date invalide, on ignore simplement le filtre
}
}
$date = $dateString;
if (!isset($changesByDate[$date])) { if (!isset($changesByDate[$date])) {
$changesByDate[$date] = ['deletions' => [], 'creations' => []]; $changesByDate[$date] = ['deletions' => [], 'creations' => []];
} }
@ -1530,14 +1568,17 @@ class PublicController extends AbstractController
} }
} }
// Traiter les créations/modifications récentes // Traiter les créations/modifications
$currentList = $zp->getCurrentList() ?? []; $currentList = $zp->getCurrentList() ?? [];
if (is_array($currentList)) { if (is_array($currentList)) {
foreach ($currentList as $obj) { foreach ($currentList as $obj) {
if (isset($obj['timestamp'])) { if (isset($obj['timestamp'])) {
try { try {
$timestamp = new \DateTime($obj['timestamp']); $timestamp = new \DateTime($obj['timestamp']);
if ($timestamp >= $thirtyDaysAgo) { if ($cutoffDate !== null && $timestamp < $cutoffDate) {
continue;
}
$date = $timestamp->format('Y-m-d'); $date = $timestamp->format('Y-m-d');
if (!isset($changesByDate[$date])) { if (!isset($changesByDate[$date])) {
$changesByDate[$date] = ['deletions' => [], 'creations' => []]; $changesByDate[$date] = ['deletions' => [], 'creations' => []];
@ -1551,7 +1592,6 @@ class PublicController extends AbstractController
if (!empty($obj['user'])) { if (!empty($obj['user'])) {
$uniqueUsers[$obj['user']] = true; $uniqueUsers[$obj['user']] = true;
} }
}
} catch (\Exception $e) { } catch (\Exception $e) {
// Ignorer les timestamps invalides // Ignorer les timestamps invalides
} }
@ -1623,6 +1663,7 @@ class PublicController extends AbstractController
'followup_icons' => $followupIcons, 'followup_icons' => $followupIcons,
'unique_users' => $uniqueUsersList, 'unique_users' => $uniqueUsersList,
'chart_data' => $chartData, 'chart_data' => $chartData,
'period' => $period,
]); ]);
} }

View file

@ -15,8 +15,17 @@ class FollowUpService
$insee_code = $stats->getZone(); $insee_code = $stats->getZone();
$elements = $motocultrice->followUpCity($insee_code) ?? []; $elements = $motocultrice->followUpCity($insee_code) ?? [];
$themes = self::getFollowUpThemes(); $themes = self::getFollowUpThemes();
$types = []; $now = new \DateTime();
$persisted = 0;
// Repository pour les ZonePlaces
$zonePlacesRepository = $em->getRepository(ZonePlaces::class);
$completionTags = self::getFollowUpCompletionTags();
foreach ($themes as $type => $label) { foreach ($themes as $type => $label) {
$objects = [];
// Filtrer les objets Overpass par thème (en mémoire, mais un seul thème à la fois)
if ($type === 'fire_hydrant') { if ($type === 'fire_hydrant') {
$objects = array_filter($elements, fn($el) => ($el['tags']['emergency'] ?? null) === 'fire_hydrant') ?? []; $objects = array_filter($elements, fn($el) => ($el['tags']['emergency'] ?? null) === 'fire_hydrant') ?? [];
} elseif ($type === 'charging_station') { } elseif ($type === 'charging_station') {
@ -66,8 +75,6 @@ class FollowUpService
$objects = array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'drinking_water') ?? []; $objects = array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'drinking_water') ?? [];
} elseif ($type === 'tree') { } elseif ($type === 'tree') {
$objects = array_filter($elements, fn($el) => ($el['tags']['natural'] ?? null) === 'tree') ?? []; $objects = array_filter($elements, fn($el) => ($el['tags']['natural'] ?? null) === 'tree') ?? [];
} elseif ($type === 'places') {
$objects = [];
} elseif ($type === 'power_pole') { } elseif ($type === 'power_pole') {
$objects = array_filter($elements, fn($el) => ($el['tags']['power'] ?? null) === 'pole') ?? []; $objects = array_filter($elements, fn($el) => ($el['tags']['power'] ?? null) === 'pole') ?? [];
} elseif ($type === 'manhole') { } elseif ($type === 'manhole') {
@ -78,30 +85,25 @@ class FollowUpService
$objects = array_filter($elements, fn($el) => ($el['tags']['leisure'] ?? null) === 'playground') ?? []; $objects = array_filter($elements, fn($el) => ($el['tags']['leisure'] ?? null) === 'playground') ?? [];
} elseif ($type === 'restaurant') { } elseif ($type === 'restaurant') {
$objects = array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'restaurant') ?? []; $objects = array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'restaurant') ?? [];
} else { } elseif ($type === 'places') {
// Les lieux (places) sont gérés via l'entité Place, pas via Overpass ici
$objects = []; $objects = [];
} }
$types[$type] = ['objects' => $objects, 'label' => $label];
}
$types['places'] = ['objects' => [], 'label' => 'Lieux'];
$now = new \DateTime(); // Mettre à jour les ZonePlaces pour tous les thèmes sauf 'places'
$persisted = 0; if ($type !== 'places') {
// Toujours appeler updateZonePlacesForTheme, même si $objects est vide, pour détecter les suppressions
// Gestion des ZonePlaces pour chaque thème (sauf 'places') $this->updateZonePlacesForTheme($stats, $type, $objects, $zonePlacesRepository, $em, $now);
$zonePlacesRepository = $em->getRepository(ZonePlaces::class);
foreach ($types as $type => $data) {
// On ne gère pas les ZonePlaces pour le thème 'places'
if ($type !== 'places' && !empty($data['objects'])) {
$this->updateZonePlacesForTheme($stats, $type, $data['objects'], $zonePlacesRepository, $em, $now);
} }
}
// Flush des ZonePlaces avant de continuer avec les CityFollowUp
$em->flush();
foreach ($types as $type => $data) {
// Suivi du nombre // Suivi du nombre
$measureCount = $type === 'places' ? $stats->getPlacesCount() : count($data['objects']); $measureCount = null;
if ($type === 'places') {
$measureCount = $stats->getPlacesCount();
} else {
$measureCount = count($objects);
}
if ($measureCount !== null) { if ($measureCount !== null) {
$followupCount = new CityFollowUp(); $followupCount = new CityFollowUp();
$followupCount->setName($type . '_count') $followupCount->setName($type . '_count')
@ -110,19 +112,18 @@ class FollowUpService
->setStats($stats); ->setStats($stats);
$em->persist($followupCount); $em->persist($followupCount);
$persisted++; $persisted++;
if ($persisted % 100 === 0) {
$em->flush();
$em->clear();
}
} }
// Suivi de la complétion basé sur les tags attendus // Suivi de la complétion basé sur les tags attendus
$completionTags = self::getFollowUpCompletionTags();
$expectedTags = $completionTags[$type] ?? []; $expectedTags = $completionTags[$type] ?? [];
$completed = [];
$partialCompletions = []; $partialCompletions = [];
if ($type === 'places') {
// Pour les lieux, on se base sur la complétion globale déjà calculée
$completion = $stats->getCompletionPercent();
} else {
if (!empty($expectedTags)) { if (!empty($expectedTags)) {
foreach ($data['objects'] as $el) { foreach ($objects as $el) {
$tags = $el['tags'] ?? []; $tags = $el['tags'] ?? [];
$filled = 0; $filled = 0;
foreach ($expectedTags as $tag) { foreach ($expectedTags as $tag) {
@ -142,20 +143,19 @@ class FollowUpService
} }
$percent = count($expectedTags) > 0 ? ($filled / count($expectedTags)) : 0; $percent = count($expectedTags) > 0 ? ($filled / count($expectedTags)) : 0;
$partialCompletions[] = $percent; $partialCompletions[] = $percent;
if ($percent === 1.0) {
$completed[] = $el;
} }
}
} // ... fallback pour les types sans tags attendus
else {
$completed = [];
$partialCompletions = array_fill(0, count($data['objects']), 0);
}
if ($type === 'places') {
$completion = $stats->getCompletionPercent();
} else { } else {
$completion = count($partialCompletions) > 0 ? round(array_sum($partialCompletions) / count($partialCompletions) * 100) : 0; // Pas de tags de complétion définis : complétion à 0 pour tous
if (count($objects) > 0) {
$partialCompletions = array_fill(0, count($objects), 0);
} }
}
$completion = count($partialCompletions) > 0
? round(array_sum($partialCompletions) / count($partialCompletions) * 100)
: 0;
}
if ($completion !== null) { if ($completion !== null) {
$followupCompletion = new CityFollowUp(); $followupCompletion = new CityFollowUp();
$followupCompletion->setName($type . '_completion') $followupCompletion->setName($type . '_completion')
@ -164,17 +164,111 @@ class FollowUpService
->setStats($stats); ->setStats($stats);
$em->persist($followupCompletion); $em->persist($followupCompletion);
$persisted++; $persisted++;
if ($persisted % 100 === 0) { }
// Flusher périodiquement pour limiter l'utilisation mémoire
if ($persisted > 0 && $persisted % 200 === 0) {
$em->flush(); $em->flush();
$em->clear(); $em->clear();
// Après un clear(), il peut être nécessaire de ré-attacher $stats
$stats = $em->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
$zonePlacesRepository = $em->getRepository(ZonePlaces::class);
} }
// Libérer la mémoire utilisée par $objects / $partialCompletions
unset($objects, $partialCompletions);
} }
}
// Flush final
$em->flush(); $em->flush();
$em->clear(); $em->clear();
// Suppression des mesures redondantes (même valeur consécutive, sauf la dernière) - désactivée définitivement // Suppression des mesures redondantes désactivée : on ne supprime plus de CityFollowUp
// (aucune suppression de CityFollowUp) }
/**
* Génère des CityFollowUp à partir d'un snapshot d'éléments déjà disponibles (par exemple depuis un GeoJSON exporté),
* sans appeler Overpass ni toucher aux ZonePlaces.
* Utilisé pour exploiter les exports historiques (counting_osm_objects/temp/export_*.geojson).
*/
public function generateCityFollowUpsFromElements(
Stats $stats,
array $elements,
EntityManagerInterface $em,
\DateTimeInterface $snapshotDate
): void {
$themes = self::getFollowUpThemes();
$completionTags = self::getFollowUpCompletionTags();
$now = \DateTime::createFromInterface($snapshotDate);
$persisted = 0;
foreach ($themes as $type => $label) {
// On ne gère pas les lieux "places" ici (basés sur l'entité Place)
if ($type === 'places') {
continue;
}
// Réutiliser le même filtrage que pour les rafraîchissements standards
$objects = $this->filterObjectsByThemeForRefresh($elements, $type);
// Suivi du nombre
$measureCount = count($objects);
$followupCount = new CityFollowUp();
$followupCount->setName($type . '_count')
->setMeasure($measureCount)
->setDate($now)
->setStats($stats);
$em->persist($followupCount);
$persisted++;
// Suivi de la complétion basé sur les tags attendus
$expectedTags = $completionTags[$type] ?? [];
$partialCompletions = [];
if (!empty($expectedTags)) {
foreach ($objects as $el) {
$tags = $el['tags'] ?? [];
$filled = 0;
foreach ($expectedTags as $tag) {
if ($tag === 'phone') {
if (!empty($tags['phone'] ?? null) || !empty($tags['contact:phone'] ?? null)) {
$filled++;
}
} elseif ($tag === 'email') {
if (!empty($tags['email'] ?? null) || !empty($tags['contact:email'] ?? null)) {
$filled++;
}
} else {
if (!empty($tags[$tag] ?? null)) {
$filled++;
}
}
}
$percent = count($expectedTags) > 0 ? ($filled / count($expectedTags)) : 0;
$partialCompletions[] = $percent;
}
} else {
if (count($objects) > 0) {
$partialCompletions = array_fill(0, count($objects), 0);
}
}
$completion = count($partialCompletions) > 0
? round(array_sum($partialCompletions) / count($partialCompletions) * 100)
: 0;
$followupCompletion = new CityFollowUp();
$followupCompletion->setName($type . '_completion')
->setMeasure($completion)
->setDate($now)
->setStats($stats);
$em->persist($followupCompletion);
$persisted++;
}
if ($persisted > 0) {
$em->flush();
}
} }
public function generateGlobalFollowUps(EntityManagerInterface $em): void public function generateGlobalFollowUps(EntityManagerInterface $em): void
@ -651,6 +745,98 @@ class FollowUpService
// Mettre à jour même si vide pour détecter les suppressions // Mettre à jour même si vide pour détecter les suppressions
$this->updateZonePlacesForTheme($stats, $type, $themeObjects, $zonePlacesRepository, $em, $now); $this->updateZonePlacesForTheme($stats, $type, $themeObjects, $zonePlacesRepository, $em, $now);
// Libérer la mémoire utilisée par le tableau filtré
unset($themeObjects);
}
// Libérer également le tableau complet d'éléments
unset($elements);
$em->flush();
}
/**
* Initialise les ZonePlaces à partir d'un snapshot (baseline) sans détecter de suppressions.
* Utilisé pour poser un état de référence (ex: 1er janvier 2020) avant de comparer avec l'état actuel.
*/
public function initializeZonePlacesFromSnapshot(
Stats $stats,
array $elements,
EntityManagerInterface $em,
\DateTimeInterface $snapshotDate
): void {
$themes = self::getFollowUpThemes();
$zonePlacesRepository = $em->getRepository(ZonePlaces::class);
foreach ($themes as $type => $label) {
if ($type === 'places') {
continue;
}
$themeObjects = $this->filterObjectsByThemeForRefresh($elements, $type);
// Récupérer ou créer le ZonePlaces pour ce thème
$zonePlaces = $zonePlacesRepository->findOneByStatsAndTheme($stats, $type);
if (!$zonePlaces) {
$zonePlaces = new ZonePlaces();
$zonePlaces->setStats($stats);
$zonePlaces->setTheme($type);
}
// Construire la liste "courante" à la date du snapshot, sans comparer avec l'existant
$currentObjectsMap = [];
foreach ($themeObjects as $obj) {
if (isset($obj['type']) && isset($obj['id'])) {
$key = $obj['type'] . '_' . $obj['id'];
$currentObjectsMap[$key] = [
'type' => $obj['type'],
'id' => $obj['id'],
'user' => $obj['user'] ?? null,
'timestamp' => $obj['timestamp'] ?? null,
'changeset' => $obj['changeset'] ?? null,
];
}
}
$zonePlaces->setCurrentList(array_values($currentObjectsMap));
// Pour un baseline propre, on remet la liste des disparus à vide
$zonePlaces->setDisappearedList([]);
$em->persist($zonePlaces);
unset($themeObjects, $currentObjectsMap, $zonePlaces);
}
$em->flush();
}
/**
* Met à jour les ZonePlaces d'une ville à partir d'un snapshot d'éléments (Overpass ou GeoJSON),
* pour une date donnée. Utilisé pour rejouer des snapshots historiques de façon incrémentale.
*/
public function updateZonePlacesFromElementsAtDate(
Stats $stats,
array $elements,
EntityManagerInterface $em,
\DateTimeInterface $snapshotDate
): void {
$themes = self::getFollowUpThemes();
$zonePlacesRepository = $em->getRepository(ZonePlaces::class);
$now = \DateTime::createFromInterface($snapshotDate);
foreach ($themes as $type => $label) {
if ($type === 'places') {
continue;
}
// Réutiliser le même filtrage que pour les rafraîchissements standards
$themeObjects = $this->filterObjectsByThemeForRefresh($elements, $type);
// Mettre à jour les ZonePlaces pour ce snapshot (détections de disparitions incrémentales)
$this->updateZonePlacesForTheme($stats, $type, $themeObjects, $zonePlacesRepository, $em, $now);
unset($themeObjects);
} }
$em->flush(); $em->flush();

View file

@ -2,8 +2,10 @@
namespace App\Service; namespace App\Service;
use App\Entity\Stats;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
class Motocultrice class Motocultrice
{ {
private $overpassApiUrl = 'https://overpass-api.de/api/interpreter'; private $overpassApiUrl = 'https://overpass-api.de/api/interpreter';
@ -639,7 +641,59 @@ QUERY;
} }
/** /**
* Récupère les objets de suivi pour une ville (bornes incendie et bornes de recharge) * Génère la requête Overpass de suivi pour une ville à une date donnée (snapshot historique)
*/
public function get_followup_query_at_date(string $zone, \DateTimeInterface $date): string
{
// Overpass attend une date UTC au format ISO8601
$dateIso = $date->format('Y-m-d\T00:00:00\Z');
return <<<QUERY
[out:json][timeout:180][date:"$dateIso"];
area["ref:INSEE"="$zone"]->.searchArea;
(
nwr["emergency"="fire_hydrant"](area.searchArea);
nwr["amenity"="charging_station"](area.searchArea);
nwr["amenity"="toilets"](area.searchArea);
nwr["highway"="bus_stop"](area.searchArea);
nwr["emergency"="defibrillator"](area.searchArea);
nwr["man_made"="surveillance"](area.searchArea);
nwr["amenity"="recycling"](area.searchArea);
nwr["power"="substation"](area.searchArea);
nwr["healthcare"](area.searchArea);
nwr["amenity"="doctors"](area.searchArea);
nwr["amenity"="pharmacy"](area.searchArea);
nwr["amenity"="hospital"](area.searchArea);
nwr["amenity"="clinic"](area.searchArea);
nwr["amenity"="social_facility"](area.searchArea);
nwr["healthcare"="laboratory"](area.searchArea);
nwr["amenity"="school"](area.searchArea);
nwr["amenity"="police"](area.searchArea);
nwr["amenity"="bicycle_parking"](area.searchArea);
nwr["advertising"="board"]["message"="political"](area.searchArea);
way["building"](area.searchArea);
nwr["email"](area.searchArea);
nwr["contact:email"](area.searchArea);
nwr["amenity"="bench"](area.searchArea);
nwr["amenity"="waste_basket"](area.searchArea);
nwr["highway"="street_lamp"](area.searchArea);
nwr["amenity"="drinking_water"](area.searchArea);
nwr["natural"="tree"](area.searchArea);
nw["power"="pole"](area.searchArea);
nwr["manhole"](area.searchArea);
nwr["amenity"="public_bookcase"](area.searchArea);
nwr["leisure"="playground"](area.searchArea);
nwr["amenity"="restaurant"](area.searchArea);
nwr["ref:FR:RNB"](area.searchArea);
);
(._;>;);
out meta;
>;
QUERY;
}
/**
* Récupère les objets de suivi pour une ville (état actuel)
*/ */
public function followUpCity($zone) { public function followUpCity($zone) {
$query = $this->get_followup_query($zone); $query = $this->get_followup_query($zone);
@ -658,6 +712,136 @@ QUERY;
} }
} }
/**
* Récupère les objets de suivi pour une ville à une date donnée (snapshot historique)
*/
public function followUpCityAtDate(string $zone, \DateTimeInterface $date): array
{
$query = $this->get_followup_query_at_date($zone, $date);
try {
$response = $this->client->request('POST', 'https://overpass-api.de/api/interpreter', [
'body' => ['data' => $query],
'timeout' => 180
]);
if ($response->getStatusCode() !== 200) {
throw new \Exception('L\'API Overpass a retourné un code de statut non-200 : ' . $response->getStatusCode());
}
$data = json_decode($response->getContent(), true);
return $data['elements'] ?? [];
} catch (\Exception $e) {
return [];
}
}
/**
* Pose un baseline au 1er janvier 2020 à partir du fichier PBF interne
* (france-latest-internal.osm.pbf via osmium) puis compare avec l'état actuel
* en utilisant la logique standard de FollowUpService (generateCityFollowUps).
*
* Objectif : détecter les suppressions et modifications entre le snapshot 2020 et maintenant.
*/
public function populateHistoricalZonePlaces(
Stats $stats,
\App\Service\FollowUpService $followUpService,
EntityManagerInterface $em
): void {
$zone = $stats->getZone();
// 1. Construire les chemins vers les données OSM internes et le polygone de la commune
$projectRoot = dirname(__DIR__, 2); // remonte de src/Service à la racine du projet
$pbfPath = $projectRoot . '/counting_osm_objects/osm_data/france-internal.osh.pbf';
$polygonsDir = $projectRoot . '/counting_osm_objects';
$polygonsPath = $polygonsDir . '/polygons';
$polyPath = $polygonsPath . '/commune_' . $zone . '.poly';
$polyPathWithPrefix = $polygonsPath . '/commune_' . $zone . '.poly';
if (!file_exists($pbfPath)) {
throw new \RuntimeException('Fichier PBF interne introuvable : ' . $pbfPath);
}
// Ici, on suppose que le polygone a été généré en amont par la commande (get_poly.py).
if (!file_exists($polyPath) && !file_exists($polyPathWithPrefix)) {
throw new \RuntimeException(
'Fichier polygone introuvable pour la commune : ' . $polyPath
);
}
if (file_exists($polyPathWithPrefix)) {
$polyPath = $polyPathWithPrefix;
}
// 2. Fichiers temporaires pour le snapshot 2020, l'extraction et l'export JSON
$tmpDir = sys_get_temp_dir();
$snapshotPbf = $tmpDir . '/france_2020_snapshot_for_' . $zone . '.osm.pbf';
$tmpPbf = $tmpDir . '/osm_zone_' . $zone . '_2020.osm.pbf';
$tmpJson = $tmpDir . '/osm_zone_' . $zone . '_2020.json';
// 3. Créer un snapshot 2020 à partir du fichier history (.osh.pbf) avec osmium time-filter
// Ceci produit un fichier ordonné (nodes, puis ways, puis relations) compatible avec osmium extract.
$timeFilterCmd = sprintf(
'osmium time-filter %s 2020-01-01T00:00:00Z -o %s --overwrite 2>&1',
escapeshellarg($pbfPath),
escapeshellarg($snapshotPbf)
);
exec($timeFilterCmd, $timeFilterOutput, $timeFilterCode);
if ($timeFilterCode !== 0 || !file_exists($snapshotPbf)) {
throw new \RuntimeException('Échec du time-filter osmium (snapshot 2020) : ' . implode("\n", $timeFilterOutput));
}
// 4. Extraction de la zone à partir du snapshot 2020 avec osmium
// (forcer l'écrasement du fichier temporaire s'il existe déjà)
if (file_exists($tmpPbf)) {
@unlink($tmpPbf);
}
$extractCmd = sprintf(
'osmium extract -p %s %s -o %s --overwrite 2>&1',
escapeshellarg($polyPath),
escapeshellarg($snapshotPbf),
escapeshellarg($tmpPbf)
);
exec($extractCmd, $extractOutput, $extractCode);
if ($extractCode !== 0 || !file_exists($tmpPbf)) {
throw new \RuntimeException('Échec de l\'extraction osmium : ' . implode("\n", $extractOutput));
}
// 5. Export JSON avec osmium export
$exportCmd = sprintf(
'osmium export -f json %s -o %s 2>&1',
escapeshellarg($tmpPbf),
escapeshellarg($tmpJson)
);
exec($exportCmd, $exportOutput, $exportCode);
if ($exportCode !== 0 || !file_exists($tmpJson)) {
throw new \RuntimeException('Échec de l\'export JSON osmium : ' . implode("\n", $exportOutput));
}
// 6. Charger le JSON et récupérer les éléments (format proche dOverpass)
$jsonContent = file_get_contents($tmpJson);
if ($jsonContent === false) {
throw new \RuntimeException('Impossible de lire le fichier JSON temporaire : ' . $tmpJson);
}
$data = json_decode($jsonContent, true);
if (!is_array($data)) {
throw new \RuntimeException('JSON osmium invalide pour la commune ' . $zone);
}
$elements2020 = $data['elements'] ?? [];
// 7. Date de baseline (1er janvier 2020)
$baselineDate = new \DateTime('2020-01-01 00:00:00', new \DateTimeZone('UTC'));
// 8. Initialiser les ZonePlaces avec l'état 2020 (sans détecter de suppressions)
$followUpService->initializeZonePlacesFromSnapshot($stats, $elements2020, $em, $baselineDate);
// Libérer la mémoire et nettoyer temporairement
unset($elements2020, $data, $jsonContent);
@unlink($snapshotPbf);
@unlink($tmpPbf);
@unlink($tmpJson);
// 9. Générer les followups actuels (et mettre à jour les ZonePlaces en comparant baseline 2020 -> maintenant)
$followUpService->generateCityFollowUps($stats, $this, $em, true);
}
/** /**
* Calcule la progression sur 7j, 30j, 6 mois, 1 an pour une série de points [{date, value}] * Calcule la progression sur 7j, 30j, 6 mois, 1 an pour une série de points [{date, value}]
* @param array $data * @param array $data

View file

@ -360,8 +360,10 @@
}, },
plugins: { plugins: {
legend: { legend: {
display: true, display: false,
position: 'top', },
datalabels: {
display: false,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
@ -456,8 +458,10 @@
}, },
plugins: { plugins: {
legend: { legend: {
display: true, display: false,
position: 'top', },
datalabels: {
display: false,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',
@ -600,8 +604,10 @@
}, },
plugins: { plugins: {
legend: { legend: {
display: true, display: false,
position: 'top', },
datalabels: {
display: false,
}, },
tooltip: { tooltip: {
mode: 'index', mode: 'index',

View file

@ -5,6 +5,16 @@
{% block stylesheets %} {% block stylesheets %}
{{ parent() }} {{ parent() }}
<link href='{{ asset('css/city-sidebar.css') }}' rel='stylesheet'/> <link href='{{ asset('css/city-sidebar.css') }}' rel='stylesheet'/>
<style>
/* Colonnes de thème à largeur régulière dans les tableaux ZonePlaces */
.zoneplaces-table th.theme-col,
.zoneplaces-table td.theme-col {
width: 180px;
min-width: 180px;
max-width: 180px;
white-space: nowrap;
}
</style>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
@ -19,7 +29,32 @@
<div class="col-md-9 col-lg-10 main-content"> <div class="col-md-9 col-lg-10 main-content">
<div class="p-4"> <div class="p-4">
<h1>Historique ZonePlaces - {{ stats.name }} ({{ stats.zone }})</h1> <h1>Historique ZonePlaces - {{ stats.name }} ({{ stats.zone }})</h1>
<p class="text-muted">Historique combiné des suppressions et créations d'objets OSM par thème, groupé par date.</p> <p class="text-muted">
Historique combiné des suppressions et créations d'objets OSM par thème, groupé par date.
</p>
{# Sélecteur de période (pagination temporelle) #}
<div class="mb-3">
<label class="form-label"><strong>Période affichée :</strong></label>
<div class="btn-group" role="group" aria-label="Période d'historique">
<a href="{{ path('app_public_zone_places_history', { insee_code: stats.zone, period: '3m' }) }}"
class="btn btn-outline-primary {{ period is defined and period == '3m' ? 'active' : (period is not defined ? 'active' : '') }}">
3 derniers mois
</a>
<a href="{{ path('app_public_zone_places_history', { insee_code: stats.zone, period: '6m' }) }}"
class="btn btn-outline-primary {{ period is defined and period == '6m' ? 'active' : '' }}">
6 derniers mois
</a>
<a href="{{ path('app_public_zone_places_history', { insee_code: stats.zone, period: '12m' }) }}"
class="btn btn-outline-primary {{ period is defined and period == '12m' ? 'active' : '' }}">
12 derniers mois
</a>
<a href="{{ path('app_public_zone_places_history', { insee_code: stats.zone, period: 'all' }) }}"
class="btn btn-outline-secondary {{ period is defined and period == 'all' ? 'active' : '' }}">
Tout l'historique
</a>
</div>
</div>
{% if changesByDate is empty %} {% if changesByDate is empty %}
<div class="alert alert-info"> <div class="alert alert-info">
@ -86,14 +121,23 @@
</div> </div>
</div> </div>
{# Graphique des créations et suppressions #} {# Graphiques des créations et suppressions #}
{% if chart_data is defined and chart_data.dates|length > 0 %} {% if chart_data is defined and chart_data.dates|length > 0 %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h5><i class="bi bi-graph-up"></i> Évolution des créations et suppressions</h5> <h5><i class="bi bi-graph-up"></i> Évolution des créations</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<canvas id="changesChart" style="max-height: 400px;"></canvas> <canvas id="creationsChart" style="max-height: 300px;"></canvas>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5><i class="bi bi-graph-down"></i> Évolution des suppressions</h5>
</div>
<div class="card-body">
<canvas id="deletionsChart" style="max-height: 300px;"></canvas>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -114,10 +158,10 @@
<div class="mb-3 theme-section" data-theme="{{ theme }}"> <div class="mb-3 theme-section" data-theme="{{ theme }}">
<h5><i class="bi {{ themeIcon }}"></i> {{ themeLabel }} ({{ objects|length }} suppression{{ objects|length > 1 ? 's' : '' }})</h5> <h5><i class="bi {{ themeIcon }}"></i> {{ themeLabel }} ({{ objects|length }} suppression{{ objects|length > 1 ? 's' : '' }})</h5>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover"> <table class="table table-sm table-hover zoneplaces-table">
<thead> <thead>
<tr> <tr>
<th>Thème</th> <th class="theme-col">Thème</th>
<th>Type</th> <th>Type</th>
<th>ID</th> <th>ID</th>
<th>Contributeur</th> <th>Contributeur</th>
@ -130,7 +174,7 @@
<tbody> <tbody>
{% for obj in objects %} {% for obj in objects %}
<tr data-theme="{{ theme }}" data-change-type="deletions" data-user="{{ obj.user|default('')|e('html_attr') }}"> <tr data-theme="{{ theme }}" data-change-type="deletions" data-user="{{ obj.user|default('')|e('html_attr') }}">
<td><span class="badge bg-info">{{ themeLabel }}</span></td> <td class="theme-col"><span class="badge bg-info">{{ themeLabel }}</span></td>
<td><span class="badge bg-secondary">{{ obj.type|upper }}</span></td> <td><span class="badge bg-secondary">{{ obj.type|upper }}</span></td>
<td><code>{{ obj.id }}</code></td> <td><code>{{ obj.id }}</code></td>
<td> <td>
@ -204,10 +248,10 @@
<div class="mb-3 theme-section" data-theme="{{ theme }}"> <div class="mb-3 theme-section" data-theme="{{ theme }}">
<h5><i class="bi {{ themeIcon }}"></i> {{ themeLabel }} ({{ objects|length }} objet{{ objects|length > 1 ? 's' : '' }})</h5> <h5><i class="bi {{ themeIcon }}"></i> {{ themeLabel }} ({{ objects|length }} objet{{ objects|length > 1 ? 's' : '' }})</h5>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover"> <table class="table table-sm table-hover zoneplaces-table">
<thead> <thead>
<tr> <tr>
<th>Thème</th> <th class="theme-col">Thème</th>
<th>Type</th> <th>Type</th>
<th>ID</th> <th>ID</th>
<th>Contributeur</th> <th>Contributeur</th>
@ -219,7 +263,7 @@
<tbody> <tbody>
{% for obj in objects %} {% for obj in objects %}
<tr data-theme="{{ theme }}" data-change-type="creations" data-user="{{ obj.user|default('')|e('html_attr') }}"> <tr data-theme="{{ theme }}" data-change-type="creations" data-user="{{ obj.user|default('')|e('html_attr') }}">
<td><span class="badge bg-info">{{ themeLabel }}</span></td> <td class="theme-col"><span class="badge bg-info">{{ themeLabel }}</span></td>
<td><span class="badge bg-success">{{ obj.type|upper }}</span></td> <td><span class="badge bg-success">{{ obj.type|upper }}</span></td>
<td><code>{{ obj.id }}</code></td> <td><code>{{ obj.id }}</code></td>
<td> <td>
@ -414,7 +458,7 @@
// Initialiser les filtres // Initialiser les filtres
applyFilters(); applyFilters();
// Créer le graphique des créations et suppressions // Créer les graphiques des créations et suppressions
{% if chart_data is defined and chart_data.dates|length > 0 %} {% if chart_data is defined and chart_data.dates|length > 0 %}
{% set hasCompletionData = false %} {% set hasCompletionData = false %}
{% for comp in chart_data.completion %} {% for comp in chart_data.completion %}
@ -422,19 +466,11 @@
{% set hasCompletionData = true %} {% set hasCompletionData = true %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
const chartCanvas = document.getElementById('changesChart'); const creationsCanvas = document.getElementById('creationsChart');
if (chartCanvas) { if (creationsCanvas) {
const chartData = { const creationsData = {
labels: {{ chart_data.dates|json_encode|raw }}, labels: {{ chart_data.dates|json_encode|raw }},
datasets: [ datasets: [
{
label: 'Suppressions',
data: {{ chart_data.deletions|json_encode|raw }},
borderColor: 'rgb(220, 53, 69)',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
tension: 0.4,
fill: true
},
{ {
label: 'Créations', label: 'Créations',
data: {{ chart_data.creations|json_encode|raw }}, data: {{ chart_data.creations|json_encode|raw }},
@ -458,9 +494,9 @@
] ]
}; };
new Chart(chartCanvas, { new Chart(creationsCanvas, {
type: 'line', type: 'line',
data: chartData, data: creationsData,
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: true,
@ -532,6 +568,77 @@
} }
}); });
} }
const deletionsCanvas = document.getElementById('deletionsChart');
if (deletionsCanvas) {
const deletionsData = {
labels: {{ chart_data.dates|json_encode|raw }},
datasets: [
{
label: 'Suppressions',
data: {{ chart_data.deletions|json_encode|raw }},
borderColor: 'rgb(220, 53, 69)',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
tension: 0.4,
fill: true
}
]
};
new Chart(deletionsCanvas, {
type: 'line',
data: deletionsData,
options: {
responsive: true,
maintainAspectRatio: true,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'top',
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += context.parsed.y;
} else {
label += 'N/A';
}
return label;
}
}
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: 'Date'
}
},
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Nombre d\'objets'
},
beginAtZero: true
}
}
}
});
}
{% endif %} {% endif %}
// Convertir les dates en format relatif // Convertir les dates en format relatif