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
|
||||
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
|
||||
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à
|
||||
|
|
@ -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}"
|
||||
run_command_cached(filter_cmd)
|
||||
|
||||
# Exporter vers GeoJSON
|
||||
export_cmd = f"osmium export {filtered_file} -O -o {geojson_file} -f geojson --geometry-types point,linestring,polygon"
|
||||
# Exporter vers GeoJSON en utilisant un fichier de configuration osmium
|
||||
# (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)
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
$io->info('Récupération des followups de cette ville...');
|
||||
// $this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager);
|
||||
$io->info('Récupération des followups de cette ville (comptages, complétions, ZonePlaces)...');
|
||||
// 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
|
||||
// update completion (basée sur les lieux)
|
||||
$stats->computeCompletionPercent();
|
||||
$followups = $stats->getCityFollowUps();
|
||||
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+'])]
|
||||
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]);
|
||||
if (!$stats) {
|
||||
|
|
@ -1497,12 +1497,36 @@ class PublicController extends AbstractController
|
|||
$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();
|
||||
$changesByDate = [];
|
||||
$followupLabels = \App\Service\FollowUpService::getFollowUpThemes();
|
||||
$followupIcons = \App\Service\FollowUpService::getFollowUpIcons();
|
||||
$thirtyDaysAgo = new \DateTime('-30 days');
|
||||
$uniqueUsers = [];
|
||||
|
||||
foreach ($zonePlaces as $zp) {
|
||||
|
|
@ -1513,7 +1537,21 @@ class PublicController extends AbstractController
|
|||
if (is_array($disappearedList)) {
|
||||
foreach ($disappearedList as $obj) {
|
||||
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])) {
|
||||
$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() ?? [];
|
||||
if (is_array($currentList)) {
|
||||
foreach ($currentList as $obj) {
|
||||
if (isset($obj['timestamp'])) {
|
||||
try {
|
||||
$timestamp = new \DateTime($obj['timestamp']);
|
||||
if ($timestamp >= $thirtyDaysAgo) {
|
||||
$date = $timestamp->format('Y-m-d');
|
||||
if (!isset($changesByDate[$date])) {
|
||||
$changesByDate[$date] = ['deletions' => [], 'creations' => []];
|
||||
}
|
||||
if (!isset($changesByDate[$date]['creations'][$theme])) {
|
||||
$changesByDate[$date]['creations'][$theme] = [];
|
||||
}
|
||||
$changesByDate[$date]['creations'][$theme][] = $obj;
|
||||
if ($cutoffDate !== null && $timestamp < $cutoffDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collecter les utilisateurs uniques
|
||||
if (!empty($obj['user'])) {
|
||||
$uniqueUsers[$obj['user']] = true;
|
||||
}
|
||||
$date = $timestamp->format('Y-m-d');
|
||||
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
|
||||
if (!empty($obj['user'])) {
|
||||
$uniqueUsers[$obj['user']] = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Ignorer les timestamps invalides
|
||||
|
|
@ -1623,6 +1663,7 @@ class PublicController extends AbstractController
|
|||
'followup_icons' => $followupIcons,
|
||||
'unique_users' => $uniqueUsersList,
|
||||
'chart_data' => $chartData,
|
||||
'period' => $period,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,17 @@ class FollowUpService
|
|||
$insee_code = $stats->getZone();
|
||||
$elements = $motocultrice->followUpCity($insee_code) ?? [];
|
||||
$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) {
|
||||
$objects = [];
|
||||
|
||||
// Filtrer les objets Overpass par thème (en mémoire, mais un seul thème à la fois)
|
||||
if ($type === 'fire_hydrant') {
|
||||
$objects = array_filter($elements, fn($el) => ($el['tags']['emergency'] ?? null) === 'fire_hydrant') ?? [];
|
||||
} elseif ($type === 'charging_station') {
|
||||
|
|
@ -66,8 +75,6 @@ class FollowUpService
|
|||
$objects = array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'drinking_water') ?? [];
|
||||
} elseif ($type === 'tree') {
|
||||
$objects = array_filter($elements, fn($el) => ($el['tags']['natural'] ?? null) === 'tree') ?? [];
|
||||
} elseif ($type === 'places') {
|
||||
$objects = [];
|
||||
} elseif ($type === 'power_pole') {
|
||||
$objects = array_filter($elements, fn($el) => ($el['tags']['power'] ?? null) === 'pole') ?? [];
|
||||
} elseif ($type === 'manhole') {
|
||||
|
|
@ -78,30 +85,25 @@ class FollowUpService
|
|||
$objects = array_filter($elements, fn($el) => ($el['tags']['leisure'] ?? null) === 'playground') ?? [];
|
||||
} elseif ($type === '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 = [];
|
||||
}
|
||||
$types[$type] = ['objects' => $objects, 'label' => $label];
|
||||
}
|
||||
$types['places'] = ['objects' => [], 'label' => 'Lieux'];
|
||||
|
||||
$now = new \DateTime();
|
||||
$persisted = 0;
|
||||
|
||||
// Gestion des ZonePlaces pour chaque thème (sauf 'places')
|
||||
$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);
|
||||
// Mettre à jour les ZonePlaces pour tous les thèmes sauf 'places'
|
||||
if ($type !== 'places') {
|
||||
// Toujours appeler updateZonePlacesForTheme, même si $objects est vide, pour détecter les suppressions
|
||||
$this->updateZonePlacesForTheme($stats, $type, $objects, $zonePlacesRepository, $em, $now);
|
||||
}
|
||||
}
|
||||
// Flush des ZonePlaces avant de continuer avec les CityFollowUp
|
||||
$em->flush();
|
||||
|
||||
foreach ($types as $type => $data) {
|
||||
// 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) {
|
||||
$followupCount = new CityFollowUp();
|
||||
$followupCount->setName($type . '_count')
|
||||
|
|
@ -110,19 +112,121 @@ class FollowUpService
|
|||
->setStats($stats);
|
||||
$em->persist($followupCount);
|
||||
$persisted++;
|
||||
if ($persisted % 100 === 0) {
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Suivi de la complétion basé sur les tags attendus
|
||||
$completionTags = self::getFollowUpCompletionTags();
|
||||
$expectedTags = $completionTags[$type] ?? [];
|
||||
$completed = [];
|
||||
$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)) {
|
||||
foreach ($data['objects'] as $el) {
|
||||
foreach ($objects as $el) {
|
||||
$tags = $el['tags'] ?? [];
|
||||
$filled = 0;
|
||||
foreach ($expectedTags as $tag) {
|
||||
|
|
@ -142,39 +246,29 @@ class FollowUpService
|
|||
}
|
||||
$percent = count($expectedTags) > 0 ? ($filled / count($expectedTags)) : 0;
|
||||
$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 {
|
||||
$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++;
|
||||
if ($persisted % 100 === 0) {
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
if (count($objects) > 0) {
|
||||
$partialCompletions = array_fill(0, count($objects), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
// Suppression des mesures redondantes (même valeur consécutive, sauf la dernière) - désactivée définitivement
|
||||
// (aucune suppression de CityFollowUp)
|
||||
$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
|
||||
|
|
@ -651,6 +745,98 @@ class FollowUpService
|
|||
|
||||
// Mettre à jour même si vide pour détecter les suppressions
|
||||
$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();
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Stats;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class Motocultrice
|
||||
{
|
||||
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) {
|
||||
$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}]
|
||||
* @param array $data
|
||||
|
|
|
|||
|
|
@ -360,8 +360,10 @@
|
|||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
display: false,
|
||||
},
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
|
|
@ -456,8 +458,10 @@
|
|||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
display: false,
|
||||
},
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
|
|
@ -600,8 +604,10 @@
|
|||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
display: false,
|
||||
},
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@
|
|||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<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 %}
|
||||
|
||||
{% block body %}
|
||||
|
|
@ -19,7 +29,32 @@
|
|||
<div class="col-md-9 col-lg-10 main-content">
|
||||
<div class="p-4">
|
||||
<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 %}
|
||||
<div class="alert alert-info">
|
||||
|
|
@ -86,14 +121,23 @@
|
|||
</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 %}
|
||||
<div class="card mb-4">
|
||||
<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 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>
|
||||
{% endif %}
|
||||
|
|
@ -114,10 +158,10 @@
|
|||
<div class="mb-3 theme-section" data-theme="{{ theme }}">
|
||||
<h5><i class="bi {{ themeIcon }}"></i> {{ themeLabel }} ({{ objects|length }} suppression{{ objects|length > 1 ? 's' : '' }})</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<table class="table table-sm table-hover zoneplaces-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Thème</th>
|
||||
<th class="theme-col">Thème</th>
|
||||
<th>Type</th>
|
||||
<th>ID</th>
|
||||
<th>Contributeur</th>
|
||||
|
|
@ -130,7 +174,7 @@
|
|||
<tbody>
|
||||
{% for obj in objects %}
|
||||
<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><code>{{ obj.id }}</code></td>
|
||||
<td>
|
||||
|
|
@ -204,10 +248,10 @@
|
|||
<div class="mb-3 theme-section" data-theme="{{ theme }}">
|
||||
<h5><i class="bi {{ themeIcon }}"></i> {{ themeLabel }} ({{ objects|length }} objet{{ objects|length > 1 ? 's' : '' }})</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<table class="table table-sm table-hover zoneplaces-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Thème</th>
|
||||
<th class="theme-col">Thème</th>
|
||||
<th>Type</th>
|
||||
<th>ID</th>
|
||||
<th>Contributeur</th>
|
||||
|
|
@ -219,7 +263,7 @@
|
|||
<tbody>
|
||||
{% for obj in objects %}
|
||||
<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><code>{{ obj.id }}</code></td>
|
||||
<td>
|
||||
|
|
@ -414,7 +458,7 @@
|
|||
// Initialiser les filtres
|
||||
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 %}
|
||||
{% set hasCompletionData = false %}
|
||||
{% for comp in chart_data.completion %}
|
||||
|
|
@ -422,19 +466,11 @@
|
|||
{% set hasCompletionData = true %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
const chartCanvas = document.getElementById('changesChart');
|
||||
if (chartCanvas) {
|
||||
const chartData = {
|
||||
const creationsCanvas = document.getElementById('creationsChart');
|
||||
if (creationsCanvas) {
|
||||
const creationsData = {
|
||||
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
|
||||
},
|
||||
{
|
||||
label: 'Créations',
|
||||
data: {{ chart_data.creations|json_encode|raw }},
|
||||
|
|
@ -458,9 +494,9 @@
|
|||
]
|
||||
};
|
||||
|
||||
new Chart(chartCanvas, {
|
||||
new Chart(creationsCanvas, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
data: creationsData,
|
||||
options: {
|
||||
responsive: 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 %}
|
||||
|
||||
// Convertir les dates en format relatif
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue