add history for zone command
This commit is contained in:
parent
2b07e6a4c6
commit
45df535a35
12 changed files with 1071 additions and 854 deletions
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
19
counting_osm_objects/osmium_export_config.json
Normal file
19
counting_osm_objects/osmium_export_config.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
395
src/Command/ExploreZonePlacesHistoryCommand.php
Normal file
395
src/Command/ExploreZonePlacesHistoryCommand.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,27 +1568,29 @@ 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) {
|
||||||
$date = $timestamp->format('Y-m-d');
|
continue;
|
||||||
if (!isset($changesByDate[$date])) {
|
}
|
||||||
$changesByDate[$date] = ['deletions' => [], 'creations' => []];
|
|
||||||
}
|
|
||||||
if (!isset($changesByDate[$date]['creations'][$theme])) {
|
|
||||||
$changesByDate[$date]['creations'][$theme] = [];
|
|
||||||
}
|
|
||||||
$changesByDate[$date]['creations'][$theme][] = $obj;
|
|
||||||
|
|
||||||
// Collecter les utilisateurs uniques
|
$date = $timestamp->format('Y-m-d');
|
||||||
if (!empty($obj['user'])) {
|
if (!isset($changesByDate[$date])) {
|
||||||
$uniqueUsers[$obj['user']] = true;
|
$changesByDate[$date] = ['deletions' => [], 'creations' => []];
|
||||||
}
|
}
|
||||||
|
if (!isset($changesByDate[$date]['creations'][$theme])) {
|
||||||
|
$changesByDate[$date]['creations'][$theme] = [];
|
||||||
|
}
|
||||||
|
$changesByDate[$date]['creations'][$theme][] = $obj;
|
||||||
|
|
||||||
|
// Collecter les utilisateurs uniques
|
||||||
|
if (!empty($obj['user'])) {
|
||||||
|
$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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,121 @@ 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)) {
|
||||||
|
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 {
|
||||||
|
// 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) {
|
||||||
|
$followupCompletion = new CityFollowUp();
|
||||||
|
$followupCompletion->setName($type . '_completion')
|
||||||
|
->setMeasure($completion)
|
||||||
|
->setDate($now)
|
||||||
|
->setStats($stats);
|
||||||
|
$em->persist($followupCompletion);
|
||||||
|
$persisted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flusher périodiquement pour limiter l'utilisation mémoire
|
||||||
|
if ($persisted > 0 && $persisted % 200 === 0) {
|
||||||
|
$em->flush();
|
||||||
|
$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->clear();
|
||||||
|
|
||||||
|
// Suppression des mesures redondantes désactivée : on ne supprime plus 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)) {
|
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,39 +246,29 @@ 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;
|
if (count($objects) > 0) {
|
||||||
}
|
$partialCompletions = array_fill(0, count($objects), 0);
|
||||||
if ($completion !== null) {
|
|
||||||
$followupCompletion = new CityFollowUp();
|
|
||||||
$followupCompletion->setName($type . '_completion')
|
|
||||||
->setMeasure($completion)
|
|
||||||
->setDate($now)
|
|
||||||
->setStats($stats);
|
|
||||||
$em->persist($followupCompletion);
|
|
||||||
$persisted++;
|
|
||||||
if ($persisted % 100 === 0) {
|
|
||||||
$em->flush();
|
|
||||||
$em->clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
$em->flush();
|
|
||||||
$em->clear();
|
|
||||||
|
|
||||||
// Suppression des mesures redondantes (même valeur consécutive, sauf la dernière) - désactivée définitivement
|
$completion = count($partialCompletions) > 0
|
||||||
// (aucune suppression de CityFollowUp)
|
? 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();
|
||||||
|
|
|
||||||
|
|
@ -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 d’Overpass)
|
||||||
|
$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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue