Compare commits

...

157 commits

Author SHA1 Message Date
Tykayn
7665f1d99c documentation wiki osm, ajout dashboard issues osmose 2025-08-31 11:06:54 +02:00
Tykayn
b28f8eac63 use insee codes to generate all cities measures from osmium and osm pbf 2025-08-31 01:33:22 +02:00
Tykayn
fa346d522f up popup info ville 2025-08-30 22:19:40 +02:00
Tykayn
2665adc897 up compare 2025-08-22 23:30:36 +02:00
Tykayn
e533c273b2 up wiki land 2025-08-22 18:19:20 +02:00
Tykayn
391a212034 up wiki controller 2025-08-22 17:58:13 +02:00
Tykayn
2f49ef6479 up wiki compare 2025-08-22 17:58:04 +02:00
Tykayn
ce508974c9 ajout de thèmes osmose pour places 2025-08-21 17:24:31 +02:00
Tykayn
e262a83687 proposition fix alerte osmose 2025-08-21 17:11:11 +02:00
Tykayn
503e6e9dac up liens osmose thème détails 2025-08-21 17:04:09 +02:00
Tykayn
38fbc451f5 add wiki compare 2025-08-21 16:50:17 +02:00
Tykayn
692e609a46 fetch des infos sur toutes les communes du pays 2025-08-21 16:08:22 +02:00
Tykayn
83d1972589 add wiki fraicheur comparée anglais français 2025-08-21 16:07:49 +02:00
Tykayn
0aaddb44c5 ajout analyses osmose dans les pages de détail 2025-08-21 16:07:02 +02:00
Tykayn
359d4ba6b8 ajout de ville sans labourage par défaut 2025-08-21 11:56:02 +02:00
Tykayn
6f4e6a6810 script d'analyse de plusieurs polygones de ville 2025-08-21 11:27:14 +02:00
Tykayn
5deda4a01d add logs to main city page 2025-08-18 12:58:11 +02:00
Tykayn
060b23f87e harmoniser les décomptes de completion sur la page stats 2025-08-18 12:41:31 +02:00
Tykayn
b9f57e48b5 arrondi completion sur ville 2025-08-12 12:01:49 +02:00
Tykayn
1659864efb add measure on checking advanced graph 2025-08-12 11:58:11 +02:00
Tykayn
46f8b3f6ab recompute percent completion if needed on load of city stats 2025-08-12 11:42:23 +02:00
Tykayn
b959c695ae up details theme graph page 2025-08-12 11:39:05 +02:00
Tykayn
af1233c246 page de détail, ajout de mesures 2025-08-12 11:23:29 +02:00
Tykayn
bcafef75f1 fix submit edit place 2025-08-09 11:47:23 +02:00
Tykayn
63dd364bcb add followup for restaurants 2025-08-09 11:04:25 +02:00
Tykayn
7527d5cf6c fix missing lieux in stat page 2025-08-08 19:04:42 +02:00
Tykayn
a81112a018 up command and labourage 2025-08-08 18:51:44 +02:00
Tykayn
8cfea30fdf commande pour créer des stats de toutes les villes insee
# Conflicts:
#	src/Command/ProcessLabourageQueueCommand.php
#	src/Controller/PublicController.php
2025-08-08 18:13:46 +02:00
Tykayn
dfeaf123f4 import city followup from csv 2025-08-08 18:09:29 +02:00
Tykayn
eee5d6349a commande pour enlever les duplications de Places 2025-08-02 15:14:13 +02:00
Tykayn
37fd162824 compute after labourage in command 2025-08-02 11:22:09 +02:00
Tykayn
d9b95f5c1a add compute completion after labourage 2025-08-02 11:19:57 +02:00
Tykayn
4890dc8a34 reste routes 2025-08-02 11:17:05 +02:00
Tykayn
46e3e55d57 remove rss links 2025-08-02 11:13:00 +02:00
Tykayn
a815183800 hide rss links 2025-08-02 11:10:02 +02:00
Tykayn
0e47b349a2 hide city demands 2025-08-02 11:09:26 +02:00
Tykayn
8733111897 hide rss nav 2025-08-02 11:08:59 +02:00
Tykayn
c7ddda4755 up nav 2025-08-02 11:08:21 +02:00
Tykayn
b84f1e9237 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-02 11:00:14 +02:00
Tykayn
6134381422 up commande de labourage 2025-08-02 10:59:49 +02:00
Tykayn
a695a8ef03 up 2025-07-28 16:37:50 +02:00
Tykayn
66bbce5e85 add computing from osm history 2025-07-27 18:01:24 +02:00
Tykayn
da60f964ab details in results on home 2025-07-20 16:54:09 +02:00
Tykayn
7887356dd9 up recherche home 2025-07-18 17:08:13 +02:00
Tykayn
c89751b45c up recherche home avec nominatim 2025-07-18 14:21:55 +02:00
Tykayn
7f79ec3a9f linking demandes 2025-07-16 23:01:13 +02:00
Tykayn
0aa050b38b panel latéral sur graphe avancé 2025-07-16 17:34:13 +02:00
Tykayn
2e459122b5 menu latéral ville 2025-07-16 17:31:15 +02:00
Tykayn
f4c5e048ff retapage accueil, gestion de Demandes 2025-07-16 17:00:09 +02:00
Tykayn
d777221d0d angle régression dashboard 2025-07-15 23:26:10 +02:00
Tykayn
0b760c20bc up infos vélo 2025-07-15 23:23:32 +02:00
Tykayn
c0a1780fce up labourage nouvelle ville 2025-07-15 21:46:30 +02:00
Tykayn
cc6c991090 fix graph list 2025-07-15 21:27:32 +02:00
Tykayn
aae37c6ce0 ajout thème rnb 2025-07-15 21:22:02 +02:00
Tykayn
3d767ffaae ajout page speed limit 2025-07-14 19:27:07 +02:00
Tykayn
a56c4b052c ajouts d'infos calculées au vol pour le graphe avancé vélo 2025-07-14 19:18:34 +02:00
Tykayn
979be016f2 liens de thème dans la page de graphe, thèmes bouche d'égout, micro bibliothèque, parc à jeux 2025-07-14 18:55:53 +02:00
Tykayn
0a5814011f doc cronjob labourage 2025-07-14 18:20:41 +02:00
Tykayn
1345cc903b up commande labourage queue 2025-07-14 18:17:41 +02:00
Tykayn
ca0ec580f5 up clean command 2025-07-14 17:40:34 +02:00
Tykayn
ae8b15e54c sélection de bus, commande pour enlever les villes dupliquées 2025-07-14 13:14:34 +02:00
Tykayn
6399e1f218 edit form préremplissage email et website 2025-07-13 18:41:50 +02:00
Tykayn
5c6a28df53 up labourage long noms 2025-07-13 18:11:38 +02:00
Tykayn
5b18e4fb08 remove debug submit 2025-07-13 18:05:07 +02:00
Tykayn
2572e045dc style medias sociaux 2025-07-13 18:04:30 +02:00
Tykayn
21d3a5dfc7 précision champs importants et manquants dans edit form 2025-07-13 18:01:50 +02:00
Tykayn
bf2c5bdf7d fix footer link 2025-07-13 17:18:54 +02:00
Tykayn
3fcc36f238 up submit en gardant les chamsp précédents, application de cuisine, clim et wheelchair 2025-07-13 17:03:19 +02:00
Tykayn
e96acf4ab8 addr in submit form 2025-07-13 16:59:45 +02:00
Tykayn
6707385ec9 add suggestions en footer 2025-07-12 14:42:16 +02:00
Tykayn
9eb08073d0 graph détaillé, listing clés courantes 2025-07-12 14:28:00 +02:00
Tykayn
b771aea541 liens de complétion de rue overpass 2025-07-12 13:49:40 +02:00
Tykayn
5490453764 affichage code postal et insee en recherche de ville 2025-07-12 13:45:09 +02:00
Tykayn
205a653bee carte graph avancé: centroides de batiments 2025-07-12 13:41:17 +02:00
Tykayn
ed9e5b6b47 emoji pour le suivi de thème 2025-07-12 13:32:08 +02:00
Tykayn
7166eb646a graph avancé, coloration et complétion des marqueurs 2025-07-12 13:17:41 +02:00
Tykayn
d76c06402d graphs avancés up cartes 2025-07-12 13:04:39 +02:00
Tykayn
c8e3cf2ada up pages rues et évolutions dans le temps 2025-07-12 12:53:06 +02:00
Tykayn
7355600e6b up stats par rue et dans le temps 2025-07-12 12:03:40 +02:00
Tykayn
cd6c14c378 gestion cuisine, page faq 2025-07-07 23:30:09 +02:00
Tykayn
2bcec59281 ajout infos formulaire envoi 2025-07-07 23:06:04 +02:00
Tykayn
44b4f49289 command remove zero followups 2025-07-05 17:47:00 +02:00
Tykayn
be97910177 MAJ de completion 2025-07-05 17:35:20 +02:00
Tykayn
813125a871 bloc expliquant la complétion sur la page de graphs followup 2025-07-05 17:22:37 +02:00
Tykayn
c7e4f4e6a2 centralisation des tags de complétion par thème 2025-07-05 17:21:18 +02:00
Tykayn
6cfb2f0958 up tableau thèmes 2025-07-05 16:53:12 +02:00
Tykayn
c5bf83a4f8 lien de modification dans le graphe détaillé 2025-07-05 16:34:37 +02:00
Tykayn
68c9ed283b remove var dump 2025-07-05 16:25:44 +02:00
Tykayn
d351c3d9e9 enlever debug au submit 2025-07-05 16:24:36 +02:00
Tykayn
5fe2804a1a carte thèmatique 2025-07-05 16:15:56 +02:00
Tykayn
2fd0d8d933 tests liens ctc 2025-07-05 15:25:33 +02:00
Tykayn
6f3d19245e presentation thèmes par ville 2025-07-05 14:31:50 +02:00
Tykayn
5188f12ad4 update graph linkings 2025-07-05 13:00:00 +02:00
Tykayn
a2936841f9 carte des villes et ajout de ville sur accueil 2025-07-05 12:55:33 +02:00
Tykayn
56f62c45bb map on home 2025-07-05 12:37:01 +02:00
Tykayn
a5cd69961f critères de followup plus amples 2025-07-05 11:48:56 +02:00
Tykayn
b5b2880637 enrich exports lat et lon 2025-07-05 10:59:37 +02:00
Tykayn
46d3b21cf6 export command et enddpoint pour les villes 2025-07-05 10:50:38 +02:00
Tykayn
c81affd3e3 calcul progression 2025-07-05 10:29:53 +02:00
Tykayn
2bbc7153c6 test page follow up unused stores ctc 2025-07-03 17:33:05 +02:00
Tykayn
00977d4fba fix pages zones 2025-07-03 10:28:49 +02:00
Tykayn
1c24ae1fea up labourage 2025-06-30 22:27:00 +02:00
Tykayn
7cde6a56aa ajout de lieux à labourer 2025-06-30 17:50:44 +02:00
Tykayn
0a20be186a simplification des followup 2025-06-30 16:02:53 +02:00
Tykayn
949dc71fb8 up décompte de suivi des lieux et arbres 2025-06-30 15:51:51 +02:00
Tykayn
6c457986ad ajout icones graphs thématiques 2025-06-30 15:33:43 +02:00
Tykayn
f1d2374119 up embed themes 2025-06-30 15:26:32 +02:00
Tykayn
f4e8c70ead thèmes en plus, graph embed 2025-06-30 15:03:37 +02:00
Tykayn
4300278f15 up décompte lieux 2025-06-29 20:02:51 +02:00
Tykayn
4fbdcfc704 ajout de suivi des parkings vélo 2025-06-29 19:38:54 +02:00
Tykayn
0611b28172 followup service refacto 2025-06-29 19:24:00 +02:00
Tykayn
1c2c3575f9 up global folloup 2025-06-29 18:41:26 +02:00
Tykayn
e3f8680472 suivi global 2025-06-29 18:32:24 +02:00
Tykayn
afc120ef2a add overpass query and josm 2025-06-29 17:23:54 +02:00
Tykayn
0cdb2f9ae9 ajout de followup sur plusieurs thèmes 2025-06-29 16:41:18 +02:00
Tykayn
ab1b9a9d3d follow up graphs 2025-06-29 15:56:55 +02:00
Tykayn
4b4a46c3f7 créer follow up des villes 2025-06-29 14:50:46 +02:00
Tykayn
10a984738f fix déclaration podium stats ville 2025-06-29 11:29:48 +02:00
Tykayn
e72922bc57 test mesure completion 2025-06-29 11:22:47 +02:00
Tykayn
d2c0326231 détection des arrondissements dans le dashboard 2025-06-29 11:03:41 +02:00
Tykayn
73e021c854 fix décompte de notes 2025-06-29 11:00:13 +02:00
Tykayn
8136d8e0cb fix export CSV 2025-06-29 10:29:41 +02:00
Tykayn
2ea7f7711f conteneur de tableau long 2025-06-29 10:22:24 +02:00
Tykayn
f50f2f87f5 fix charset place PDO 2025-06-29 10:09:47 +02:00
Tykayn
80bab14bf0 ajout podium par ville 2025-06-27 11:14:27 +02:00
Tykayn
c08b49fe48 fix error when not in error for edit form 2025-06-27 00:36:55 +02:00
Tykayn
b3d4064841 add logging data for edit form 2025-06-27 00:35:11 +02:00
Tykayn
f7d659119a up line tension graph 2025-06-27 00:23:21 +02:00
Tykayn
cb34fc3e1b up suivi 2025-06-27 00:12:14 +02:00
Tykayn
ad240bc1b7 up send form 2025-06-26 23:56:51 +02:00
Tykayn
6796d52119 log erreurs labourrage 2025-06-26 23:40:37 +02:00
Tykayn
12d4db370f add loggers actions 2025-06-26 23:14:22 +02:00
Tykayn
59398d14ba up score complétion normalisé dans le podium 2025-06-26 19:18:29 +02:00
Tykayn
c3a9bc52b2 ajout podium, fix stats manquantes 2025-06-26 18:20:43 +02:00
Tykayn
d2d2ebe0f0 fix labourage form stats 2025-06-25 00:32:46 +02:00
Tykayn
1ad909cb86 budget formaté en M€ sur le dashboard 2025-06-24 13:31:14 +02:00
Tykayn
acb71e45e0 up graph 2025-06-24 13:23:27 +02:00
Tykayn
93086eba60 bubble fraicheur des completions ajouté 2025-06-24 13:16:48 +02:00
Tykayn
cd8369d08c ajout de stats sur le budget des villes 2025-06-24 12:30:39 +02:00
Tykayn
1973f85dd4 guide lecture de graph 2025-06-24 00:32:31 +02:00
Tykayn
b41bbc9696 infos de fraicheur de données 2025-06-24 00:29:15 +02:00
Tykayn
4eb95d5b95 show back graphs 2025-06-23 23:49:48 +02:00
Tykayn
884c190ee5 ajout api controller, failsafe sur js bubble 2025-06-23 23:36:50 +02:00
Tykayn
adf9daa117 fix submit var 2025-06-23 22:41:45 +02:00
Tykayn
5b1eca615b up charset 2025-06-23 00:53:10 +02:00
Tykayn
31bbf6fc36 up missing js 2025-06-23 00:47:49 +02:00
Tykayn
f785e67f49 fix imports js 2025-06-23 00:38:13 +02:00
Tykayn
ada9fa4029 style for table sort, add more tags after sending 2025-06-23 00:01:38 +02:00
Tykayn
1895089ec9 ajout médias sociaux dans la page edit 2025-06-22 23:37:29 +02:00
Tykayn
c46a8304a9 up prise en compte de tags websites différents 2025-06-22 23:28:09 +02:00
Tykayn
1bde077df6 up graph home 2025-06-21 18:41:00 +02:00
Tykayn
68680e0569 refacto js, up graph 2025-06-21 18:37:31 +02:00
Tykayn
d66bc5e40c stats de ville prendre position des objets en base 2025-06-21 12:36:09 +02:00
Tykayn
711fc277be up stats dashboard 2025-06-21 12:24:06 +02:00
Tykayn
fd3827ee52 up historique 2025-06-21 11:48:20 +02:00
Tykayn
c274fd6a63 up historique 2025-06-21 11:28:31 +02:00
Tykayn
ad4170db14 add bubble chart, fix dashboard perf 2025-06-21 10:26:55 +02:00
226 changed files with 2020345 additions and 2778 deletions

1
#stats_export.json# Normal file

File diff suppressed because one or more lines are too long

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified"
xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="framework" type="frameworkType"/>
<xs:complexType name="commandType">
<xs:all>
<xs:element type="xs:string" name="name" minOccurs="1" maxOccurs="1"/>
<xs:element type="xs:string" name="params" minOccurs="0" maxOccurs="1"/>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
<xs:element type="optionsBeforeType" name="optionsBefore" minOccurs="0" maxOccurs="1"/>
</xs:all>
</xs:complexType>
<xs:complexType name="frameworkType">
<xs:sequence>
<xs:element type="xs:string" name="extraData" minOccurs="0" maxOccurs="1"/>
<xs:element type="commandType" name="command" maxOccurs="unbounded" minOccurs="0"/>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute type="xs:string" name="name" use="required"/>
<xs:attribute type="xs:string" name="invoke" use="required"/>
<xs:attribute type="xs:string" name="alias" use="required"/>
<xs:attribute type="xs:boolean" name="enabled" use="required"/>
<xs:attribute type="xs:integer" name="version" use="required"/>
<xs:attribute type="xs:string" name="frameworkId" use="optional"/>
</xs:complexType>
<xs:complexType name="optionsBeforeType">
<xs:sequence>
<xs:element type="optionType" name="option" maxOccurs="unbounded" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="optionType">
<xs:sequence>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute type="xs:string" name="name" use="required"/>
<xs:attribute type="xs:string" name="shortcut" use="optional"/>
<xs:attribute name="pattern" use="optional">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="space"/>
<xs:enumeration value="equals"/>
<xs:enumeration value="unknown"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:complexType>
</xs:schema>

View file

@ -2,9 +2,154 @@
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="App\" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="App\Tests\" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/vendor/composer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/collections" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/dbal" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/deprecations" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/event-manager" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/inflector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/instantiator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/lexer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/migrations" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/orm" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/persistence" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/sql-formatter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/egulias/email-validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/fakerphp/faker" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/guzzle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/promises" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/psr7" />
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
<excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nyholm/psr7" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/php-http/discovery" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-common" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-docblock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/type-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-text-template" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-timer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/phpunit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-factory" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-message" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/link" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ralouphie/getallheaders" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/comparator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/complexity" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/diff" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/environment" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/exporter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/global-state" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/lines-of-code" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-enumerator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-reflector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/recursion-context" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/resource-operations" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/type" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/asset" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/browser-kit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/cache-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/config" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/console" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/css-selector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/debug-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-messenger" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/expression-language" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/form" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/framework-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/messenger" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/notifier" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/phpunit-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-icu" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-idn" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-mbstring" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php83" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php84" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/process" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/property-access" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/property-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/routing" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/runtime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-core" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-csrf" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-link" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/webpack-encore-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
<excludeFolder url="file://$MODULE_DIR$/public/build" />
<excludeFolder url="file://$MODULE_DIR$/var" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

184
.idea/php.xml generated Normal file
View file

@ -0,0 +1,184 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpCodeSniffer">
<phpcs_settings>
<phpcs_by_interpreter asDefaultInterpreter="true" interpreter_id="9bdd9bda-8cdb-43f2-af13-070085f7956e" timeout="30000" />
</phpcs_settings>
</component>
<component name="PhpIncludePathManager">
<include_path>
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/php-http/discovery" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/nyholm/psr7" />
<path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/guzzle" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/promises" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit" />
<path value="$PROJECT_DIR$/vendor/sebastian/resource-operations" />
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
<path value="$PROJECT_DIR$/vendor/psr/http-client" />
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/link" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php83" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/webpack-encore-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-messenger" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/debug-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/notifier" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/form" />
<path value="$PROJECT_DIR$/vendor/symfony/messenger" />
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
<path value="$PROJECT_DIR$/vendor/fakerphp/faker" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
<component name="PhpStan">
<PhpStan_settings>
<phpstan_by_interpreter asDefaultInterpreter="true" interpreter_id="9bdd9bda-8cdb-43f2-af13-070085f7956e" timeout="60000" />
</PhpStan_settings>
</component>
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PhpUnit">
<phpunit_settings>
<PhpUnitSettings configuration_file_path="$PROJECT_DIR$/phpunit.xml.dist" custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" use_configuration_file="true" />
</phpunit_settings>
</component>
<component name="Psalm">
<Psalm_settings>
<psalm_fixer_by_interpreter asDefaultInterpreter="true" interpreter_id="9bdd9bda-8cdb-43f2-af13-070085f7956e" timeout="60000" />
</Psalm_settings>
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

10
.idea/phpunit.xml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PHPUnit">
<option name="directories">
<list>
<option value="$PROJECT_DIR$/tests" />
</list>
</option>
</component>
</project>

6
.idea/symfony2.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Symfony2PluginSettings">
<option name="pluginEnabled" value="true" />
</component>
</project>

334
README.md
View file

@ -6,11 +6,11 @@ Configurer .env.local pour mettre le token bearer d'un compte dédié
installer les dépendances avec composer
déployer sur un serveur ayant du php 8
# Dépendances
# Dépendances
- PHP 8.1 ou supérieur
- Composer
- PostgreSQL 13 ou supérieur, ou Mysql / MariaDB
- PostgreSQL 13 ou supériesdfsdffgdfgfdgur, ou Mysql / MariaDB
- Symfony 7.2
- Extensions PHP requises :
- pdo_pgsql
@ -18,7 +18,7 @@ déployer sur un serveur ayant du php 8
- intl
- mbstring
# base de données
# Base de données
créer un utilisateur et sa base
@ -36,5 +36,333 @@ CREATE USER 'sf'@'localhost' IDENTIFIED BY 'sfrgdHYJi56631lyshFSQGfd45452ùwdf54
CREATE DATABASE `osm-my-commerce`;
GRANT ALL PRIVILEGES ON `osm-my-commerce`.* TO 'sf'@'localhost';
FLUSH PRIVILEGES;
```
# Installation et configuration
## 1. Installation des dépendances
```shell
composer install
npm install
```
## 2. Configuration de l'environnement
Créer un fichier `.env.local` avec les variables suivantes :
```env
DATABASE_URL="mysql://sf:sfrgdHYJi56631lyshFSQGfd45452ùwdf54f8fg5dfhg5_tyfdgthIOPHFUGH@127.0.0.1:3306/osm-my-commerce?serverVersion=8.0.32&charset=utf8mb4"
# ou pour PostgreSQL :
# DATABASE_URL="postgresql://sf:sfrgdHYJi56631lyshFSQGfd45452ùwdf54f8fg5dfhg5_tyfdgthIOPHFUGH@127.0.0.1:5432/osm-my-commerce?serverVersion=15&charset=utf8"
# Token OSM pour les modifications
OSM_TOKEN="votre_token_osm_ici"
```
## 3. Migrations de base de données
### Créer une nouvelle migration
```shell
php bin/console make:migration
```
### Exécuter les migrations
```shell
php bin/console doctrine:migrations:migrate
```
### Voir le statut des migrations
```shell
php bin/console doctrine:migrations:status
```
### Annuler la dernière migration
```shell
php bin/console doctrine:migrations:migrate prev
```
# Commandes custom Symfony
## Commandes de gestion des données
### Mise à jour des coordonnées des villes
Récupère et stocke les coordonnées lat/lon pour toutes les villes dans la base de données :
```shell
php bin/console app:update-city-coordinates
```
### Mise à jour des Stats avec kind vide
Change les Stats qui ont un kind vide (NULL) pour leur mettre "user" en kind et les enregistre :
```shell
php bin/console app:update-empty-stats-kind
```
### Test du budget
Teste le calcul du budget pour une ville donnée :
```shell
php bin/console app:test-budget [insee_code]
```
### Labourage d'une ville
Ajoute une nouvelle ville à la base de données avec son code INSEE :
```shell
php bin/console app:labourage [insee_code]
```
### Création des Stats manquantes à partir du CSV
Examine le fichier CSV des communes et crée des objets Stats pour les communes qui n'en ont pas encore :
```shell
php bin/console app:create-missing-stats-from-csv [options]
```
Options disponibles :
- `--limit=N` ou `-l N` : Limite le nombre de communes à traiter
- `--dry-run` : Simule sans modifier la base de données
Cette commande utilise le fichier `communes_france.csv` à la racine du projet et crée des objets Stats pour les communes qui n'en ont pas encore. Les objets sont créés avec les informations du CSV et complétés avec des données supplémentaires (coordonnées, budget, etc.). Les objets sont sauvegardés par paquets de 100 pour optimiser les performances.
## Génération de statistiques pour toute la France
Le projet inclut un ensemble de commandes Symfony qui permettent de générer des statistiques de complétion pour toutes les communes de France. Ces commandes doivent être exécutées dans l'ordre suivant :
### 1. Récupération des polygones des villes
Récupère les polygones des villes selon leur zone donnée par le code INSEE :
```shell
php bin/console app:retrieve-city-polygons [insee-code] [options]
```
Arguments :
- `insee-code` : (Optionnel) Code INSEE spécifique à traiter
Options :
- `--limit=N` ou `-l N` : Limite le nombre de villes à traiter
- `--force` ou `-f` : Force la récupération même si le polygone existe déjà
Cette commande :
- Crée le dossier `counting_osm_objects/polygons` s'il n'existe pas
- Utilise le script Python `get_poly.py` pour récupérer les polygones des communes
- Affiche une barre de progression et un résumé des résultats
### 2. Extraction des données OSM pour chaque zone INSEE
Extrait les données OSM pour chaque zone INSEE à partir du fichier france-latest.osm.pbf :
```shell
php bin/console app:extract-insee-zones [insee-code] [options]
```
Arguments :
- `insee-code` : (Optionnel) Code INSEE spécifique à traiter
Options :
- `--limit=N` ou `-l N` : Limite le nombre de villes à traiter
- `--force` ou `-f` : Force l'extraction même si le fichier JSON existe déjà
- `--keep-pbf` ou `-k` : Conserve les fichiers PBF intermédiaires
Cette commande :
- Télécharge automatiquement le fichier france-latest.osm.pbf depuis Geofabrik s'il n'existe pas
- Crée le dossier `insee_extracts` s'il n'existe pas
- Utilise osmium pour extraire les données OSM pour chaque zone INSEE
- Convertit les données extraites en format JSON
- Affiche une barre de progression et un résumé des résultats
### 3. Traitement des extraits JSON pour calculer les mesures de thèmes
Traite les extraits JSON des zones INSEE pour calculer les mesures de thèmes :
```shell
php bin/console app:process-insee-extracts [insee-code] [options]
```
Arguments :
- `insee-code` : (Optionnel) Code INSEE spécifique à traiter
Options :
- `--limit=N` ou `-l N` : Limite le nombre de villes à traiter
- `--force` ou `-f` : Force le traitement même si déjà effectué
Cette commande :
- Utilise le service Motocultrice pour traiter les données
- Met à jour la date de labourage dans l'entité Stats
- Affiche une barre de progression et un résumé des résultats
### Exemple d'utilisation pour générer des statistiques pour toute la France
```shell
# 1. Récupérer les polygones de toutes les communes
php bin/console app:retrieve-city-polygons
# 2. Extraire les données OSM pour chaque zone INSEE
php bin/console app:extract-insee-zones
# 3. Traiter les extraits JSON pour calculer les mesures de thèmes
php bin/console app:process-insee-extracts
```
Pour traiter une seule commune (par exemple avec le code INSEE 75056 pour Paris) :
```shell
php bin/console app:retrieve-city-polygons 75056
php bin/console app:extract-insee-zones 75056
php bin/console app:process-insee-extracts 75056
```
### Dépendances
Pour exécuter ces commandes, vous aurez besoin de :
- Python 3 avec les bibliothèques requises pour `get_poly.py`
- Osmium Tool (`osmium`) installé sur votre système
- Suffisamment d'espace disque pour stocker le fichier france-latest.osm.pbf (~4 Go) et les extraits JSON
# Routes d'administration
## Création des Stats manquantes à partir du CSV
Examine le fichier CSV des communes et crée des objets Stats pour les communes manquantes :
```
/admin/create-missing-stats-from-csv
```
Cette route lit le fichier `communes_france.csv` à la racine du projet et crée des objets Stats pour les communes qui n'en ont pas encore. Les objets sont créés avec les informations du CSV uniquement (sans labourage) et sont sauvegardés par paquets de 100.
Pour plus de détails, consultez la [documentation dédiée](docs/create_missing_stats.md).
## Commandes de maintenance
### Nettoyage du cache
```shell
php bin/console cache:clear
```
### Validation du schéma de base de données
```shell
php bin/console doctrine:schema:validate
```
### Mise à jour du schéma de base de données
```shell
php bin/console doctrine:schema:update --force
```
# Fonctionnalités principales
## Interface publique
- **Page d'accueil** : Carte interactive des villes avec taux de complétion
- **Ajouter ma ville** : Formulaire pour ajouter une nouvelle ville
- **Statistiques par ville** : Graphiques détaillés de progression
- **Graphiques thématiques** : Suivi spécifique par type d'objet (ex: arrêts de bus, bornes de recharge, etc.)
## Interface d'administration
- **Tableau de bord** : Vue d'ensemble des statistiques
- **Gestion des villes** : Ajout, modification, suppression
- **Suivi des modifications** : Historique des changements
- **Export de données** : CSV, JSON, API Overpass
## API et intégrations
- **API Overpass** : Récupération de données OSM
- **Nominatim** : Géocodage des adresses
- **Addok** : Service de géocodage alternatif
# Structure du projet
```
src/
├── Command/ # Commandes custom Symfony
├── Controller/ # Contrôleurs (public et admin)
├── Entity/ # Entités Doctrine
├── Repository/ # Repositories Doctrine
├── Service/ # Services métier
└── Kernel.php # Configuration du kernel
templates/
├── admin/ # Templates d'administration
├── public/ # Templates publics
└── base.html.twig # Template de base
assets/ # Assets frontend (JS, CSS)
config/ # Configuration Symfony
migrations/ # Migrations de base de données
```
# Développement
## Ajouter une nouvelle commande
```shell
php bin/console make:command NomCommande
```
## Ajouter une nouvelle entité
```shell
php bin/console make:entity NomEntite
```
## Ajouter un nouveau contrôleur
```shell
php bin/console make:controller NomController
```
## Tests
```shell
php bin/phpunit
```
# Déploiement
## Variables d'environnement de production
- `APP_ENV=prod`
- `APP_SECRET=clé_secrète_ici`
- `DATABASE_URL=url_de_la_base_production`
- `OSM_TOKEN=token_osm_production`
## Optimisations
```shell
composer install --no-dev --optimize-autoloader
php bin/console cache:clear --env=prod
```
## Labourage différé des villes
Depuis la version X, le labourage (mise à jour des lieux OSM pour une ville) peut être différé automatiquement si le serveur manque de RAM.
- Lorsqu'un admin demande un labourage, la date de requête (`date_labourage_requested`) est enregistrée.
- Si le serveur dispose d'au moins 1 Go de RAM libre, le labourage est effectué immédiatement (création/mise à jour des objets Place).
- Sinon, seul le suivi (CityFollowUp) est mis à jour, et un message informe que la mise à jour des lieux sera différée.
- Une commande cron (`php bin/console app:process-labourage-queue`) traite les villes en attente dès que possible, en respectant la RAM disponible.
### Lancer le cron de labourage
Ajoutez dans votre crontab :
```
* * * * * cd /chemin/vers/le/projet && php bin/console app:process-labourage-queue >> var/log/labourage_cron.log 2>&1
```
La commande traite la ville la plus ancienne en attente de labourage, si les ressources le permettent.
### Exemple de cron pour traiter 300 villes par jour
Pour labourer environ 300 villes par jour, il faut lancer la commande toutes les 5 minutes (24h * 60min / 5 = 288 passages par jour) :
```
*/5 * * * * cd /poule/encrypted/www/osm-commerces && php bin/console app:process-labourage-queue >> var/log/labourage_cron.log 2>&1
```
Chaque exécution traite une ville si les ressources le permettent. En adaptant la fréquence, vous pouvez ajuster le débit de traitement.
### Propriétés Stats
- `date_labourage_requested` : date de la dernière demande de labourage
- `date_labourage_done` : date du dernier labourage effectif
### Remarques
- Les CityFollowUp ne sont plus supprimés lors des labourages.
- Le système garantit que les villes sont mises à jour dès que possible sans surcharger le serveur.
# Analyses complémentaires
## Analyse de l'historique des objets dans les villes
dossier counting_osm_objects
## Inspection de la fraîcheur des traductions de wiki
Le dossier `wiki_compare` contient des scripts pour analyser les pages wiki d'OpenStreetMap, identifier celles qui ont besoin de mises à jour ou de traductions, et publier des suggestions sur Mastodon.
### Scripts disponibles
- **wiki_compare.py** : Récupère les 10 clés OSM les plus utilisées, compare leurs pages wiki en anglais et en français, et identifie celles qui ont besoin de mises à jour.
- **post_outdated_page.py** : Sélectionne aléatoirement une page wiki française qui n'est pas à jour et publie un message sur Mastodon pour suggérer sa mise à jour.
- **suggest_translation.py** : Identifie les pages wiki anglaises qui n'ont pas de traduction française et publie une suggestion de traduction sur Mastodon.
### Utilisation
Consultez le [README du dossier wiki_compare](wiki_compare/README.md) pour plus de détails sur l'installation, la configuration et l'utilisation de ces scripts.

View file

@ -7,16 +7,64 @@
// any CSS you import will output into a single css file (app.css in this case)
import './styles/app.css';
import jQuery from 'jquery';
window.$ = jQuery;
window.jQuery = jQuery;
import 'tablesort/tablesort.css';
// start the Stimulus application
// import './bootstrap';
import './utils.js';
import './opening_hours.js';
import './josm.js';
import './edit.js';
import './table-map-toggles.js';
import './stats-charts.js';
import './dashboard-charts.js';
import Chart from 'chart.js/auto';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import maplibregl from 'maplibre-gl';
import {
genererCouleurPastel,
setupCitySearch,
handleAddCityFormSubmit,
enableLabourageForm,
getLabourerUrl,
adjustListGroupFontSize,
toggleCompletionInfo,
updateMapHeightForLargeScreens
} from './utils.js';
import tableSortJs from 'table-sort-js/table-sort.js';
import 'chartjs-adapter-date-fns';
console.log('TableSort', tableSortJs)
// Charger table-sortable (version non minifiée locale)
// import '../assets/js/table-sortable.js';
window.Chart = Chart;
window.genererCouleurPastel = genererCouleurPastel;
window.setupCitySearch = setupCitySearch;
window.handleAddCityFormSubmit = handleAddCityFormSubmit;
window.getLabourerUrl = getLabourerUrl;
window.ChartDataLabels = ChartDataLabels;
window.maplibregl = maplibregl;
window.toggleCompletionInfo = toggleCompletionInfo;
window.updateMapHeightForLargeScreens = updateMapHeightForLargeScreens;
// window.Tablesort = Tablesort;
Chart.register(ChartDataLabels);
// Attendre le chargement du DOM
document.addEventListener('DOMContentLoaded', () => {
console.log('DOMContentLoaded');
if(updateMapHeightForLargeScreens){
window.addEventListener('resize', updateMapHeightForLargeScreens);
}
const randombg = genererCouleurPastel();
// Appliquer la couleur au body
@ -74,26 +122,19 @@ document.addEventListener('DOMContentLoaded', () => {
updateCompletionProgress();
// Focus sur le premier champ texte au chargement
const firstTextInput = document.querySelector('input.form-control');
if (firstTextInput) {
firstTextInput.focus();
console.log('focus sur le premier champ texte', firstTextInput);
} else {
console.log('pas de champ texte trouvé');
}
// const firstTextInput = document.querySelector('input.form-control');
// if (firstTextInput) {
// firstTextInput.focus();
// console.log('focus sur le premier champ texte', firstTextInput);
// } else {
// console.log('pas de champ texte trouvé');
// }
parseCuisine();
// Tri automatique des tableaux
const tables = document.querySelectorAll('table');
tables.forEach(table => {
table.classList.add('js-sort-table');
});
// Modifier la fonction de recherche existante
const searchInput = document.getElementById('app_admin_labourer');
const suggestionList = document.getElementById('suggestionList');
@ -154,4 +195,166 @@ document.addEventListener('DOMContentLoaded', () => {
}, 300);
});
}
if(enableLabourageForm){
enableLabourageForm();
}
adjustListGroupFontSize('.list-group-item');
// Activer le tri naturel sur tous les tableaux avec la classe table-sort
if (tableSortJs) {
tableSortJs();
}else{
console.log('pas de tablesort')
}
// Initialisation du tri et filtrage sur les tableaux du dashboard et de la page stats
// if (document.querySelector('#dashboard-table')) {
// $('#dashboard-table').tableSortable({
// pagination: false,
// showPaginationLabel: true,
// searchField: '#dashboard-table-search',
// responsive: false
// });
// }
// if (document.querySelector('#stats-table')) {
// $('#stats-table').tableSortable({
// pagination: false,
// showPaginationLabel: true,
// searchField: '#stats-table-search',
// responsive: false
// });
// }
// Correction pour le formulaire de labourage
const labourerForm = document.getElementById('labourerForm');
if (labourerForm) {
labourerForm.addEventListener('submit', async function(e) {
e.preventDefault();
const zipInput = document.getElementById('selectedZipCode');
const cityInput = document.getElementById('citySearch');
let insee = zipInput.value;
if (!insee && cityInput && cityInput.value.trim().length > 0) {
// Recherche du code INSEE via l'API
const response = await fetch(`https://geo.api.gouv.fr/communes?nom=${encodeURIComponent(cityInput.value.trim())}&fields=nom,code&limit=1`);
const data = await response.json();
if (data.length > 0) {
insee = data[0].code;
}
}
if (insee) {
window.location.href = `/add-city-without-labourage/${insee}`;
} else {
alert('Veuillez sélectionner une ville valide.');
}
});
}
// Ajouter un écouteur pour l'événement 'load' de MapLibre afin d'ajuster la hauteur de la carte
if (window.maplibregl && document.getElementById('map')) {
// On suppose que la carte est initialisée ailleurs et accessible via window.mapInstance
// Sinon, on peut essayer de détecter l'instance automatiquement
let mapInstance = window.mapInstance;
if (!mapInstance && window.maplibreMap) {
mapInstance = window.maplibreMap;
}
// Si l'instance n'est pas trouvée, essayer de la récupérer via une variable globale courante
if (!mapInstance && window.map) {
mapInstance = window.map;
}
if (mapInstance && typeof mapInstance.on === 'function') {
mapInstance.on('load', function() {
updateMapHeightForLargeScreens();
});
}
}
//updateMapHeightForLargeScreens();
console.log('window.followupSeries',window.followupSeries)
if (!window.followupSeries) return;
const series = window.followupSeries;
// Données bornes de recharge
const chargingStationCount = (series['charging_station_count'] || []).map(point => ({ x: point.date, y: point.value }));
const chargingStationCompletion = (series['charging_station_completion'] || []).map(point => ({ x: point.date, y: point.value }));
// Données bornes incendie
const fireHydrantCount = (series['fire_hydrant_count'] || []).map(point => ({ x: point.date, y: point.value }));
const fireHydrantCompletion = (series['fire_hydrant_completion'] || []).map(point => ({ x: point.date, y: point.value }));
// Graphique bornes de recharge
const chargingStationChart = document.getElementById('chargingStationChart');
if (chargingStationChart) {
new Chart(chargingStationChart, {
type: 'line',
data: {
datasets: [
{
label: 'Nombre de bornes de recharge',
data: chargingStationCount,
borderColor: 'blue',
backgroundColor: 'rgba(0,0,255,0.1)',
fill: false,
yAxisID: 'y',
},
{
label: 'Complétion (%)',
data: chargingStationCompletion,
borderColor: 'green',
backgroundColor: 'rgba(0,255,0,0.1)',
fill: false,
yAxisID: 'y1',
}
]
},
options: {
parsing: false,
responsive: true,
scales: {
x: { type: 'time', time: { unit: 'day' }, title: { display: true, text: 'Date' } },
y: { beginAtZero: true, title: { display: true, text: 'Nombre' } },
y1: { beginAtZero: true, position: 'right', title: { display: true, text: 'Complétion (%)' }, grid: { drawOnChartArea: false } }
}
}
});
}
// Graphique bornes incendie
const fireHydrantChart = document.getElementById('fireHydrantChart');
if (fireHydrantChart) {
new Chart(fireHydrantChart, {
type: 'line',
data: {
datasets: [
{
label: 'Nombre de bornes incendie',
data: fireHydrantCount,
borderColor: 'red',
backgroundColor: 'rgba(255,0,0,0.1)',
fill: false,
yAxisID: 'y',
},
{
label: 'Complétion (%)',
data: fireHydrantCompletion,
borderColor: 'orange',
backgroundColor: 'rgba(255,165,0,0.1)',
fill: false,
yAxisID: 'y1',
}
]
},
options: {
parsing: false,
responsive: true,
scales: {
x: { type: 'time', time: { unit: 'day' }, title: { display: true, text: 'Date' } },
y: { beginAtZero: true, title: { display: true, text: 'Nombre' } },
y1: { beginAtZero: true, position: 'right', title: { display: true, text: 'Complétion (%)' }, grid: { drawOnChartArea: false } }
}
}
});
}
});

229
assets/dashboard-charts.js Normal file
View file

@ -0,0 +1,229 @@
// Bubble chart du dashboard avec option de taille de bulle proportionnelle ou égale
let bubbleChart = null; // Déclaré en dehors pour garder la référence
function waitForChartAndDrawBubble() {
if (!window.Chart || !window.ChartDataLabels) {
setTimeout(waitForChartAndDrawBubble, 50);
return;
}
const chartCanvas = document.getElementById('bubbleChart');
const toggle = document.getElementById('toggleBubbleSize');
function drawBubbleChart(proportional) {
// Détruire toute instance Chart.js existante sur ce canvas (Chart.js v3+)
const existing = window.Chart.getChart(chartCanvas);
if (existing) {
try { existing.destroy(); } catch (e) { console.warn('Erreur destroy Chart:', e); }
}
if (bubbleChart) {
try { bubbleChart.destroy(); } catch (e) { console.warn('Erreur destroy Chart:', e); }
bubbleChart = null;
}
// Forcer le canvas à occuper toute la largeur/hauteur du conteneur en pixels
if (chartCanvas && chartCanvas.parentElement) {
const parentRect = chartCanvas.parentElement.getBoundingClientRect();
console.log('parentRect', parentRect)
chartCanvas.width = (parentRect.width);
chartCanvas.height = (parentRect.height);
chartCanvas.style.width = parentRect.width + 'px';
chartCanvas.style.height = parentRect.height + 'px';
}
if(!getBubbleData){
console.log('pas de getBubbleData')
return ;
}
const bubbleChartData = getBubbleData(proportional);
if(!bubbleChartData){
console.log('pas de bubbleChartData')
return ;
}
// Calcul de la régression linéaire (moindres carrés)
// On ne fait la régression que si on veut, mais l'axe X = fraicheur, Y = complétion
const validPoints = bubbleChartData.filter(d => d.x !== null && d.y !== null);
const n = validPoints.length;
let regressionLine = null, slope = 0, intercept = 0;
if (n >= 2) {
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
validPoints.forEach(d => {
sumX += d.x;
sumY += d.y;
sumXY += d.x * d.y;
sumXX += d.x * d.x;
});
const meanX = sumX / n;
const meanY = sumY / n;
slope = (sumXY - n * meanX * meanY) / (sumXX - n * meanX * meanX);
intercept = meanY - slope * meanX;
const xMin = Math.min(...validPoints.map(d => d.x));
const xMax = Math.max(...validPoints.map(d => d.x));
regressionLine = [
{ x: xMin, y: slope * xMin + intercept },
{ x: xMax, y: slope * xMax + intercept }
];
}
window.Chart.register(window.ChartDataLabels);
bubbleChart = new window.Chart(chartCanvas.getContext('2d'), {
type: 'bubble',
data: {
datasets: [
{
label: 'Villes',
data: bubbleChartData,
backgroundColor: bubbleChartData.map(d => `rgba(94, 255, 121, ${d.completion / 100})`),
borderColor: 'rgb(94, 255, 121)',
datalabels: {
anchor: 'center',
align: 'center',
color: '#000',
display: true,
font: { weight: '400', size : "12px" },
formatter: (value, context) => {
return context.dataset.data[context.dataIndex].label;
}
}
},
regressionLine ? {
label: 'Régression linéaire',
type: 'line',
data: regressionLine,
borderColor: 'rgba(95, 168, 0, 0.7)',
borderWidth: 2,
pointRadius: 0,
fill: false,
order: 0,
tension: 0,
datalabels: { display: false }
} : null
].filter(Boolean)
},
options: {
plugins: {
datalabels: {
display: false
},
legend: { display: true },
tooltip: {
callbacks: {
label: (context) => {
const d = context.raw;
if (context.dataset.type === 'line') {
return `Régression: y = ${slope.toFixed(2)} × x + ${intercept.toFixed(2)}`;
}
return [
`${d.label}`,
`Fraîcheur moyenne: ${d.freshnessDays ? d.freshnessDays.toLocaleString() + ' jours' : 'N/A'}`,
`Complétion: ${d.y.toFixed(2)}%`,
`Population: ${d.population ? d.population.toLocaleString() : 'N/A'}`,
`Nombre de lieux: ${d.r.toFixed(2)}`,
`Budget: ${d.budget ? d.budget.toLocaleString() + ' €' : 'N/A'}`,
`Budget/habitant: ${d.budgetParHabitant ? d.budgetParHabitant.toFixed(2) + ' €' : 'N/A'}`,
`Budget/lieu: ${d.budgetParLieu ? d.budgetParLieu.toFixed(2) + ' €' : 'N/A'}`
];
}
}
}
},
scales: {
x: {
type: 'linear',
title: { display: true, text: 'Fraîcheur moyenne (jours, plus petit = plus récent)' }
},
y: {
title: { display: true, text: 'Taux de complétion (%)' },
min: 0,
max: 100
}
}
}
});
// Ajout du clic sur une bulle
chartCanvas.onclick = function(evt) {
const points = bubbleChart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true);
if (points.length > 0) {
const firstPoint = points[0];
const dataIndex = firstPoint.index;
const stat = window.statsDataForBubble[dataIndex];
if (stat && stat.zone) {
window.location.href = '/admin/stats/' + stat.zone;
}
}
};
}
// Initial draw
console.log('[bubble chart] Initialisation avec taille proportionnelle ?', toggle?.checked);
if(drawBubbleChart){
drawBubbleChart(toggle && toggle.checked);
// Listener
toggle?.addEventListener('change', function() {
console.log('[bubble chart] Toggle changé, taille proportionnelle ?', toggle?.checked);
drawBubbleChart(toggle?.checked);
});
}
}
function getBubbleData(proportional) {
// Générer les données puis trier par rayon décroissant
const data = window.statsDataForBubble?.map(stat => {
const population = parseInt(stat.population, 10);
const placesCount = parseInt(stat.placesCount, 10);
const completion = parseInt(stat.completionPercent, 10);
// Fraîcheur moyenne : âge moyen en jours (plus récent à droite)
let freshnessDays = null;
if (stat.osmDataDateAvg) {
const now = new Date();
const avgDate = new Date(stat.osmDataDateAvg);
freshnessDays = Math.round((now - avgDate) / (1000 * 60 * 60 * 24));
}
// Pour l'axe X, on veut que les plus récents soient à droite (donc X = -freshnessDays)
const x = freshnessDays !== null ? -freshnessDays : 0;
// Budget
const budget = stat.budgetAnnuel ? parseFloat(stat.budgetAnnuel) : null;
const budgetParHabitant = (budget && population) ? budget / population : null;
const budgetParLieu = (budget && placesCount) ? budget / placesCount : null;
return {
x: x,
y: completion,
r: proportional ? Math.sqrt(placesCount) * 2 : 12,
label: stat.name,
completion: stat.completionPercent || 0,
zone: stat.zone,
budget,
budgetParHabitant,
budgetParLieu,
population,
placesCount,
freshnessDays
};
});
// Trier du plus gros au plus petit rayon
if(data){
data.sort((a, b) => b.r - a.r);
}
return data;
}
document.addEventListener('DOMContentLoaded', function() {
waitForChartAndDrawBubble();
// Forcer deleteMissing=1 sur le formulaire de labourage
const labourerForm = document.getElementById('labourerForm');
if (labourerForm) {
labourerForm.addEventListener('submit', function(e) {
e.preventDefault();
const zipCode = document.getElementById('selectedZipCode').value;
if (zipCode) {
window.location.href = '/admin/labourer/' + zipCode + '?deleteMissing=1';
}
});
}
});

View file

@ -4,13 +4,36 @@
* pour le formulaire de modification
*/
function updateCompletionProgress() {
const inputs = document.querySelectorAll('input[type="text"]');
const inputs = document.querySelectorAll('input[data-important]');
let filledInputs = 0;
let totalInputs = inputs.length;
let missingFields = [];
inputs.forEach(input => {
if (input.value.trim() !== '') {
filledInputs++;
} else {
// Get the field label or name for display in the popup
let fieldName = '';
const label = input.closest('.row')?.querySelector('.form-label, .label-translated');
if (label) {
fieldName = label.textContent.trim();
} else {
// If no label found, try to get a meaningful name from the input
const name = input.getAttribute('name');
if (name) {
// Extract field name from the attribute (e.g., commerce_tag_value__contact:email -> Email)
const parts = name.split('__');
if (parts.length > 1) {
fieldName = parts[1].replace('commerce_tag_value_', '').replace('contact:', '');
// Capitalize first letter
fieldName = fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
} else {
fieldName = name;
}
}
}
missingFields.push(fieldName || input.getAttribute('name') || 'Champ inconnu');
}
});
@ -19,7 +42,64 @@ function updateCompletionProgress() {
if (progressBar) {
progressBar.style.width = completionPercentage + '%';
progressBar.setAttribute('aria-valuenow', completionPercentage);
document.querySelector('#completion_display').innerHTML = `Votre commerce est complété à ${Math.round(completionPercentage)}%`;
// Create the completion display with a clickable question mark
const displayElement = document.querySelector('#completion_display');
// Format missing fields as an HTML list for better readability
let missingFieldsContent = '';
if (missingFields.length > 0) {
missingFieldsContent = '<ul class="list-unstyled mb-0">';
// Filter out empty or undefined field names and sort them alphabetically
missingFields
.filter(field => field && field.trim() !== '')
.sort()
.forEach(field => {
missingFieldsContent += `<li><i class="bi bi-exclamation-circle text-warning"></i> ${field}</li>`;
});
missingFieldsContent += '</ul>';
} else {
missingFieldsContent = 'Tous les champs importants sont remplis !';
}
displayElement.innerHTML = `Votre commerce est complété à ${Math.round(completionPercentage)}% <a href="#" class="missing-fields-info badge rounded-pill bg-warning text-dark ms-1" style="text-decoration: none; font-weight: bold;" data-bs-toggle="popover" data-bs-placement="bottom" title="Champs manquants" data-bs-html="true" data-bs-content="${missingFieldsContent.replace(/"/g, '&quot;')}">?</a>`;
// Initialize the Bootstrap popover
const popoverTrigger = displayElement.querySelector('.missing-fields-info');
if (popoverTrigger) {
// Add click handler to focus on the first missing field
popoverTrigger.addEventListener('click', function(e) {
e.preventDefault(); // Prevent scrolling to top
// Find the first missing field
const missingInput = document.querySelector('input[data-important]:not(.good_filled)');
if (missingInput) {
// Focus on the first missing field
missingInput.focus();
// Scroll to the field if needed
missingInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
// Use setTimeout to ensure this runs after the current execution context
setTimeout(() => {
if (typeof bootstrap !== 'undefined' && bootstrap.Popover) {
// Destroy existing popover if any
const existingPopover = bootstrap.Popover.getInstance(popoverTrigger);
if (existingPopover) {
existingPopover.dispose();
}
// Initialize new popover
new bootstrap.Popover(popoverTrigger, {
html: true,
trigger: 'click',
container: 'body'
});
} else {
console.warn('Bootstrap popover not available');
}
}, 0);
}
}
}

File diff suppressed because one or more lines are too long

0
assets/js/table-sortable.min.js vendored Normal file
View file

44
assets/stats-charts.js Normal file
View file

@ -0,0 +1,44 @@
// Affichage du graphique de fréquence des mises à jour par trimestre sur la page stats d'une ville
document.addEventListener('DOMContentLoaded', function() {
function drawModificationsByQuarterChart() {
if (!window.Chart) {
setTimeout(drawModificationsByQuarterChart, 50);
return;
}
if (typeof window.modificationsByQuarter === 'undefined') return;
const modifData = window.modificationsByQuarter;
const modifLabels = Object.keys(modifData);
const modifCounts = Object.values(modifData);
const modifCanvas = document.getElementById('modificationsByQuarterChart');
if (modifCanvas && modifLabels.length > 0) {
new window.Chart(modifCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: modifLabels,
datasets: [{
label: 'Nombre de lieux modifiés',
data: modifCounts,
backgroundColor: 'rgba(54, 162, 235, 0.7)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
title: { display: true, text: 'Fréquence des mises à jour par trimestre' }
},
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Nombre de lieux' } },
x: { title: { display: true, text: 'Trimestre' } }
}
}
});
} else if (modifCanvas) {
modifCanvas.parentNode.innerHTML = '<div class="alert alert-info">Aucune donnée de modification disponible pour cette ville.</div>';
}
}
drawModificationsByQuarterChart();
});

View file

@ -19,18 +19,33 @@ body {
margin-bottom: 8rem;
}
.filled {
background-color: rgba(0, 255, 0, 0.2) !important;
input[data-important] {
border-color: #7a8fbb;
border-left-width: 5px;
}
.filled:hover {
background-color: #8abb7a !important;
input[data-important]:before {
content: ">" !important;
}
.filled, .good_filled {
border-color: rgba(0, 255, 0, 0.8) !important;
color: #082b0a !important;
}
.filled:hover, .good_filled:hover {
background-color: #d9ffd1 !important;
}
.no-name {
color: #df5a0d;
}
table {
max-height: 100vh;
max-width: 100vw;
}
table.js-sort-table th {
cursor: pointer;
}
@ -140,6 +155,11 @@ img {
max-height: 400px;
}
#completionHistoryChart {
min-height: 500px;
}
@media (max-width: 768px) {
.form-label {
margin-bottom: 0.5rem;
@ -154,3 +174,19 @@ table tbody {
max-height: 700px;
overflow: auto;
}
#table_container, .table-container, #table-container {
max-height: 700px;
overflow: auto;
display: block;
border: solid 3px rgb(255, 255, 255);
}
#citySuggestions {
z-index: 10;
}
body .card:hover {
transform: none !important;
box-shadow: none !important;
}

View file

@ -0,0 +1,19 @@
// Gestion du tri des tableaux
// import Tablesort from 'tablesort';
document.addEventListener('DOMContentLoaded', () => {
// Gestion du toggle gouttes/ronds sur la carte
const toggle = document.getElementById('toggleMarkers');
if (toggle && window.updateMarkers) {
toggle.addEventListener('change', (e) => {
window.updateMarkers(e.target.value);
});
}
});
// Exposer une fonction pour (ré)appliquer le tri si besoin
export function applyTableSort() {
document.querySelectorAll('.js-sort-table').forEach(table => {
new window.Tablesort(table);
});
}

View file

@ -1,5 +1,4 @@
function colorHeadingTable() {
const headers = document.querySelectorAll('th');
headers.forEach(header => {
const text = header.textContent;
@ -13,9 +12,7 @@ function colorHeadingTable() {
});
}
function check_validity(e) {
list_inputs_good_to_fill = [
'input[name="commerce_tag_value__contact:email"]',
'input[name="commerce_tag_value__contact:phone"]',
@ -73,20 +70,16 @@ function check_validity(e) {
document.querySelector('#validation_messages').classList.add('is-invalid');
}
}
// Générer une couleur pastel aléatoire
const genererCouleurPastel = () => {
// Utiliser des valeurs plus claires (180-255) pour obtenir des tons pastel
export const genererCouleurPastel = () => {
const r = Math.floor(Math.random() * 75 + 180);
const g = Math.floor(Math.random() * 75 + 180);
const b = Math.floor(Math.random() * 75 + 180);
return `rgb(${r}, ${g}, ${b})`;
};
async function searchInseeCode(query) {
try {
// Afficher l'indicateur de chargement
document.querySelector('#loading_search_insee').classList.remove('d-none');
const response = await fetch(`https://geo.api.gouv.fr/communes?nom=${query}&fields=nom,code,codesPostaux&limit=10`);
@ -104,21 +97,18 @@ async function searchInseeCode(query) {
}
}
function updateMapHeightForLargeScreens() {
export function updateMapHeightForLargeScreens() {
const mapFound = document.querySelector('#map');
const canvasFound = document.querySelector('#map canvas');
const newHeight = window.innerHeight * 0.5 + 'px'
if (mapFound && window.innerHeight > 800 && window.innerWidth > 800) {
mapFound.style.height = window.innerWidth * 0.8 + 'px';
mapFound.style.height = newHeight;
} else {
console.log('window.innerHeight', window.innerHeight);
}
}
// lister les changesets de l'utilisateur osm-commerces
async function listChangesets() {
// Ajouter le header Accept pour demander du JSON
const options = {
headers: {
'Accept': 'application/json'
@ -128,7 +118,6 @@ async function listChangesets() {
const data = await changesets.json();
console.log(data.changesets.length);
// Grouper les changesets par période
const now = new Date();
const last24h = new Date(now - 24 * 60 * 60 * 1000);
const last7days = new Date(now - 7 * 24 * 60 * 60 * 1000);
@ -154,7 +143,6 @@ async function listChangesets() {
}
});
// Afficher les statistiques
const historyDiv = document.getElementById('userChangesHistory');
if (historyDiv) {
historyDiv.innerHTML = `
@ -170,8 +158,6 @@ async function listChangesets() {
}
}
function openInPanoramax() {
const center = map.getCenter();
const zoom = map.getZoom();
@ -179,150 +165,140 @@ function openInPanoramax() {
window.open(panoramaxUrl);
}
function enableLabourageForm() {
// Récupérer les éléments du formulaire
export function enableLabourageForm() {
const citySearchInput = document.getElementById('citySearch');
const citySuggestionsList = document.getElementById('citySuggestions');
if (citySearchInput && citySuggestionsList) {
// Configurer la recherche de ville avec la fonction existante
const form = citySearchInput.closest('form');
setupCitySearch('citySearch', 'citySuggestions', function (result_search) {
console.log('code_insee', result_search.insee);
// Activer le spinner dans le bouton de labourage
const labourageBtn = document.querySelector('.btn-labourer');
if (labourageBtn) {
labourageBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Chargement...';
labourageBtn.disabled = true;
if (form) {
const labourageBtn = form.querySelector('button[type="submit"]');
if (labourageBtn) {
// Remplir le champ caché avec le code INSEE
const inseeInput = form.querySelector('#selectedZipCode');
if (inseeInput) {
inseeInput.value = result_search.insee;
}
// Changer l'action du formulaire pour pointer vers la bonne URL
form.action = getLabourerUrl(result_search);
// Changer le texte du bouton et le désactiver
labourageBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Ajout de ' + result_search.name + '...';
labourageBtn.disabled = true;
// Soumettre le formulaire
form.submit();
}
}else{
console.warn('pas de form pour labourage trouvé')
}
console.log('result_search', result_search, getLabourerUrl(result_search));
window.location.href = getLabourerUrl(result_search);
});
}else{
console.warn('pas de labourage citySearchInput citySuggestionsList trouvé', citySearchInput,citySuggestionsList )
}
}
// Fonction pour gérer la recherche de villes
/**
* Configure la recherche de ville avec autocomplétion
* @param {string} inputId - ID de l'input de recherche
* @param {string} suggestionListId - ID de la liste des suggestions
* @param {Function} onSelect - Callback appelé lors de la sélection d'une ville
*/
function setupCitySearch(inputId, suggestionListId, onSelect) {
export function setupCitySearch(inputId, suggestionListId, onSelect) {
const searchInput = document.getElementById(inputId);
const suggestionList = document.getElementById(suggestionListId);
window.searchInput = searchInput;
window.suggestionList = suggestionList;
window.onSelect = onSelect;
if (!searchInput || !suggestionList) return;
let timeoutId = null;
let searchOngoing = false;
searchInput.addEventListener('input', function () {
console.log('input', this.value);
searchInput.addEventListener('keyup', function () {
clearTimeout(timeoutId);
const query = this.value.trim();
if (query.length < 3) {
clearSuggestions();
return;
}
timeoutId = setTimeout(() => {
if (!searchOngoing) {
searchOngoing = true;
performSearch(query);
searchOngoing = false;
performSearch(query).then(() => {
searchOngoing = false;
});
}
}, 300);
});
}
function performSearch(query) {
console.log('performSearch', query);
fetch(`https://geo.api.gouv.fr/communes?nom=${encodeURIComponent(query)}&fields=nom,code,codesPostaux&limit=5`)
.then(response => response.json())
.then(data => {
async function performSearch(query) {
try {
const response = await fetch(`https://geo.api.gouv.fr/communes?nom=${encodeURIComponent(query)}&fields=nom,code,codesPostaux&limit=5`);
const data = await response.json();
const citySuggestions = data.map(city => ({
name: city.nom,
postcode: city.codesPostaux[0],
insee: city.code
insee: city.code,
postcodes: city.codesPostaux,
postcode: city.codesPostaux && city.codesPostaux.length > 0 ? city.codesPostaux[0] : '',
display_name: `${city.nom} (${city.codesPostaux && city.codesPostaux.length > 0 ? city.codesPostaux[0] : ''})`
}));
displaySuggestions(citySuggestions);
})
.catch(error => {
console.error('Erreur lors de la recherche:', error);
clearSuggestions();
});
}
} catch (error) {
console.error("Erreur de recherche:", error);
}
}
function displaySuggestions(suggestions) {
console.log('displaySuggestions', suggestions);
clearSuggestions();
suggestions.forEach(suggestion => {
const li = document.createElement('li');
li.className = 'list-group-item p-2';
li.textContent = `${suggestion.name} (${suggestion.postcode})`;
li.addEventListener('click', () => {
searchInput.value = suggestion.name;
clearSuggestions();
if (onSelect) onSelect(suggestion);
});
window.suggestionList.appendChild(li);
});
window.suggestionList.classList.remove('d-none');
console.log('window.suggestionList', window.suggestionList);
}
function clearSuggestions() {
window.suggestionList.innerHTML = '';
}
// Fermer les suggestions en cliquant en dehors
document.addEventListener('click', function (e) {
if (window.searchInput && !window.searchInput?.contains(e.target) && !window.suggestionList?.contains(e.target)) {
function displaySuggestions(suggestions) {
clearSuggestions();
suggestions.forEach(suggestion => {
const item = document.createElement('div');
item.classList.add('suggestion-item');
// Nouveau rendu : nom en gras, INSEE et CP en petit/gris
item.innerHTML = `
<span class="suggestion-name" style="font-weight:bold;">${suggestion.name}</span><br>
<span class="suggestion-details" style="font-size:0.95em;color:#666;">
<span>INSEE&nbsp;: <b>${suggestion.insee}</b></span>
<span style="margin-left:12px;">CP&nbsp;: <b>${Array.isArray(suggestion.postcodes) ? suggestion.postcodes.join(', ') : suggestion.postcode}</b></span>
</span>
`;
item.addEventListener('click', () => {
searchInput.value = suggestion.name;
clearSuggestions();
if (onSelect) {
onSelect(suggestion);
}
});
suggestionList.appendChild(item);
});
suggestionList.style.display = 'block';
}
});
// Fonction pour formater l'URL de labourage
/**
* Génère l'URL de labourage pour un code postal donné
* @param {string} zipCode - Le code postal
* @returns {string} L'URL de labourage
*/
function getLabourerUrl(obj) {
function clearSuggestions() {
suggestionList.innerHTML = '';
suggestionList.style.display = 'none';
}
return `/admin/labourer/${obj.insee}`;
document.addEventListener('click', (e) => {
if (!searchInput.contains(e.target) && !suggestionList.contains(e.target)) {
clearSuggestions();
}
});
}
// Fonction pour gérer la soumission du formulaire d'ajout de ville
function handleAddCityFormSubmit(event) {
event.preventDefault();
const form = event.target;
const submitButton = form.querySelector('button[type="submit"]');
const zipCodeInput = form.querySelector('input[name="zip_code"]');
if (!zipCodeInput.value) {
return;
export function getLabourerUrl(obj) {
if (obj && obj.insee) {
return `/add-city-without-labourage/${obj.insee}`;
}
// Afficher le spinner
submitButton.disabled = true;
const originalContent = submitButton.innerHTML;
submitButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Labourer...';
// Rediriger
window.location.href = getLabourerUrl(zipCodeInput.value);
return '#';
}
/**
* Colore les cellules d'un tableau en fonction des pourcentages
* @param {string} selector - Le sélecteur CSS pour cibler les cellules à colorer
* @param {string} color - La couleur de base en format RGB (ex: '154, 205, 50')
*/
function colorizePercentageCells(selector, color = '154, 205, 50') {
export function handleAddCityFormSubmit(event) {
event.preventDefault();
const zipCode = document.getElementById('selectedZipCode').value;
if (zipCode && zipCode.match(/^\d{5}$/)) {
window.location.href = `/add-city-without-labourage/${zipCode}`;
} else {
alert('Veuillez sélectionner une ville valide avec un code postal.');
}
}
export function colorizePercentageCells(selector, color = '154, 205, 50') {
document.querySelectorAll(selector).forEach(cell => {
const percentage = parseInt(cell.textContent);
const percentage = parseInt(cell.textContent.replace('%', ''), 10);
if (!isNaN(percentage)) {
const alpha = percentage / 100;
cell.style.backgroundColor = `rgba(${color}, ${alpha})`;
@ -330,144 +306,79 @@ function colorizePercentageCells(selector, color = '154, 205, 50') {
});
}
/**
* Colore les cellules d'un tableau avec un gradient relatif à la valeur maximale
* @param {string} selector - Le sélecteur CSS pour cibler les cellules à colorer
* @param {string} color - La couleur de base en format RGB (ex: '154, 205, 50')
*/
function colorizePercentageCellsRelative(selector, color = '154, 205, 50') {
// Récupérer toutes les cellules
export function colorizePercentageCellsRelative(selector, color = '154, 205, 50') {
let min = Infinity;
let max = -Infinity;
const cells = document.querySelectorAll(selector);
// Trouver la valeur maximale
let maxValue = 0;
cells.forEach(cell => {
const value = parseInt(cell.textContent);
if (!isNaN(value) && value > maxValue) {
maxValue = value;
const value = parseInt(cell.textContent.replace('%', ''), 10);
if (!isNaN(value)) {
min = Math.min(min, value);
max = Math.max(max, value);
}
});
// Appliquer le gradient relatif à la valeur max
cells.forEach(cell => {
const value = parseInt(cell.textContent);
if (!isNaN(value)) {
const alpha = value / maxValue; // Ratio relatif au maximum
cell.style.backgroundColor = `rgba(${color}, ${alpha})`;
}
});
if (max > min) {
cells.forEach(cell => {
const value = parseInt(cell.textContent.replace('%', ''), 10);
if (!isNaN(value)) {
const ratio = (value - min) / (max - min);
cell.style.backgroundColor = `rgba(${color}, ${ratio.toFixed(2)})`;
}
});
}
}
/**
* Ajuste dynamiquement la taille du texte des éléments list-group-item selon leur nombre
* @param {string} selector - Le sélecteur CSS des éléments à ajuster
* @param {number} [minFont=0.8] - Taille de police minimale en rem
* @param {number} [maxFont=1.2] - Taille de police maximale en rem
*/
function adjustListGroupFontSize(selector, minFont = 0.8, maxFont = 1.2) {
const items = document.querySelectorAll(selector);
const count = items.length;
export function adjustListGroupFontSize(selector, minFont = 0.8, maxFont = 1.2) {
const listItems = document.querySelectorAll(selector);
if (listItems.length === 0) return;
let fontSize = maxFont;
const count = listItems.length;
if (count > 0) {
// Plus il y a d'items, plus la taille diminue, mais jamais en dessous de minFont
fontSize = Math.max(minFont, maxFont - (count - 5) * 0.05);
}
items.forEach(item => {
listItems.forEach(item => {
item.style.fontSize = fontSize + 'rem';
});
}
function check_validity() {
if (!document.getElementById('editLand')) {
return;
}
export function calculateCompletion(properties) {
let completed = 0;
const total = 7; // Nombre de critères
const form = document.getElementById('editLand');
const fields = {
'name': {
required: true,
message: 'Le nom est requis'
},
'contact:street': {
required: true,
message: 'La rue est requise'
},
'contact:housenumber': {
required: true,
message: 'Le numéro est requis'
},
'contact:phone': {
pattern: /^(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}$/,
message: 'Le numéro de téléphone n\'est pas valide'
},
'contact:email': {
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'L\'adresse email n\'est pas valide'
},
'contact:website': {
pattern: /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/,
message: 'L\'URL du site web n\'est pas valide'
}
};
if (properties.name) completed++;
if (properties['addr:housenumber'] && properties['addr:street']) completed++;
if (properties.opening_hours) completed++;
if (properties.website || properties['contact:website']) completed++;
if (properties.phone || properties['contact:phone']) completed++;
if (properties.wheelchair) completed++;
let isValid = true;
const errorMessages = {};
// Supprimer les messages d'erreur précédents
document.querySelectorAll('.error-message').forEach(el => el.remove());
// Vérifier chaque champ
for (const [fieldName, rules] of Object.entries(fields)) {
const input = form.querySelector(`[name="${fieldName}"]`);
if (!input) continue;
const value = input.value.trim();
let fieldError = null;
// Ne valider que si le champ n'est pas vide
if (value) {
if (rules.pattern && !rules.pattern.test(value)) {
fieldError = rules.message;
}
} else if (rules.required) {
// Si le champ est vide et requis
fieldError = rules.message;
}
if (fieldError) {
isValid = false;
errorMessages[fieldName] = fieldError;
// Créer et afficher le message d'erreur
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message text-danger small mt-1';
errorDiv.textContent = fieldError;
input.parentNode.appendChild(errorDiv);
// Ajouter une classe d'erreur au champ
input.classList.add('is-invalid');
} else {
input.classList.remove('is-invalid');
}
}
return isValid;
return {
percentage: total > 0 ? (completed / total) * 100 : 0,
completed: completed,
total: total
};
}
export function toggleCompletionInfo() {
const content = document.getElementById('completionInfoContent');
const icon = document.getElementById('completionInfoIcon');
if (content) {
const isVisible = content.style.display === 'block';
content.style.display = isVisible ? 'none' : 'block';
if (icon) {
icon.classList.toggle('bi-chevron-down', isVisible);
icon.classList.toggle('bi-chevron-up', !isVisible);
}
}
}
// Exporter les fonctions dans window
window.setupCitySearch = setupCitySearch;
window.getLabourerUrl = getLabourerUrl;
window.handleAddCityFormSubmit = handleAddCityFormSubmit;
window.colorizePercentageCells = colorizePercentageCells;
window.colorizePercentageCellsRelative = colorizePercentageCellsRelative;
window.adjustListGroupFontSize = adjustListGroupFontSize;
window.check_validity = check_validity;
window.enableLabourageForm = enableLabourageForm;
window.performSearch = performSearch;
window.colorHeadingTable = colorHeadingTable;
window.openInPanoramax = openInPanoramax;
window.listChangesets = listChangesets;
window.updateMapHeightForLargeScreens = updateMapHeightForLargeScreens;
window.searchInseeCode = searchInseeCode;
window.genererCouleurPastel = genererCouleurPastel;
window.check_validity = check_validity;
window.adjustListGroupFontSize = adjustListGroupFontSize;
window.calculateCompletion = calculateCompletion;
window.toggleCompletionInfo = toggleCompletionInfo;

View file

@ -12,6 +12,11 @@ if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
// Optimisations pour éviter les timeouts
ini_set('max_execution_time', 300);
ini_set('memory_limit', '512M');
set_time_limit(300);
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {

60
clean_duplicate_stats.sql Normal file
View file

@ -0,0 +1,60 @@
-- Script SQL pour désactiver temporairement la contrainte d'unicité sur l'INSEE de ville,
-- supprimer les doublons, et réactiver la contrainte
-- ATTENTION: Ce script supprime des enregistrements de la table stats.
-- Les entités liées (CityFollowUp, Place) seront également supprimées si des contraintes
-- de clé étrangère avec ON DELETE CASCADE sont définies dans la base de données.
-- Assurez-vous de faire une sauvegarde de la base de données avant d'exécuter ce script.
-- 1. Désactiver temporairement la contrainte d'unicité
ALTER TABLE stats DROP CONSTRAINT uniq_stats_zone;
-- 2. Identifier et supprimer les doublons, en gardant l'entrée la plus ancienne
-- Créer une table temporaire pour stocker les IDs à supprimer
CREATE TEMP TABLE stats_to_delete AS
WITH duplicates AS (
SELECT
id,
zone,
date_created,
ROW_NUMBER() OVER (
PARTITION BY zone
ORDER BY
-- Garder l'entrée la plus ancienne si date_created existe
CASE WHEN date_created IS NOT NULL THEN 0 ELSE 1 END,
date_created,
-- Si date_created est NULL, utiliser l'ID le plus petit (probablement le plus ancien)
id
) AS row_num
FROM stats
WHERE zone IS NOT NULL
)
SELECT id FROM duplicates WHERE row_num > 1;
-- Afficher le nombre de doublons qui seront supprimés
SELECT COUNT(*) AS "Nombre de doublons à supprimer" FROM stats_to_delete;
-- Afficher les détails des doublons qui seront supprimés (pour vérification)
SELECT s.id, s.zone, s.name, s.date_created
FROM stats s
JOIN stats_to_delete std ON s.id = std.id
ORDER BY s.zone, s.id;
-- 3. Supprimer les doublons
-- Note: Nous utilisons DELETE ... USING pour éviter les problèmes de contraintes de clé étrangère
DELETE FROM stats
USING stats_to_delete
WHERE stats.id = stats_to_delete.id;
-- 4. Nettoyer la table temporaire
DROP TABLE stats_to_delete;
-- 5. Réactiver la contrainte d'unicité
ALTER TABLE stats ADD CONSTRAINT uniq_stats_zone UNIQUE (zone);
-- 6. Vérifier qu'il n'y a plus de doublons
SELECT zone, COUNT(*)
FROM stats
WHERE zone IS NOT NULL
GROUP BY zone
HAVING COUNT(*) > 1;

View file

@ -0,0 +1,62 @@
# Script de nettoyage des doublons de villes
Ce dossier contient un script SQL pour résoudre le problème de contrainte d'unicité sur le code INSEE des villes dans la table `stats`.
## Problème
Lorsque vous rencontrez l'erreur suivante :
```
SQLSTATE[23505]: Unique violation: 7 ERREUR: n'a pas pu créer l'index unique « uniq_stats_zone »
DETAIL: La clé (zone)=(91111) est dupliquée.
```
Cela signifie qu'il existe des doublons dans la table `stats` avec le même code INSEE (colonne `zone`), ce qui empêche la création de la contrainte d'unicité.
## Solution
Le script `clean_duplicate_stats.sql` permet de :
1. Désactiver temporairement la contrainte d'unicité
2. Identifier et supprimer les doublons de villes, en gardant l'entrée la plus ancienne
3. Réactiver la contrainte d'unicité
## Comment utiliser le script
### Précautions importantes
⚠️ **ATTENTION** : Ce script supprime des données de la base. Assurez-vous de faire une sauvegarde complète de votre base de données avant de l'exécuter.
Les entités liées aux enregistrements `Stats` supprimés (comme `CityFollowUp` et `Place`) seront également supprimées si des contraintes de clé étrangère avec `ON DELETE CASCADE` sont définies dans la base de données.
### Exécution du script
Pour exécuter le script sur une base PostgreSQL :
```bash
psql -U username -d database_name -f clean_duplicate_stats.sql
```
Remplacez `username` par votre nom d'utilisateur PostgreSQL et `database_name` par le nom de votre base de données.
### Vérification
Le script inclut des requêtes pour :
- Afficher le nombre de doublons qui seront supprimés
- Afficher les détails des doublons avant suppression
- Vérifier qu'il n'y a plus de doublons après l'opération
## Fonctionnement technique
Le script :
1. Désactive la contrainte d'unicité `uniq_stats_zone`
2. Crée une table temporaire pour identifier les doublons
3. Utilise `ROW_NUMBER()` pour conserver l'entrée la plus ancienne de chaque groupe de doublons
4. Supprime les doublons identifiés
5. Réactive la contrainte d'unicité
6. Vérifie qu'il n'y a plus de doublons
## Après l'exécution
Une fois le script exécuté avec succès, vous ne devriez plus rencontrer l'erreur de violation de contrainte d'unicité lors de l'exécution des commandes Symfony.

3
commerce-clean.sh Normal file
View file

@ -0,0 +1,3 @@
rm -rf /poule/encrypted/www/osm-commerces/var/log/dev.log
rm -rf /poule/encrypted/www/osm-commerces/var/cache/*
rm -rf /var/log/journal/71a53459546c4baeb0d4e7c95504ee2d/*

34876
communes_france.csv Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,14 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
options:
1002: "SET NAMES utf8mb4"
1000: true
# Optimisations pour éviter les timeouts
PDO::ATTR_TIMEOUT: 300
PDO::ATTR_PERSISTENT: false
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY: true
PDO::MYSQL_ATTR_INIT_COMMAND: "SET SESSION wait_timeout=300, interactive_timeout=300"
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)

View file

@ -5,7 +5,16 @@ framework:
http_method_override: false
handle_all_throwables: true
# Optimisations pour éviter les timeouts
cache:
app: cache.adapter.filesystem
system: cache.adapter.system
directory: '%kernel.cache_dir%/pools/app'
default_redis_provider: 'redis://localhost'
default_memcached_provider: 'memcached://localhost'
default_doctrine_dbal_provider: database_connection
default_pdo_provider: null
pools: { }
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.

15
counting_osm_objects/.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
osm_data/*pbf
osm_data/*geojson
polygons/*.poly
test_data/*
test_temp/*
test_results/*
temp/*
resultats/*
osm_config.txt
__pycache__
secrets.sh
cookie.txt
bin/venv
activate
city_analysis

View file

@ -0,0 +1,237 @@
# Counting OSM Objects
Ce répertoire contient des scripts pour compter et analyser les objets OpenStreetMap dans différentes zones
administratives françaises.
Pour fonctionner vous aurez besoin du fichier historisé de la france, pour cela connectez vous à geofabrik avec votre
compte osm (oui c'est relou).
## TODO
faire une extraction json de toutes les zones insee de france et réaliser des mesures pour tous les thèmes.
## Scripts disponibles
activez le venv python et les dépendances
```shell
python -m venv bin/venv activate
source bin/venv/bin/activate
pip install plotly pandas
```
```shell
# extraire un historique pour une ville à partir de l'historique de france et du polygone de la ville
# ici pour paris on peut utiliser l'historique de l'ile de france
wget https://osm-commerces.cipherbliss.com/admin/export_csv
py get_all_polys.py # ce qui utilise l'export csv des villes de osm mon commerce
osmium extract -p polygons/commune_75056.poly -H osm_data/ile-de-france-internal.osh.pbf -s complete_ways -O -o commune_75056.osh.pbf
```
```shell
# générer les historiques de la commune 76216 pour tous ses thèmes et tous ses graphes
py loop_thematics_history_in_zone_to_counts.py --input osm_data/commune_76216.osh.pbf --output-dir test_results --temp-dir test_temp --max-dates 100 --poly polygons/commune_76216.poly
```
```shell
# générer un graphe pour un thème dans une ville
py generate_graph.py --insee 91111 test_results/commune_75056_building.csv
Graphique sauvegardé: test_results/commune_75056_building_monthly_graph.png
Graphique de complétion sauvegardé: test_results/commune_75056_building_monthly_graph_completion.png
Graphiques générés avec succès!
```
### historize_zone.py
Ce script principal permet de lancer l'analyse historique d'une ville dans OpenStreetMap. Il:
1. Demande à l'utilisateur quelle ville il souhaite traiter
2. Trouve le code INSEE de la ville demandée
3. Vérifie si le polygone de la ville existe, sinon le récupère
4. Traite les données historiques OSM pour cette ville en utilisant loop_thematics_history_in_zone_to_counts.py
#### Utilisation
```bash
python historize_zone.py [--input fichier_historique.osh.pbf]
```
Si le fichier d'historique n'est pas spécifié, le script utilisera par défaut le fichier
`osm_data/france-internal.osh.pbf`.
#### Exemples
Lancer l'analyse avec le fichier d'historique par défaut:
```bash
python historize_zone.py
```
Lancer l'analyse avec un fichier d'historique spécifique:
```bash
python historize_zone.py --input osm_data/ile-de-france-internal.osh.pbf
```
### loop_thematics_history_in_zone_to_counts.py
Ce script compte les objets OSM par thématique sur une zone donnée à différentes dates. Il:
1. Filtre les données historiques OSM à différentes dates (mensuelles sur les 10 dernières années)
2. Compte les objets correspondant à chaque thématique à chaque date
3. Calcule le pourcentage de complétion des attributs importants pour chaque thème
4. Sauvegarde les résultats dans des fichiers CSV
5. Génère des graphiques montrant l'évolution dans le temps
#### Utilisation
```bash
python loop_thematics_history_in_zone_to_counts.py --input fichier.osh.pbf --poly polygons/commune_XXXXX.poly
```
Ce script est généralement appelé par historize_zone.py et ne nécessite pas d'être exécuté directement.
### get_poly.py
Ce script permet de récupérer le polygone d'une commune française à partir de son code INSEE. Il interroge l'API
Overpass Turbo pour obtenir les limites administratives de la commune et sauvegarde le polygone dans un fichier au
format .poly (compatible avec Osmosis).
#### Utilisation
```bash
python get_poly.py [code_insee]
```
Si le code INSEE n'est pas fourni en argument, le script le demandera interactivement.
#### Exemples
Récupérer le polygone de la commune d'Étampes (code INSEE 91111) :
```bash
python get_poly.py 91111
```
Le polygone sera sauvegardé dans le fichier `polygons/commune_91111.poly`.
#### Format de sortie
Le fichier de sortie est au format .poly, qui est utilisé par Osmosis et d'autres outils OpenStreetMap. Il contient :
- Le nom de la commune
- Un numéro de section
- Les coordonnées des points du polygone (longitude, latitude)
- Des marqueurs "END" pour fermer le polygone et le fichier
Exemple de contenu :
```
commune_91111
1
2.1326337 48.6556426
2.1323684 48.6554398
...
2.1326337 48.6556426
END
END
```
## Dépendances
- Python 3.6+
- Modules Python : argparse, urllib, json
- Pour compare_osm_objects.sh : PostgreSQL, curl, jq
## Installation
Aucune installation spécifique n'est nécessaire pour ces scripts. Assurez-vous simplement que les dépendances sont
installées.
### analyze_city_polygons.py
Ce script analyse les polygones de villes et génère des fichiers JSON d'analyse. Il:
1. Parcourt tous les fichiers de polygones dans le dossier "polygons"
2. Pour chaque polygone, vérifie si un fichier d'analyse JSON existe déjà
3. Si non, utilise loop_thematics_history_in_zone_to_counts.py pour extraire les données
4. Sauvegarde les résultats dans un fichier JSON avec une analyse de complétion
5. Ajoute une date de création à chaque analyse
#### Utilisation
```bash
python analyze_city_polygons.py [--force] [--single CODE_INSEE]
```
Options:
- `--force` ou `-f`: Force la recréation des analyses existantes
- `--single` ou `-s`: Traite uniquement le polygone spécifié (code INSEE)
#### Exemples
Analyser tous les polygones disponibles:
```bash
python analyze_city_polygons.py
```
Analyser uniquement la commune avec le code INSEE 59140:
```bash
python analyze_city_polygons.py --single 59140
```
Forcer la recréation des analyses existantes:
```bash
python analyze_city_polygons.py --force
```
#### Format de sortie
Les fichiers d'analyse sont au format JSON et sont sauvegardés dans le dossier `city_analysis`. Chaque fichier contient:
- Une section `themes` avec des données pour chaque thématique (nombre d'objets, pourcentage de complétion, etc.)
- Une section `metadata` avec des informations sur l'analyse (code INSEE, date de création, statistiques globales)
Exemple de contenu:
```json
{
"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
}
},
"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.44,
"theme_count": 7
}
}
```
## Licence
Ces scripts sont distribués sous la même licence que le projet Osmose-Backend.

View file

@ -0,0 +1,295 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script pour analyser les polygones de villes et générer des fichiers JSON d'analyse.
Ce script:
1. Parcourt tous les fichiers de polygones dans le dossier "polygons"
2. Pour chaque polygone, vérifie si un fichier d'analyse JSON existe déjà
3. Si non, utilise loop_thematics_history_in_zone_to_counts.py pour extraire les données
4. Sauvegarde les résultats dans un fichier JSON avec une analyse de complétion
5. Ajoute une date de création à chaque analyse
Usage:
python analyze_city_polygons.py
"""
import os
import sys
import json
import glob
import subprocess
import argparse
from datetime import datetime
from pathlib import Path
# Import des thèmes depuis loop_thematics_history_in_zone_to_counts.py
from loop_thematics_history_in_zone_to_counts import THEMES
# Chemins des répertoires
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
POLYGONS_DIR = os.path.join(SCRIPT_DIR, "polygons")
OSM_DATA_FILE = os.path.join(SCRIPT_DIR, "osm_data", "france-latest.osm.pbf")
ANALYSIS_DIR = os.path.join(SCRIPT_DIR, "city_analysis")
TEMP_DIR = os.path.join(SCRIPT_DIR, "temp")
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "resultats")
def ensure_directories_exist():
"""
Vérifie que les répertoires nécessaires existent, sinon les crée.
"""
os.makedirs(POLYGONS_DIR, exist_ok=True)
os.makedirs(ANALYSIS_DIR, exist_ok=True)
os.makedirs(TEMP_DIR, exist_ok=True)
os.makedirs(OUTPUT_DIR, exist_ok=True)
print(f"Dossier d'analyses: {ANALYSIS_DIR}")
def analysis_exists(insee_code):
"""
Vérifie si l'analyse pour le code INSEE donné existe déjà.
Args:
insee_code (str): Le code INSEE de la commune
Returns:
bool: True si l'analyse existe, False sinon
"""
analysis_file = os.path.join(ANALYSIS_DIR, f"analyse_commune_{insee_code}.json")
return os.path.isfile(analysis_file)
def run_command(command):
"""
Exécute une commande shell et retourne la sortie.
Args:
command (str): La commande à exécuter
Returns:
str: La sortie de la commande ou None en cas d'erreur
"""
print(f"Exécution: {command}")
try:
result = subprocess.run(
command,
shell=True,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
return result.stdout
except subprocess.CalledProcessError as e:
print(f"Erreur lors de l'exécution de la commande: {e}")
print(f"Sortie de la commande: {e.stdout}")
print(f"Erreur de la commande: {e.stderr}")
return None
def extract_data_for_polygon(poly_file, insee_code):
"""
Extrait les données pour un polygone donné en utilisant loop_thematics_history_in_zone_to_counts.py.
Args:
poly_file (str): Chemin vers le fichier de polygone
insee_code (str): Code INSEE de la commune
Returns:
dict: Dictionnaire contenant les données extraites ou None en cas d'erreur
"""
try:
# Vérifier que le fichier OSM existe
if not os.path.isfile(OSM_DATA_FILE):
print(f"Erreur: Le fichier OSM {OSM_DATA_FILE} n'existe pas.")
return None
# Exécuter loop_thematics_history_in_zone_to_counts.py pour extraire les données
# Nous utilisons --max-dates=1 pour obtenir uniquement les données les plus récentes
command = f"python3 {os.path.join(SCRIPT_DIR, 'loop_thematics_history_in_zone_to_counts.py')} --input {OSM_DATA_FILE} --poly {poly_file} --output-dir {OUTPUT_DIR} --temp-dir {TEMP_DIR} --max-dates=1"
run_command(command)
# Récupérer les résultats depuis les fichiers CSV générés
zone_name = Path(poly_file).stem
data = {"themes": {}}
for theme_name in THEMES.keys():
csv_file = os.path.join(OUTPUT_DIR, f"{zone_name}_{theme_name}.csv")
if os.path.exists(csv_file):
# Lire le fichier CSV et extraire les données les plus récentes
with open(csv_file, "r") as f:
lines = f.readlines()
if len(lines) > 1: # S'assurer qu'il y a des données (en-tête + au moins une ligne)
headers = lines[0].strip().split(",")
latest_data = lines[-1].strip().split(",")
# Créer un dictionnaire pour ce thème
theme_data = {}
for i, header in enumerate(headers):
if i < len(latest_data):
# Convertir en nombre si possible
try:
value = latest_data[i]
if value and value != "":
theme_data[header] = float(value) if "." in value else int(value)
else:
theme_data[header] = 0
except ValueError:
theme_data[header] = latest_data[i]
data["themes"][theme_name] = theme_data
return data
except Exception as e:
print(f"Erreur lors de l'extraction des données pour {insee_code}: {e}")
return None
def create_analysis(poly_file):
"""
Crée une analyse pour un fichier de polygone donné.
Args:
poly_file (str): Chemin vers le fichier de polygone
Returns:
str: Chemin vers le fichier d'analyse créé ou None en cas d'erreur
"""
try:
# Extraire le code INSEE du nom du fichier (format: commune_XXXXX.poly)
poly_filename = os.path.basename(poly_file)
if poly_filename.startswith("commune_") and poly_filename.endswith(".poly"):
insee_code = poly_filename[8:-5] # Enlever "commune_" et ".poly"
else:
print(f"Format de nom de fichier non reconnu: {poly_filename}")
return None
print(f"Création de l'analyse pour la commune {insee_code}...")
# Extraire les données pour ce polygone
data = extract_data_for_polygon(poly_file, insee_code)
if not data:
return None
# Ajouter des métadonnées à l'analyse
data["metadata"] = {
"insee_code": insee_code,
"creation_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"polygon_file": poly_filename,
"osm_data_file": os.path.basename(OSM_DATA_FILE)
}
# Calculer les statistiques de complétion globales
total_objects = 0
total_completion = 0
theme_count = 0
for theme_name, theme_data in data["themes"].items():
if "nombre_total" in theme_data and theme_data["nombre_total"] > 0:
total_objects += theme_data["nombre_total"]
if "pourcentage_completion" in theme_data:
total_completion += theme_data["pourcentage_completion"] * theme_data["nombre_total"]
theme_count += 1
if total_objects > 0:
data["metadata"]["total_objects"] = total_objects
data["metadata"]["average_completion"] = total_completion / total_objects if total_objects > 0 else 0
data["metadata"]["theme_count"] = theme_count
# Sauvegarder l'analyse dans un fichier JSON
analysis_file = os.path.join(ANALYSIS_DIR, f"analyse_commune_{insee_code}.json")
with open(analysis_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"Analyse pour la commune {insee_code} sauvegardée dans {analysis_file}")
return analysis_file
except Exception as e:
print(f"Erreur lors de la création de l'analyse: {e}")
return None
def main():
"""
Fonction principale du script.
"""
parser = argparse.ArgumentParser(
description="Analyse les polygones de villes et génère des fichiers JSON d'analyse."
)
parser.add_argument(
"--force", "-f", action="store_true", help="Force la recréation des analyses existantes"
)
parser.add_argument(
"--single", "-s", help="Traite uniquement le polygone spécifié (code INSEE)"
)
args = parser.parse_args()
try:
# S'assurer que les répertoires nécessaires existent
ensure_directories_exist()
# Récupérer les fichiers de polygones à traiter
if args.single:
# Traiter uniquement le polygone spécifié
single_poly_file = os.path.join(POLYGONS_DIR, f"commune_{args.single}.poly")
if not os.path.isfile(single_poly_file):
print(f"Erreur: Le fichier de polygone pour la commune {args.single} n'existe pas.")
return 1
poly_files = [single_poly_file]
print(f"Mode test: traitement uniquement du polygone {args.single}")
else:
# Traiter tous les polygones
poly_files = glob.glob(os.path.join(POLYGONS_DIR, "commune_*.poly"))
if not poly_files:
print("Aucun fichier de polygone trouvé dans le dossier 'polygons'.")
return 1
# Pour le test, limiter à 3 polygones
test_mode = False # Mettre à True pour limiter le traitement à quelques polygones
if test_mode:
# Trier les polygones par taille et prendre les 3 plus petits
poly_files = sorted(poly_files, key=os.path.getsize)[:3]
print(f"Mode test: traitement limité à {len(poly_files)} polygones")
for poly_file in poly_files:
print(f" - {os.path.basename(poly_file)}")
# Compteurs pour les statistiques
total = len(poly_files)
existing = 0
created = 0
failed = 0
# Pour chaque fichier de polygone, créer une analyse si elle n'existe pas déjà
for i, poly_file in enumerate(poly_files, 1):
# Extraire le code INSEE du nom du fichier
poly_filename = os.path.basename(poly_file)
insee_code = poly_filename[8:-5] # Enlever "commune_" et ".poly"
print(f"\nTraitement du polygone {i}/{total}: {poly_filename}")
if analysis_exists(insee_code) and not args.force:
print(f"L'analyse pour la commune {insee_code} existe déjà.")
existing += 1
continue
# Créer l'analyse
result = create_analysis(poly_file)
if result:
created += 1
else:
failed += 1
# Afficher les statistiques
print("\nRésumé:")
print(f"Total des polygones traités: {total}")
print(f"Analyses déjà existantes: {existing}")
print(f"Analyses créées avec succès: {created}")
print(f"Échecs: {failed}")
return 0 # Succès
except KeyboardInterrupt:
print("\nOpération annulée par l'utilisateur.")
return 1 # Erreur
except Exception as e:
print(f"Erreur inattendue: {e}")
return 1 # Erreur
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -0,0 +1,247 @@
{
"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
}
}

1050
counting_osm_objects/counting.sh Executable file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
# on crée une page web présentant un tableau de bord pour la zone donnée
# le dashboard contient le nom de la ville et des graphiques pour l'évolution de chaque thématique

View file

@ -0,0 +1,448 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script pour générer un graphique montrant l'évolution du nombre d'objets OSM
à partir d'un fichier CSV
"""
import sys
import os
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime
import argparse
import csv
def get_city_name(insee_code):
"""
Récupère le nom de la ville à partir du code INSEE.
Args:
insee_code: Code INSEE de la commune
Returns:
Nom de la ville ou None si non trouvé
"""
try:
csv_path = os.path.join(os.path.dirname(__file__), "osm-commerces-villes-export.csv")
if os.path.exists(csv_path):
with open(csv_path, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
if row.get('zone') == insee_code:
return row.get('name')
return None
except Exception as e:
print(f"Erreur lors de la récupération du nom de la ville: {e}")
return None
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Génère un graphique à partir des données CSV d'objets OSM."
)
parser.add_argument(
"csv_file", help="Chemin vers le fichier CSV contenant les données"
)
parser.add_argument(
"--output", "-o", help="Chemin de sortie pour le graphique (PNG)", default=None
)
parser.add_argument(
"--insee", "-i", help="Code INSEE de la commune à analyser", default=None
)
parser.add_argument(
"--period",
"-p",
help="Période à analyser (annual, monthly, daily)",
default="monthly",
)
return parser.parse_args()
def load_data(csv_file, insee_code=None, period="monthly"):
"""
Charge les données depuis le fichier CSV.
Args:
csv_file: Chemin vers le fichier CSV
insee_code: Code INSEE de la commune à filtrer (optionnel)
period: Période à analyser (annual, monthly, daily)
Returns:
DataFrame pandas contenant les données filtrées
"""
# Charger le CSV avec gestion des erreurs pour les lignes mal formatées
try:
df = pd.read_csv(csv_file, error_bad_lines=False, warn_bad_lines=True)
except TypeError: # Pour les versions plus récentes de pandas
df = pd.read_csv(csv_file, on_bad_lines="skip")
# Vérifier si le CSV a la structure attendue
if "date" in df.columns:
# Format de CSV avec colonne 'date' directement
try:
df["date"] = pd.to_datetime(df["date"])
except:
# Si la conversion échoue, essayer différents formats
try:
if df["date"].iloc[0].count("-") == 2: # Format YYYY-MM-DD
df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d")
elif df["date"].iloc[0].count("-") == 1: # Format YYYY-MM
df["date"] = pd.to_datetime(df["date"], format="%Y-%m")
else:
df["date"] = pd.to_datetime(df["date"])
except:
print("Erreur: Impossible de convertir la colonne 'date'.")
sys.exit(1)
elif "periode" in df.columns:
# Ancien format avec colonne 'periode'
# Filtrer par période
df = df[df["periode"] == period]
# Filtrer par code INSEE si spécifié
if insee_code and "code_insee" in df.columns:
df = df[df["code_insee"] == insee_code]
# Convertir les dates en objets datetime
if period == "annual" and "annee" in df.columns:
df["date"] = pd.to_datetime(df["annee"].astype(str), format="%Y")
elif period == "monthly":
# Vérifier si la première colonne contient déjà un format de date mensuel (YYYY-MM)
first_col = df.columns[0]
first_val = str(df.iloc[0, 0]) if not df.empty else ""
if first_col == "annee_mois" or (len(first_val) >= 7 and "-" in first_val):
# Si la première colonne est 'annee_mois' ou contient une date au format YYYY-MM
df["date"] = pd.to_datetime(df.iloc[:, 0].astype(str), format="%Y-%m")
elif "annee" in df.columns:
# Sinon, utiliser la colonne 'annee' et ajouter un mois fictif (janvier)
df["date"] = pd.to_datetime(
df["annee"].astype(str) + "-01", format="%Y-%m"
)
elif period == "daily":
# Vérifier si la première colonne contient déjà un format de date quotidien (YYYY-MM-DD)
first_col = df.columns[0]
first_val = str(df.iloc[0, 0]) if not df.empty else ""
if first_col == "jour" or (
len(first_val) >= 10 and first_val.count("-") == 2
):
# Si la première colonne est 'jour' ou contient une date au format YYYY-MM-DD
df["date"] = pd.to_datetime(
df.iloc[:, 0].astype(str), format="%Y-%m-%d"
)
elif "annee" in df.columns:
# Sinon, utiliser la colonne 'annee' et ajouter un jour fictif (1er janvier)
df["date"] = pd.to_datetime(
df["annee"].astype(str) + "-01-01", format="%Y-%m-%d"
)
else:
# Si aucune colonne de date n'est trouvée, essayer d'utiliser la première colonne
try:
df["date"] = pd.to_datetime(df.iloc[:, 0])
except:
print("Erreur: Impossible de trouver ou convertir une colonne de date.")
sys.exit(1)
# Filtrer par code INSEE si spécifié et si la colonne 'zone' contient des codes INSEE
if insee_code and "zone" in df.columns and not "code_insee" in df.columns:
# Vérifier si la zone contient le code INSEE
if any(
zone.endswith(insee_code) for zone in df["zone"] if isinstance(zone, str)
):
df = df[df["zone"].str.endswith(insee_code)]
# Trier par date
df = df.sort_values("date")
return df
def generate_graph(df, output_path=None):
"""
Génère un graphique montrant l'évolution du nombre d'objets dans le temps.
Args:
df: DataFrame pandas contenant les données
output_path: Chemin de sortie pour le graphique (optionnel)
"""
# Créer une figure avec une taille adaptée
plt.figure(figsize=(12, 8))
# Déterminer la colonne pour les types d'objets (type_objet ou theme)
type_column = "type_objet" if "type_objet" in df.columns else "theme"
label_objet = "objet"
# Obtenir la liste des types d'objets uniques
if type_column in df.columns:
object_types = df[type_column].unique()
# Créer un graphique pour chaque type d'objet
for obj_type in object_types:
# Filtrer les données pour ce type d'objet
obj_data = df[df[type_column] == obj_type]
# Filtrer les valeurs nulles
obj_data_filtered = obj_data[obj_data["nombre_total"].notna()]
if not obj_data_filtered.empty:
# Tracer la ligne pour le nombre total d'objets (sans marqueurs, avec courbe lissée)
line, = plt.plot(
obj_data_filtered["date"],
obj_data_filtered["nombre_total"],
linestyle="-",
label=f"{obj_type}",
)
label_objet = obj_type
# Ajouter un remplissage pastel sous la courbe
plt.fill_between(
obj_data_filtered["date"],
obj_data_filtered["nombre_total"],
alpha=0.3,
color=line.get_color()
)
else:
# Si aucune colonne de type n'est trouvée, tracer simplement le nombre total
# Filtrer les valeurs nulles
df_filtered = df[df["nombre_total"].notna()]
if not df_filtered.empty:
# Tracer la ligne pour le nombre total d'objets (sans marqueurs, avec courbe lissée)
line, = plt.plot(
df_filtered["date"],
df_filtered["nombre_total"],
linestyle="-",
label="",
)
# Ajouter un remplissage pastel sous la courbe
plt.fill_between(
df_filtered["date"],
df_filtered["nombre_total"],
alpha=0.3,
color=line.get_color()
)
# Configurer les axes et les légendes
plt.xlabel("Date")
plt.ylabel("Nombre d'objets")
plt.grid(True, linestyle="--", alpha=0.7)
# Formater l'axe des x pour afficher les dates correctement
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
plt.gca().xaxis.set_major_locator(mdates.MonthLocator(interval=6))
plt.gcf().autofmt_xdate()
# Ajouter une légende
plt.legend()
zone_label = ""
city_name = None
# Ajouter des informations sur la commune
if "code_insee" in df.columns and len(df["code_insee"].unique()) == 1:
insee_code = df["code_insee"].iloc[0]
city_name = get_city_name(insee_code)
if city_name:
plt.figtext(0.02, 0.02, f"Commune: {insee_code} - {city_name}", fontsize=10)
zone_label = f"{insee_code} - {city_name}"
else:
plt.figtext(0.02, 0.02, f"Commune: {insee_code}", fontsize=10)
zone_label = insee_code
elif "zone" in df.columns and len(df["zone"].unique()) == 1:
zone = df["zone"].iloc[0]
city_name = get_city_name(zone)
if city_name:
plt.figtext(0.02, 0.02, f"Zone: {zone} - {city_name}", fontsize=10)
zone_label = f"{zone} - {city_name}"
else:
plt.figtext(0.02, 0.02, f"Zone: {zone}", fontsize=10)
zone_label = zone
plt.title(f"{zone_label} : {label_objet} dans le temps")
# Ajouter la date de génération
now = datetime.now().strftime("%Y-%m-%d %H:%M")
plt.figtext(0.98, 0.02, f"Généré le: {now}", fontsize=8, ha="right")
# Ajuster la mise en page
plt.tight_layout()
# Sauvegarder ou afficher le graphique
if output_path:
plt.savefig(output_path, dpi=300, bbox_inches="tight")
print(f"Graphique sauvegardé: {output_path}")
else:
plt.show()
def generate_completion_graph(df, output_path=None):
"""
Génère un graphique montrant l'évolution du taux de complétion des attributs dans le temps.
Args:
df: DataFrame pandas contenant les données
output_path: Chemin de sortie pour le graphique (optionnel)
"""
# Vérifier si la colonne de pourcentage de complétion existe
if "pourcentage_completion" not in df.columns:
print(
"Avertissement: La colonne 'pourcentage_completion' n'existe pas dans le CSV. Le graphique de complétion ne sera pas généré."
)
return
# Créer une figure avec une taille adaptée
plt.figure(figsize=(12, 8))
# Déterminer la colonne pour les types d'objets (type_objet ou theme)
type_column = "type_objet" if "type_objet" in df.columns else "theme"
# Obtenir la liste des types d'objets uniques
if type_column in df.columns:
object_types = df[type_column].unique()
# Créer un graphique pour chaque type d'objet
for obj_type in object_types:
# Filtrer les données pour ce type d'objet
obj_data = df[df[type_column] == obj_type]
# Filtrer les valeurs nulles
obj_data_filtered = obj_data[obj_data["pourcentage_completion"].notna()]
if not obj_data_filtered.empty:
# Tracer la ligne pour le taux de complétion (sans marqueurs, avec courbe lissée)
line, = plt.plot(
obj_data_filtered["date"],
obj_data_filtered["pourcentage_completion"],
linestyle="-",
label=f"{obj_type} - Complétion (%)",
)
# Ajouter un remplissage pastel sous la courbe
plt.fill_between(
obj_data_filtered["date"],
obj_data_filtered["pourcentage_completion"],
alpha=0.3,
color=line.get_color()
)
else:
# Si aucune colonne de type n'est trouvée, tracer simplement le taux de complétion global
# Filtrer les valeurs nulles
df_filtered = df[df["pourcentage_completion"].notna()]
if not df_filtered.empty:
# Tracer la ligne pour le taux de complétion (sans marqueurs, avec courbe lissée)
line, = plt.plot(
df_filtered["date"],
df_filtered["pourcentage_completion"],
linestyle="-",
label="Complétion (%)",
)
# Ajouter un remplissage pastel sous la courbe
plt.fill_between(
df_filtered["date"],
df_filtered["pourcentage_completion"],
alpha=0.3,
color=line.get_color()
)
# Configurer les axes et les légendes
plt.xlabel("Date")
plt.ylabel("Complétion (%)")
plt.title("Évolution du taux de complétion dans le temps")
plt.grid(True, linestyle="--", alpha=0.7)
# Définir les limites de l'axe y entre 0 et 100%
plt.ylim(0, 100)
# Formater l'axe des x pour afficher les dates correctement
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
plt.gca().xaxis.set_major_locator(mdates.MonthLocator(interval=6))
plt.gcf().autofmt_xdate()
# Ajouter une légende
plt.legend()
# Ajouter des informations sur la commune
zone_label = ""
if "code_insee" in df.columns and len(df["code_insee"].unique()) == 1:
insee_code = df["code_insee"].iloc[0]
city_name = get_city_name(insee_code)
if city_name:
plt.figtext(0.02, 0.02, f"Commune: {insee_code} - {city_name}", fontsize=10)
zone_label = f"{insee_code} - {city_name}"
else:
plt.figtext(0.02, 0.02, f"Commune: {insee_code}", fontsize=10)
zone_label = insee_code
elif "zone" in df.columns and len(df["zone"].unique()) == 1:
zone = df["zone"].iloc[0]
city_name = get_city_name(zone)
if city_name:
plt.figtext(0.02, 0.02, f"Zone: {zone} - {city_name}", fontsize=10)
zone_label = f"{zone} - {city_name}"
else:
plt.figtext(0.02, 0.02, f"Zone: {zone}", fontsize=10)
zone_label = zone
# Mettre à jour le titre avec le nom de la zone
if zone_label:
plt.title(f"{zone_label} : Évolution du taux de complétion dans le temps")
# Ajouter la date de génération
now = datetime.now().strftime("%Y-%m-%d %H:%M")
plt.figtext(0.98, 0.02, f"Généré le: {now}", fontsize=8, ha="right")
# Ajuster la mise en page
plt.tight_layout()
# Sauvegarder ou afficher le graphique
if output_path:
# Modifier le nom du fichier pour indiquer qu'il s'agit du taux de complétion
base, ext = os.path.splitext(output_path)
completion_path = f"{base}_completion{ext}"
plt.savefig(completion_path, dpi=300, bbox_inches="tight")
print(f"Graphique de complétion sauvegardé: {completion_path}")
else:
plt.show()
def main():
"""Fonction principale."""
# Analyser les arguments de la ligne de commande
args = parse_args()
# Vérifier que le fichier CSV existe
if not os.path.isfile(args.csv_file):
print(f"Erreur: Le fichier {args.csv_file} n'existe pas.")
sys.exit(1)
# Charger les données
df = load_data(args.csv_file, args.insee, args.period)
# Vérifier qu'il y a des données
if df.empty:
print(f"Aucune donnée trouvée pour la période {args.period}.")
sys.exit(1)
# Déterminer le chemin de sortie si non spécifié
if not args.output:
# Utiliser le même nom que le fichier CSV mais avec l'extension .png
base_name = os.path.splitext(args.csv_file)[0]
output_path = f"{base_name}_{args.period}_graph.png"
else:
output_path = args.output
# Générer les graphiques
generate_graph(df, output_path)
generate_completion_graph(df, output_path)
print("Graphiques générés avec succès!")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,167 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script pour récupérer les polygones de toutes les communes françaises listées dans un fichier CSV.
Ce script:
1. Ouvre le fichier osm-commerces-villes-export.csv
2. Extrait les codes INSEE (colonne 'zone')
3. Pour chaque code INSEE, vérifie si le polygone existe déjà
4. Si non, utilise get_poly.py pour récupérer le polygone
Usage:
python get_all_polys.py
"""
import os
import sys
import csv
from get_poly import query_overpass_api, extract_polygon, save_polygon_to_file
# Chemin vers le fichier CSV contenant les codes INSEE
CSV_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "osm-commerces-villes-export.csv"
)
# Chemin vers le dossier où sont stockés les polygones
POLYGONS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "polygons")
def ensure_polygons_dir_exists():
"""
Vérifie que le dossier 'polygons' existe, sinon le crée.
"""
os.makedirs(POLYGONS_DIR, exist_ok=True)
print(f"Dossier de polygones: {POLYGONS_DIR}")
def polygon_exists(insee_code):
"""
Vérifie si le polygone pour le code INSEE donné existe déjà.
Args:
insee_code (str): Le code INSEE de la commune
Returns:
bool: True si le polygone existe, False sinon
"""
polygon_file = os.path.join(POLYGONS_DIR, f"commune_{insee_code}.poly")
return os.path.isfile(polygon_file)
def get_polygon(insee_code):
"""
Récupère le polygone pour le code INSEE donné.
Args:
insee_code (str): Le code INSEE de la commune
Returns:
str: Le chemin du fichier polygone créé, ou None en cas d'erreur
"""
try:
print(f"Récupération du polygone pour la commune {insee_code}...")
# Interroger l'API Overpass
data = query_overpass_api(insee_code)
# Extraire le polygone
polygon = extract_polygon(data)
# Sauvegarder le polygone dans un fichier
output_file = save_polygon_to_file(polygon, insee_code)
print(f"Polygone pour la commune {insee_code} sauvegardé dans {output_file}")
return output_file
except Exception as e:
print(f"Erreur lors de la récupération du polygone pour {insee_code}: {e}")
return None
def read_insee_codes_from_csv():
"""
Lit le fichier CSV et extrait les codes INSEE (colonne 'zone').
Returns:
list: Liste des codes INSEE
"""
insee_codes = []
try:
print(f"Lecture du fichier CSV: {CSV_FILE}")
if not os.path.isfile(CSV_FILE):
print(f"Erreur: Le fichier {CSV_FILE} n'existe pas.")
return insee_codes
with open(CSV_FILE, "r", encoding="utf-8") as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
if "zone" in row and row["zone"]:
insee_codes.append(row["zone"])
print(f"Nombre de codes INSEE trouvés: {len(insee_codes)}")
return insee_codes
except Exception as e:
print(f"Erreur lors de la lecture du fichier CSV: {e}")
return insee_codes
def main():
"""
Fonction principale du script.
"""
try:
# S'assurer que le dossier des polygones existe
ensure_polygons_dir_exists()
# Lire les codes INSEE depuis le fichier CSV
insee_codes = read_insee_codes_from_csv()
if not insee_codes:
print("Aucun code INSEE trouvé dans le fichier CSV.")
return 1
# Compteurs pour les statistiques
total = len(insee_codes)
existing = 0
created = 0
failed = 0
# Pour chaque code INSEE, récupérer le polygone s'il n'existe pas déjà
for i, insee_code in enumerate(insee_codes, 1):
print(f"\nTraitement de la commune {i}/{total}: {insee_code}")
if polygon_exists(insee_code):
print(f"Le polygone pour la commune {insee_code} existe déjà.")
existing += 1
continue
# Récupérer le polygone
result = get_polygon(insee_code)
if result:
created += 1
else:
failed += 1
# Afficher les statistiques
print("\nRésumé:")
print(f"Total des communes traitées: {total}")
print(f"Polygones déjà existants: {existing}")
print(f"Polygones créés avec succès: {created}")
print(f"Échecs: {failed}")
return 0 # Succès
except KeyboardInterrupt:
print("\nOpération annulée par l'utilisateur.")
return 1 # Erreur
except Exception as e:
print(f"Erreur inattendue: {e}")
return 1 # Erreur
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,287 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script pour récupérer le polygone d'une commune française à partir de son code INSEE.
Ce script:
1. Demande un code INSEE
2. Interroge l'API Overpass Turbo pour obtenir les limites administratives
3. Extrait le polygone de la commune
4. Sauvegarde le polygone dans un fichier
Usage:
python get_poly.py [code_insee]
Si le code INSEE n'est pas fourni en argument, le script le demandera interactivement.
"""
import sys
import os
import json
import urllib.parse
import urllib.request
import argparse
def get_insee_code():
"""
Récupère le code INSEE soit depuis les arguments de ligne de commande,
soit en demandant à l'utilisateur.
Returns:
str: Le code INSEE de la commune
"""
parser = argparse.ArgumentParser(
description="Récupère le polygone d'une commune à partir de son code INSEE"
)
parser.add_argument("insee", nargs="?", help="Code INSEE de la commune")
args = parser.parse_args()
if args.insee:
return args.insee
# Si le code INSEE n'est pas fourni en argument, le demander
return input("Entrez le code INSEE de la commune: ")
def query_overpass_api(insee_code):
"""
Interroge l'API Overpass pour obtenir les limites administratives d'une commune.
Args:
insee_code (str): Le code INSEE de la commune
Returns:
dict: Les données GeoJSON de la commune
"""
print(f"Récupération des limites administratives pour la commune {insee_code}...")
# Construire la requête Overpass QL pour obtenir la relation administrative
query = f"""
[out:json][timeout:60];
(
relation["boundary"="administrative"]["admin_level"="8"]["ref:INSEE"="{insee_code}"];
way(r);
node(w);
);
out geom;
"""
# Encoder la requête pour l'URL
encoded_query = urllib.parse.quote(query)
# Construire l'URL de l'API Overpass
url = f"https://overpass-api.de/api/interpreter?data={encoded_query}"
try:
# Envoyer la requête à l'API
print("Envoi de la requête à Overpass API...")
with urllib.request.urlopen(url) as response:
data = json.loads(response.read().decode("utf-8"))
# Afficher des informations sur la réponse (version réduite pour production)
print(
f"Réponse reçue de l'API Overpass. Nombre d'éléments: {len(data.get('elements', []))}"
)
return data
except Exception as e:
print(f"Erreur lors de la requête à l'API Overpass: {e}")
raise RuntimeError(f"Erreur lors de la requête à l'API Overpass: {e}")
def extract_polygon(data):
"""
Extrait le polygone des données GeoJSON.
Args:
data (dict): Les données GeoJSON de la commune
Returns:
list: Liste des coordonnées du polygone
"""
print("Extraction du polygone des données...")
# Vérifier si des éléments ont été trouvés
if not data.get("elements"):
print("Aucune limite administrative trouvée pour ce code INSEE.")
raise ValueError("Aucune limite administrative trouvée pour ce code INSEE.")
try:
# Collecter tous les nœuds (points) avec leurs coordonnées
nodes = {}
for element in data["elements"]:
if element["type"] == "node":
nodes[element["id"]] = (element["lon"], element["lat"])
# Trouver les ways qui forment le contour de la commune
ways = []
for element in data["elements"]:
if element["type"] == "way":
ways.append(element)
# Si aucun way n'est trouvé, essayer d'extraire directement les coordonnées des nœuds
if not ways and nodes:
print("Aucun way trouvé. Utilisation directe des nœuds...")
polygon = list(nodes.values())
return polygon
# Trouver la relation administrative
relation = None
for element in data["elements"]:
if (
element["type"] == "relation"
and element.get("tags", {}).get("boundary") == "administrative"
):
relation = element
break
if not relation:
print("Aucune relation administrative trouvée.")
# Si nous avons des ways, nous pouvons essayer de les utiliser directement
if ways:
print("Tentative d'utilisation directe des ways...")
# Prendre le premier way comme contour
way = ways[0]
polygon = []
for node_id in way.get("nodes", []):
if node_id in nodes:
polygon.append(nodes[node_id])
return polygon
raise ValueError(
"Impossible de trouver une relation administrative ou des ways"
)
# Extraire les ways qui forment le contour extérieur de la relation
outer_ways = []
for member in relation.get("members", []):
if member.get("role") == "outer" and member.get("type") == "way":
# Trouver le way correspondant
for way in ways:
if way["id"] == member["ref"]:
outer_ways.append(way)
break
# Si aucun way extérieur n'est trouvé, utiliser tous les ways
if not outer_ways:
print("Aucun way extérieur trouvé. Utilisation de tous les ways...")
outer_ways = ways
# Construire le polygone à partir des ways extérieurs
polygon = []
for way in outer_ways:
for node_id in way.get("nodes", []):
if node_id in nodes:
polygon.append(nodes[node_id])
if not polygon:
raise ValueError("Impossible d'extraire le polygone de la relation")
print(f"Polygone extrait avec {len(polygon)} points.")
return polygon
except Exception as e:
print(f"Erreur lors de l'extraction du polygone: {e}")
raise RuntimeError(f"Erreur lors de l'extraction du polygone: {e}")
def save_polygon_to_file(polygon, insee_code):
"""
Sauvegarde le polygone dans un fichier.
Args:
polygon (list): Liste des coordonnées du polygone
insee_code (str): Le code INSEE de la commune
Returns:
str: Le chemin du fichier créé
Raises:
ValueError: Si le polygone est vide ou invalide
IOError: Si une erreur survient lors de l'écriture du fichier
"""
if not polygon:
raise ValueError("Le polygone est vide")
try:
# Créer le répertoire de sortie s'il n'existe pas
output_dir = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "polygons"
)
os.makedirs(output_dir, exist_ok=True)
# Définir le nom du fichier de sortie
output_file = os.path.join(output_dir, f"commune_{insee_code}.poly")
print(f"Sauvegarde du polygone dans le fichier {output_file}...")
# Écrire le polygone dans le fichier au format .poly (format utilisé par Osmosis)
with open(output_file, "w") as f:
f.write(f"commune_{insee_code}\n")
f.write("1\n") # Numéro de section
# Écrire les coordonnées
for i, (lon, lat) in enumerate(polygon):
f.write(f" {lon:.7f} {lat:.7f}\n")
# Fermer le polygone en répétant le premier point
if len(polygon) > 1 and polygon[0] != polygon[-1]:
lon, lat = polygon[0]
f.write(f" {lon:.7f} {lat:.7f}\n")
f.write("END\n")
f.write("END\n")
print(f"Polygone sauvegardé avec succès dans {output_file}")
return output_file
except IOError as e:
print(f"Erreur lors de l'écriture du fichier: {e}")
raise # Re-raise the IOError
except Exception as e:
print(f"Erreur inattendue lors de la sauvegarde du polygone: {e}")
raise RuntimeError(f"Erreur inattendue lors de la sauvegarde du polygone: {e}")
def main():
"""
Fonction principale du script.
"""
try:
# Récupérer le code INSEE
insee_code = get_insee_code()
# Vérifier que le code INSEE est valide (format numérique ou alphanumérique pour les DOM-TOM)
if not insee_code:
raise ValueError("Le code INSEE ne peut pas être vide")
if not insee_code.isalnum() or len(insee_code) not in [5, 3]:
raise ValueError(
"Code INSEE invalide. Il doit être composé de 5 chiffres (ou 3 pour certains territoires)."
)
# Interroger l'API Overpass
data = query_overpass_api(insee_code)
# Extraire le polygone
polygon = extract_polygon(data)
# Sauvegarder le polygone dans un fichier
output_file = save_polygon_to_file(polygon, insee_code)
print(
f"Terminé. Le polygone de la commune {insee_code} a été sauvegardé dans {output_file}"
)
return 0 # Succès
except ValueError as e:
print(f"Erreur de validation: {e}")
return 1 # Erreur
except KeyboardInterrupt:
print("\nOpération annulée par l'utilisateur.")
return 1 # Erreur
except Exception as e:
print(f"Erreur inattendue: {e}")
return 1 # Erreur
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,301 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script principal pour lancer l'analyse historique d'une ville.
Ce script:
1. Demande à l'utilisateur quelle ville il souhaite traiter
2. Trouve le code INSEE de la ville demandée
3. Vérifie si le polygone de la ville existe, sinon le récupère
4. Traite les données historiques OSM pour cette ville
Usage:
python historize_zone.py [--input fichier_historique.osh.pbf]
"""
import os
import sys
import csv
import argparse
import subprocess
from pathlib import Path
# Chemin vers le répertoire du script
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Chemin vers le fichier CSV contenant les données des villes
CITIES_CSV = os.path.join(SCRIPT_DIR, "osm-commerces-villes-export.csv")
# Chemin vers le répertoire des polygones
POLYGONS_DIR = os.path.join(SCRIPT_DIR, "polygons")
# Chemin par défaut pour le fichier d'historique OSM France
DEFAULT_HISTORY_FILE = os.path.join(SCRIPT_DIR, "osm_data", "france-internal.osh.pbf")
def run_command(command):
"""Exécute une commande shell et retourne la sortie"""
print(f"Exécution: {command}")
try:
result = subprocess.run(
command,
shell=True,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
return result.stdout
except subprocess.CalledProcessError as e:
print(f"Erreur lors de l'exécution de la commande: {e}")
print(f"Sortie de la commande: {e.stdout}")
print(f"Erreur de la commande: {e.stderr}")
return None
def load_cities():
"""
Charge les données des villes depuis le fichier CSV.
Returns:
dict: Dictionnaire des villes avec le nom comme clé et les données comme valeur
"""
cities = {}
try:
with open(CITIES_CSV, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
if row.get("name") and row.get("zone"):
cities[row["name"].lower()] = row
except Exception as e:
print(f"Erreur lors du chargement du fichier CSV des villes: {e}")
sys.exit(1)
return cities
def find_city(city_name, cities):
"""
Recherche une ville par son nom dans le dictionnaire des villes.
Args:
city_name (str): Nom de la ville à rechercher
cities (dict): Dictionnaire des villes
Returns:
dict: Données de la ville si trouvée, None sinon
"""
# Recherche exacte
if city_name.lower() in cities:
return cities[city_name.lower()]
# Recherche partielle
matches = []
for name, data in cities.items():
if city_name.lower() in name:
matches.append(data)
if not matches:
return None
# Si plusieurs correspondances, demander à l'utilisateur de choisir
if len(matches) > 1:
print(f"Plusieurs villes correspondent à '{city_name}':")
for i, city in enumerate(matches):
print(f"{i+1}. {city['name']} (INSEE: {city['zone']})")
choice = input("Entrez le numéro de la ville souhaitée (ou 'q' pour quitter): ")
if choice.lower() == "q":
sys.exit(0)
try:
index = int(choice) - 1
if 0 <= index < len(matches):
return matches[index]
else:
print("Choix invalide.")
return None
except ValueError:
print("Veuillez entrer un numéro valide.")
return None
return matches[0]
def check_polygon_exists(insee_code):
"""
Vérifie si le polygone d'une commune existe déjà.
Args:
insee_code (str): Code INSEE de la commune
Returns:
str: Chemin vers le fichier polygone s'il existe, None sinon
"""
poly_file = os.path.join(POLYGONS_DIR, f"commune_{insee_code}.poly")
if os.path.isfile(poly_file):
return poly_file
return None
def get_polygon(insee_code):
"""
Récupère le polygone d'une commune à partir de son code INSEE.
Args:
insee_code (str): Code INSEE de la commune
Returns:
str: Chemin vers le fichier polygone créé, None en cas d'erreur
"""
get_poly_script = os.path.join(SCRIPT_DIR, "get_poly.py")
command = f"python3 {get_poly_script} {insee_code}"
output = run_command(command)
if output:
# Vérifier si le polygone a été créé
poly_file = check_polygon_exists(insee_code)
if poly_file:
return poly_file
return None
def process_city_history(input_file, poly_file, cleanup=False, benchmark=False):
"""
Traite l'historique OSM pour une ville.
Args:
input_file (str): Chemin vers le fichier d'historique OSM
poly_file (str): Chemin vers le fichier polygone de la ville
cleanup (bool): Si True, nettoie les fichiers temporaires après traitement
benchmark (bool): Si True, affiche des informations de performance détaillées
Returns:
bool: True si le traitement a réussi, False sinon
"""
loop_script = os.path.join(
SCRIPT_DIR, "loop_thematics_history_in_zone_to_counts.py"
)
output_dir = os.path.join(SCRIPT_DIR, "test_results")
temp_dir = os.path.join(SCRIPT_DIR, "test_temp")
# Créer les répertoires de sortie si nécessaires
os.makedirs(output_dir, exist_ok=True)
os.makedirs(temp_dir, exist_ok=True)
# Construire la commande avec les options supplémentaires
command = f"python3 {loop_script} --input {input_file} --poly {poly_file} --output-dir {output_dir} --temp-dir {temp_dir} --max-dates 100"
# Ajouter les options de nettoyage et de benchmark si activées
if cleanup:
command += " --cleanup"
if benchmark:
command += " --benchmark"
print(f"Exécution de la commande: {command}")
output = run_command(command)
if output is not None:
return True
return False
def main():
"""Fonction principale"""
parser = argparse.ArgumentParser(
description="Analyse historique d'une ville dans OpenStreetMap."
)
parser.add_argument(
"--input",
"-i",
default=DEFAULT_HISTORY_FILE,
help=f"Fichier d'historique OSM (.osh.pbf). Par défaut: {DEFAULT_HISTORY_FILE}",
)
parser.add_argument(
"--cleanup",
"-c",
action="store_true",
help="Nettoyer les fichiers temporaires après traitement",
)
parser.add_argument(
"--benchmark",
"-b",
action="store_true",
help="Afficher des informations de performance détaillées",
)
parser.add_argument(
"--city",
"-v",
help="Nom de la ville à traiter (si non spécifié, demande interactive)",
)
args = parser.parse_args()
# Vérifier que le fichier d'historique existe
if not os.path.isfile(args.input):
print(f"Erreur: Le fichier d'historique {args.input} n'existe pas.")
print(
f"Veuillez spécifier un fichier d'historique valide avec l'option --input."
)
sys.exit(1)
# Charger les données des villes
cities = load_cities()
if not cities:
print("Aucune ville n'a été trouvée dans le fichier CSV.")
sys.exit(1)
print(f"Données chargées pour {len(cities)} villes.")
# Obtenir le nom de la ville à traiter
city_name = args.city
if not city_name:
# Mode interactif si aucune ville n'est spécifiée
city_name = input("Quelle ville souhaitez-vous traiter ? ")
# Rechercher la ville
city = find_city(city_name, cities)
if not city:
print(f"Aucune ville correspondant à '{city_name}' n'a été trouvée.")
sys.exit(1)
insee_code = city["zone"]
print(f"Ville trouvée: {city['name']} (INSEE: {insee_code})")
# Vérifier si le polygone existe
poly_file = check_polygon_exists(insee_code)
if poly_file:
print(f"Le polygone pour {city['name']} existe déjà: {poly_file}")
else:
print(f"Le polygone pour {city['name']} n'existe pas. Récupération en cours...")
poly_file = get_polygon(insee_code)
if not poly_file:
print(f"Erreur: Impossible de récupérer le polygone pour {city['name']}.")
sys.exit(1)
print(f"Polygone récupéré avec succès: {poly_file}")
# Afficher les options activées
if args.benchmark:
print("\n=== Options ===")
print(
f"Nettoyage des fichiers temporaires: {'Activé' if args.cleanup else 'Désactivé'}"
)
print(f"Benchmark: Activé")
print("===============\n")
# Traiter l'historique pour cette ville
print(f"Traitement de l'historique OSM pour {city['name']}...")
success = process_city_history(args.input, poly_file, args.cleanup, args.benchmark)
if success:
print(f"Traitement terminé avec succès pour {city['name']}.")
else:
print(f"Erreur lors du traitement de l'historique pour {city['name']}.")
sys.exit(1)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,9 @@
# get latest france with cli
source ./secrets.sh
python3 /home/poule/encrypted/stockage-syncable/www/development/html/sendfile_osm_oauth_protector/oauth_cookie_client.py \
-u "$OSM_USER" -p "$OSM_PASS" \
-c https://osm-internal.download.geofabrik.de/get_cookie \
-o cookie.txt
wget -N --no-cookies --header "Cookie: $(cat cookie.txt | cut -d ';' -f 1)" https://osm-internal.download.geofabrik.de/europe/france-latest-internal.osm.pbf -O osm_data/france-internal.osh.pbf

View file

@ -0,0 +1,952 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script pour compter les objets OSM par thématique sur une zone donnée à différentes dates.
Ce script utilise osmium pour:
1. Filtrer les données historiques OSM à différentes dates (mensuelles sur les 10 dernières années)
2. Compter les objets correspondant à chaque thématique à chaque date
3. Calculer le pourcentage de complétion des attributs importants pour chaque thème
4. Sauvegarder les résultats dans des fichiers CSV
5. Générer des graphiques montrant l'évolution dans le temps
Exemple d'utilisation:
python3 loop_thematics_history_in_zone_to_counts.py --input france.osh.pbf --poly polygons/commune_91111.poly
"""
import os
import sys
import subprocess
import csv
import json
import argparse
import multiprocessing
from datetime import datetime, timedelta
from pathlib import Path
from functools import lru_cache
import time
# Définition des thématiques et leurs tags correspondants (repris de split_history_to_thematics.py)
THEMES = {
"borne-de-recharge": {
"tag_filter": "amenity=charging_station",
"important_tags": ["operator", "capacity"],
},
"borne-incendie": {
"tag_filter": "emergency=fire_hydrant",
"important_tags": ["ref", "colour"],
},
"arbres": {
"tag_filter": "natural=tree",
"important_tags": ["species", "leaf_type"],
},
"defibrillator": {
"tag_filter": "emergency=defibrillator",
"important_tags": ["operator", "access"],
},
"toilets": {
"tag_filter": "amenity=toilets",
"important_tags": ["access", "wheelchair"],
},
"bus_stop": {
"tag_filter": "highway=bus_stop",
"important_tags": ["name", "shelter"],
},
"camera": {
"tag_filter": "man_made=surveillance",
"important_tags": ["operator", "surveillance"],
},
"recycling": {
"tag_filter": "amenity=recycling",
"important_tags": ["recycling_type", "operator"],
},
"substation": {
"tag_filter": "power=substation",
"important_tags": ["operator", "voltage"],
},
"laboratory": {
"tag_filter": "healthcare=laboratory",
"important_tags": ["name", "operator"],
},
"school": {"tag_filter": "amenity=school", "important_tags": ["name", "operator"]},
"police": {"tag_filter": "amenity=police", "important_tags": ["name", "operator"]},
"healthcare": {
"tag_filter": "healthcare or amenity=doctors or amenity=pharmacy or amenity=hospital or amenity=clinic or amenity=social_facility",
"important_tags": ["name", "healthcare"],
},
"bicycle_parking": {
"tag_filter": "amenity=bicycle_parking",
"important_tags": ["capacity", "covered"],
},
"advertising_board": {
"tag_filter": "advertising=board and message=political",
"important_tags": ["operator", ],
},
"building": {
"tag_filter": "building",
"important_tags": ["building", "addr:housenumber"],
},
"email": {
"tag_filter": "email or contact:email",
"important_tags": ["email", "contact:email"],
},
"bench": {
"tag_filter": "amenity=bench",
# "important_tags": ["backrest", "material"]
},
"waste_basket": {
"tag_filter": "amenity=waste_basket",
# "important_tags": ["operator", "capacity"]
},
"street_lamp": {
"tag_filter": "highway=street_lamp",
# "important_tags": ["light:method", "operator"]
},
"drinking_water": {
"tag_filter": "amenity=drinking_water",
# "important_tags": ["drinking_water", "bottle"]
},
"power_pole": {
"tag_filter": "power=pole",
# "important_tags": ["ref", "operator"]
},
"manhole": {
"tag_filter": "man_made=manhole",
# "important_tags": ["man_made", "substance"]
},
"little_free_library": {
"tag_filter": "amenity=public_bookcase",
# "important_tags": ["amenity", "capacity"]
},
"playground": {
"tag_filter": "leisure=playground",
# "important_tags": ["name", "surface"]
},
"siret": {
"tag_filter": "ref:FR:SIRET",
# "important_tags": ["name", "surface"]
},
"restaurants": {
"tag_filter": "amenity=restaurant",
"important_tags": ["opening_hours", "contact:street", "contact:housenumber", "website", "contact:phone"]
},
"rnb": {
"tag_filter": "ref:FR:RNB",
# "important_tags": ["name", "surface"]
},
}
def run_command(command):
"""Exécute une commande shell et retourne la sortie"""
print(f"Exécution: {command}")
try:
result = subprocess.run(
command,
shell=True,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
return result.stdout
except subprocess.CalledProcessError as e:
print(f"Erreur lors de l'exécution de la commande: {e}")
print(f"Sortie de la commande: {e.stdout}")
print(f"Erreur de la commande: {e.stderr}")
return None
@lru_cache(maxsize=128)
def run_command_cached(command):
"""Version mise en cache de run_command pour éviter les appels redondants"""
return run_command(command)
def count_objects_at_date(
input_file, date_str, tag_filter, output_dir=None, insee_code=None
):
"""
Compte les objets correspondant à un filtre de tag à une date spécifique.
Args:
input_file: Fichier d'historique OSM (.osh.pbf)
date_str: Date au format ISO (YYYY-MM-DDThh:mm:ssZ)
tag_filter: Filtre de tag (ex: "amenity=charging_station")
output_dir: Répertoire de sortie pour les fichiers temporaires
insee_code: Code INSEE de la commune (optionnel)
Returns:
Nombre d'objets correspondant au filtre à la date spécifiée
"""
# Créer un répertoire temporaire si nécessaire
if output_dir:
os.makedirs(output_dir, exist_ok=True)
insee_suffix = f"_insee_{insee_code}" if insee_code else ""
temp_file = os.path.join(
output_dir, f"temp_{date_str.replace(':', '_')}__{insee_suffix}.osm.pbf"
)
# Vérifier si le fichier temporaire existe déjà
if not os.path.exists(temp_file):
time_filter_cmd = f"osmium time-filter {input_file} {date_str} -O -o {temp_file} -f osm.pbf"
run_command(time_filter_cmd)
tags_count_cmd = f"osmium tags-count {temp_file} -F osm.pbf {tag_filter}"
else:
# Utiliser des pipes comme dans l'exemple
tags_count_cmd = f"osmium time-filter {input_file} {date_str} -O -o - -f osm.pbf | osmium tags-count - -F osm.pbf {tag_filter}"
# Exécuter la commande et récupérer le résultat (utiliser la version mise en cache)
output = run_command_cached(tags_count_cmd)
# Analyser la sortie pour obtenir le nombre d'objets
if output:
# La sortie d'osmium tags-count est au format "count "key" "value"" ou "count "key""
# Par exemple: "42 "amenity" "charging_station"" ou "42 "operator""
parts = output.strip().split()
if len(parts) >= 1:
try:
return int(parts[0])
except ValueError:
print(f"Impossible de convertir '{parts[0]}' en entier")
return None
return None
def export_to_geojson(
input_file, date_str, main_tag_filter, output_dir, insee_code=None
):
"""
Exporte les données OSM filtrées à une date spécifique vers un fichier GeoJSON.
Si le fichier GeoJSON ou les fichiers temporaires existent déjà, l'export est ignoré.
Args:
input_file: Fichier d'historique OSM (.osh.pbf)
date_str: Date au format ISO (YYYY-MM-DDThh:mm:ssZ)
main_tag_filter: Filtre de tag principal (ex: "amenity=charging_station")
output_dir: Répertoire pour les fichiers temporaires
insee_code: Code INSEE de la commune (optionnel)
Returns:
Chemin vers le fichier GeoJSON créé
"""
os.makedirs(output_dir, exist_ok=True)
# Ajouter le code INSEE au nom du fichier si disponible
insee_suffix = f"_insee_{insee_code}" if insee_code else ""
# Définir le chemin du fichier GeoJSON de sortie
geojson_file = os.path.join(
output_dir, f"export_{date_str.replace(':', '_')}__{insee_suffix}.geojson"
)
# Vérifier si le fichier GeoJSON existe déjà
if os.path.exists(geojson_file):
return geojson_file
# Définir les chemins des fichiers temporaires
temp_file = os.path.join(
output_dir, f"temp_{date_str.replace(':', '_')}__{insee_suffix}.osm.pbf"
)
filtered_file = os.path.join(
output_dir,
f"filtered_{date_str.replace(':', '_')}__{main_tag_filter.replace('=', '_').replace(' ', '_')}__{insee_suffix}.osm.pbf",
)
# Vérifier si le fichier temporaire filtré par date existe déjà
if not os.path.exists(temp_file):
# Créer un fichier temporaire pour les données filtrées par date
time_filter_cmd = (
f"osmium time-filter {input_file} {date_str} -O -o {temp_file} -f osm.pbf"
)
run_command_cached(time_filter_cmd)
# Vérifier si le fichier filtré par tag existe déjà
if not os.path.exists(filtered_file):
# Filtrer les objets qui ont le tag principal
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"
run_command_cached(export_cmd)
return geojson_file
def count_features_with_tags_in_geojson(geojson_file, attribute_tags):
"""
Compte les features dans un fichier GeoJSON qui ont des attributs spécifiques.
Args:
geojson_file: Chemin vers le fichier GeoJSON
attribute_tags: Liste d'attributs à vérifier (ex: ["operator", "capacity"])
Returns:
Dictionnaire avec le nombre de features ayant chaque attribut spécifié
"""
try:
with open(geojson_file, "r") as f:
geojson_data = json.load(f)
# Initialiser les compteurs pour chaque attribut
counts = {tag: 0 for tag in attribute_tags}
# Compter en une seule passe
for feature in geojson_data.get("features", []):
properties = feature.get("properties", {})
for tag in attribute_tags:
if tag in properties and properties[tag]:
counts[tag] += 1
return counts
except Exception as e:
print(
f"Erreur lors du comptage des features avec les attributs {attribute_tags}: {e}"
)
return {tag: None for tag in attribute_tags}
def count_objects_with_tags(
input_file,
date_str,
main_tag_filter,
attribute_tags,
output_dir=None,
insee_code=None,
):
"""
Compte les objets qui ont à la fois le tag principal et des attributs spécifiques.
Args:
input_file: Fichier d'historique OSM (.osh.pbf)
date_str: Date au format ISO (YYYY-MM-DDThh:mm:ssZ)
main_tag_filter: Filtre de tag principal (ex: "amenity=charging_station")
attribute_tags: Liste d'attributs à vérifier (ex: ["operator", "capacity"])
output_dir: Répertoire pour les fichiers temporaires
insee_code: Code INSEE de la commune (optionnel)
Returns:
Dictionnaire avec le nombre d'objets ayant chaque attribut spécifié
"""
# Utiliser l'export GeoJSON si un répertoire de sortie est spécifié
if output_dir:
# Exporter vers GeoJSON (la fonction export_to_geojson vérifie déjà si le fichier GeoJSON existe)
geojson_file = export_to_geojson(
input_file, date_str, main_tag_filter, output_dir, insee_code
)
# Compter les features avec les attributs spécifiques en une seule passe
return count_features_with_tags_in_geojson(geojson_file, attribute_tags)
else:
# Méthode alternative si pas de répertoire temporaire
counts = {}
for tag in attribute_tags:
combined_filter = f"{main_tag_filter} and {tag}"
counts[tag] = count_objects_at_date(
input_file, date_str, combined_filter, output_dir, insee_code
)
return counts
def count_objects_with_tag(
input_file,
date_str,
main_tag_filter,
attribute_tag,
output_dir=None,
insee_code=None,
):
"""
Compte les objets qui ont à la fois le tag principal et un attribut spécifique.
Version compatible avec l'ancienne API pour la rétrocompatibilité.
Args:
input_file: Fichier d'historique OSM (.osh.pbf)
date_str: Date au format ISO (YYYY-MM-DDThh:mm:ssZ)
main_tag_filter: Filtre de tag principal (ex: "amenity=charging_station")
attribute_tag: Attribut à vérifier (ex: "operator")
output_dir: Répertoire pour les fichiers temporaires
insee_code: Code INSEE de la commune (optionnel)
Returns:
Nombre d'objets ayant à la fois le tag principal et l'attribut spécifié
"""
result = count_objects_with_tags(
input_file, date_str, main_tag_filter, [attribute_tag], output_dir, insee_code
)
return result.get(attribute_tag, None)
def generate_time_slices(max_dates=None):
"""
Génère une liste de dates avec différentes fréquences selon l'ancienneté:
- 30 derniers jours: quotidien
- 12 derniers mois: mensuel
- Jusqu'à 2004: annuel
Args:
max_dates: Nombre maximum de dates à générer (pour les tests)
Returns:
Liste de dates au format ISO (YYYY-MM-DDThh:mm:ssZ)
"""
dates = []
now = datetime.now()
# 1. Dates quotidiennes pour les 30 derniers jours
current_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
for _ in range(30):
date_str = current_date.strftime("%Y-%m-%dT00:00:00Z")
dates.append(date_str)
current_date -= timedelta(days=1)
# 2. Dates mensuelles pour les 12 derniers mois (en excluant le mois courant déjà couvert)
current_date = datetime(now.year, now.month, 1) - timedelta(
days=1
) # Dernier jour du mois précédent
current_date = datetime(
current_date.year, current_date.month, 1
) # Premier jour du mois précédent
for _ in range(
60
): # 11 mois supplémentaires (12 mois au total avec le mois courant)
date_str = current_date.strftime("%Y-%m-%dT00:00:00Z")
if date_str not in dates: # Éviter les doublons
dates.append(date_str)
# Passer au mois précédent
if current_date.month == 1:
current_date = datetime(current_date.year - 1, 12, 1)
else:
current_date = datetime(current_date.year, current_date.month - 1, 1)
# 3. Dates annuelles de l'année précédente jusqu'à 2004
# Amélioration: deux mesures par an (1er janvier et 1er juillet) pour les périodes éloignées
start_year = min(
now.year - 1, 2023
) # Commencer à l'année précédente (ou 2023 si nous sommes en 2024)
for year in range(start_year, 2003, -1):
# Ajouter le 1er janvier
date_str_jan = f"{year}-01-01T00:00:00Z"
if date_str_jan not in dates: # Éviter les doublons
dates.append(date_str_jan)
# Ajouter le 1er juillet
date_str_jul = f"{year}-07-01T00:00:00Z"
if date_str_jul not in dates: # Éviter les doublons
dates.append(date_str_jul)
# Limiter le nombre de dates si spécifié
if max_dates is not None and max_dates > 0:
dates = dates[:max_dates]
# Trier les dates par ordre chronologique
dates.sort()
return dates
def extract_zone_data(input_file, poly_file, output_dir):
"""
Extrait les données pour une zone spécifique à partir d'un fichier d'historique OSM.
Args:
input_file: Fichier d'historique OSM (.osh.pbf)
poly_file: Fichier de polygone (.poly) définissant la zone
output_dir: Répertoire de sortie
Returns:
Chemin vers le fichier extrait
"""
os.makedirs(output_dir, exist_ok=True)
# Obtenir le nom de la zone à partir du nom du fichier poly
zone_name = Path(poly_file).stem
# Créer le fichier de sortie
output_file = os.path.join(output_dir, f"{zone_name}.osh.pbf")
# Exécuter la commande osmium extract seulement si le fichier n'existe pas déjà
if os.path.exists(output_file):
print(f"Le fichier {output_file} existe déjà, utilisation du fichier existant.")
else:
print(f"Extraction des données pour la zone {zone_name}...")
command = f"osmium extract -p {poly_file} -H {input_file} -O -o {output_file}"
run_command(command)
return output_file
def process_theme(
theme_name,
theme_info,
zone_file,
zone_name,
dates,
output_dir,
temp_dir,
insee_code=None,
):
"""
Traite une thématique spécifique pour une zone donnée.
Args:
theme_name: Nom de la thématique
theme_info: Informations sur la thématique (tag_filter, important_tags)
zone_file: Fichier de la zone à traiter
zone_name: Nom de la zone
dates: Liste des dates à traiter
output_dir: Répertoire pour les fichiers de sortie
temp_dir: Répertoire pour les fichiers temporaires
insee_code: Code INSEE de la commune (optionnel)
Returns:
Chemin vers le fichier CSV généré
"""
start_time = time.time()
print(f"Traitement de la thématique '{theme_name}' pour la zone '{zone_name}'...")
# Préparer le fichier CSV de sortie
csv_file = os.path.join(output_dir, f"{zone_name}_{theme_name}.csv")
# Entêtes du CSV - colonnes de base
headers = ["date", "zone", "theme", "nombre_total"]
# Ajouter une colonne pour chaque tag important
# Vérifier si la clé 'important_tags' existe, sinon utiliser une liste vide
important_tags = theme_info.get("important_tags", [])
for attr in important_tags:
headers.append(f"nombre_avec_{attr}")
# Ajouter la colonne de pourcentage de complétion
headers.append("pourcentage_completion")
# Vérifier si le fichier CSV existe déjà et lire les données existantes
existing_dates = set()
existing_rows = []
existing_data = {} # Dictionnaire pour stocker les données existantes par date
file_exists = os.path.exists(csv_file)
if file_exists:
try:
with open(csv_file, "r", newline="") as f:
reader = csv.reader(f)
existing_headers = next(reader) # Lire les entêtes
# Vérifier que les entêtes correspondent
if existing_headers == headers:
for row in reader:
if len(row) >= 1: # S'assurer que la ligne a au moins une date
date = row[0] # La date est dans la première colonne
existing_dates.add(date)
existing_rows.append(row)
# Stocker les données existantes dans un dictionnaire pour un accès facile
existing_data[date] = row
else:
print(f"Les entêtes du fichier existant ne correspondent pas, création d'un nouveau fichier.")
file_exists = False
except Exception as e:
print(f"Erreur lors de la lecture du fichier CSV existant: {e}")
file_exists = False
# Filtrer les dates qui n'ont pas encore été traitées
dates_to_process = [date_str for date_str in dates if date_str.split("T")[0] not in existing_dates]
# Mode d'ouverture du fichier (écriture ou ajout)
mode = "a" if file_exists else "w"
with open(csv_file, mode, newline="") as f:
writer = csv.writer(f)
# Écrire les entêtes si c'est un nouveau fichier
if not file_exists:
writer.writerow(headers)
# Traiter chaque date qui n'a pas encore été traitée
for date_str in dates_to_process:
tag_filter = theme_info["tag_filter"]
# Compter le nombre total d'objets
total_count = count_objects_at_date(
zone_file, date_str, tag_filter, temp_dir, insee_code
)
# Compter les objets avec chaque attribut important en une seule passe
# Vérifier si la clé 'important_tags' existe et n'est pas vide
important_tags = theme_info.get("important_tags", [])
if important_tags:
attr_counts_dict = count_objects_with_tags(
zone_file,
date_str,
tag_filter,
important_tags,
temp_dir,
insee_code,
)
attr_counts = [
(attr, attr_counts_dict.get(attr, 0)) for attr in important_tags
]
else:
attr_counts = []
# Formater la date pour le CSV (YYYY-MM-DD)
csv_date = date_str.split("T")[0]
# Vérifier si le total_count est 0 et s'il était > 0 dans le passé
recalculate = False
if total_count == 0:
# Parcourir les données existantes pour voir si une valeur était > 0 dans le passé
was_greater_than_zero = False
for existing_date in sorted(existing_data.keys()):
if existing_date >= csv_date:
break # Ne pas regarder les dates futures
existing_row = existing_data[existing_date]
if len(existing_row) >= 4: # S'assurer que la ligne a une valeur de nombre_total
try:
existing_total = existing_row[3]
if existing_total and existing_total != "" and int(existing_total) > 0:
was_greater_than_zero = True
break
except (ValueError, TypeError):
# Ignorer les valeurs qui ne peuvent pas être converties en entier
pass
if was_greater_than_zero:
# Si une valeur était > 0 dans le passé et est maintenant 0, remplacer par une valeur vide
total_count = ""
print(f"Valeur passée de > 0 à 0 pour {theme_name} à la date {csv_date}, remplacée par une valeur vide.")
# Relancer le calcul osmium
recalculate = True
# Vérifier également pour chaque attribut important
for i, (attr, count) in enumerate(attr_counts):
if count == 0:
# Parcourir les données existantes pour voir si une valeur était > 0 dans le passé
was_greater_than_zero = False
for existing_date in sorted(existing_data.keys()):
if existing_date >= csv_date:
break # Ne pas regarder les dates futures
existing_row = existing_data[existing_date]
if len(existing_row) >= 4 + i + 1: # S'assurer que la ligne a une valeur pour cet attribut
try:
existing_attr_count = existing_row[4 + i]
if existing_attr_count and existing_attr_count != "" and int(existing_attr_count) > 0:
was_greater_than_zero = True
break
except (ValueError, TypeError):
# Ignorer les valeurs qui ne peuvent pas être converties en entier
pass
if was_greater_than_zero:
# Si une valeur était > 0 dans le passé et est maintenant 0, remplacer par une valeur vide
attr_counts[i] = (attr, "")
print(f"Valeur de {attr} passée de > 0 à 0 pour {theme_name} à la date {csv_date}, remplacée par une valeur vide.")
# Relancer le calcul osmium
recalculate = True
# Si on doit recalculer, relancer le calcul osmium
if recalculate:
print(f"Relancement du calcul osmium pour {theme_name} à la date {csv_date}...")
# Supprimer les fichiers temporaires pour forcer un recalcul
temp_file_pattern = os.path.join(temp_dir, f"temp_{date_str.replace(':', '_')}__*")
run_command(f"rm -f {temp_file_pattern}")
# Recalculer le nombre total d'objets
if total_count == "":
total_count = count_objects_at_date(
zone_file, date_str, tag_filter, temp_dir, insee_code
)
# Recalculer les objets avec chaque attribut important
if important_tags:
attr_counts_dict = count_objects_with_tags(
zone_file,
date_str,
tag_filter,
important_tags,
temp_dir,
insee_code,
)
attr_counts = [
(attr, attr_counts_dict.get(attr, 0)) for attr in important_tags
]
# Calculer le pourcentage de complétion
if total_count is not None and total_count != "" and total_count > 0 and len(attr_counts) > 0:
# Filtrer les comptages None ou vides avant de calculer la moyenne
valid_counts = [(attr, count) for attr, count in attr_counts if count is not None and count != ""]
if valid_counts:
# Moyenne des pourcentages de présence de chaque attribut important
completion_pct = sum(
count / total_count * 100 for _, count in valid_counts
) / len(valid_counts)
else:
completion_pct = 0
else:
completion_pct = 0
# Préparer la ligne CSV avec les colonnes de base
# Si le comptage total a échoué (None), ajouter une chaîne vide au lieu de 0
row = [csv_date, zone_name, theme_name, "" if total_count is None else total_count]
# Ajouter les compteurs pour chaque attribut important
for attr, count in attr_counts:
# Si le comptage a échoué (None), ajouter une chaîne vide au lieu de 0
row.append("" if count is None else count)
# Ajouter le pourcentage de complétion
row.append(round(completion_pct, 2))
# Écrire la ligne dans le CSV
writer.writerow(row)
# Si aucune nouvelle date n'a été traitée, afficher un message
if not dates_to_process:
print(f"Toutes les dates pour la thématique '{theme_name}' sont déjà traitées dans le fichier CSV existant.")
print(f"Résultats sauvegardés dans {csv_file}")
# Générer un graphique pour cette thématique
generate_graph(csv_file, zone_name, theme_name)
end_time = time.time()
print(f"Thématique '{theme_name}' traitée en {end_time - start_time:.2f} secondes")
return csv_file
def cleanup_temp_files(temp_dir, keep_zone_files=True):
"""
Nettoie les fichiers temporaires dans le répertoire spécifié.
Args:
temp_dir: Répertoire contenant les fichiers temporaires
keep_zone_files: Si True, conserve les fichiers de zone extraits (.osh.pbf)
"""
print(f"Nettoyage des fichiers temporaires dans {temp_dir}...")
count = 0
for file in os.listdir(temp_dir):
file_path = os.path.join(temp_dir, file)
# Conserver les fichiers de zone extraits si demandé
if (
keep_zone_files
and file.endswith(".osh.pbf")
and not file.startswith("temp_")
and not file.startswith("filtered_")
):
continue
# Supprimer les fichiers temporaires
if (
file.startswith("temp_")
or file.startswith("filtered_")
or file.startswith("export_")
):
try:
os.remove(file_path)
count += 1
except Exception as e:
print(f"Erreur lors de la suppression du fichier {file_path}: {e}")
print(f"{count} fichiers temporaires supprimés.")
def process_zone(
input_file, poly_file, output_dir, temp_dir, max_dates=None, cleanup=False
):
"""
Traite une zone spécifique pour toutes les thématiques en parallèle.
Args:
input_file: Fichier d'historique OSM (.osh.pbf)
poly_file: Fichier de polygone (.poly) définissant la zone
output_dir: Répertoire pour les fichiers de sortie
temp_dir: Répertoire pour les fichiers temporaires
max_dates: Nombre maximum de dates à traiter (pour les tests)
cleanup: Si True, nettoie les fichiers temporaires après traitement
"""
start_time = time.time()
# Créer les répertoires nécessaires
os.makedirs(output_dir, exist_ok=True)
os.makedirs(temp_dir, exist_ok=True)
# Obtenir le nom de la zone à partir du nom du fichier poly
zone_name = Path(poly_file).stem
# Extraire le code INSEE à partir du nom du fichier poly (format: commune_XXXXX.poly)
insee_code = None
if zone_name.startswith("commune_"):
insee_code = zone_name.replace("commune_", "")
# Extraire les données pour la zone
zone_file = extract_zone_data(input_file, poly_file, temp_dir)
# Générer les dates avec différentes fréquences selon l'ancienneté
dates = generate_time_slices(max_dates) # Limité par max_dates si spécifié
print(f"Traitement de {len(THEMES)} thématiques pour la zone '{zone_name}'...")
# Déterminer le nombre de processus à utiliser (nombre de cœurs disponibles - 1, minimum 1)
num_processes = max(1, multiprocessing.cpu_count() - 1)
print(f"Utilisation de {num_processes} processus pour le traitement parallèle")
# Créer un pool de processus
with multiprocessing.Pool(processes=num_processes) as pool:
# Préparer les arguments pour chaque thématique
theme_args = [
(
theme_name,
theme_info,
zone_file,
zone_name,
dates,
output_dir,
temp_dir,
insee_code,
)
for theme_name, theme_info in THEMES.items()
]
# Exécuter le traitement des thématiques en parallèle
pool.starmap(process_theme, theme_args)
# Nettoyer les fichiers temporaires si demandé
if cleanup:
cleanup_temp_files(temp_dir, keep_zone_files=True)
end_time = time.time()
total_time = end_time - start_time
print(f"Traitement de la zone '{zone_name}' terminé en {total_time:.2f} secondes")
return total_time
def generate_graph(csv_file, zone_name, theme_name):
"""
Génère un graphique à partir des données CSV.
Args:
csv_file: Fichier CSV contenant les données
zone_name: Nom de la zone
theme_name: Nom de la thématique
"""
# Vérifier si le script generate_graph.py existe
if os.path.exists(os.path.join(os.path.dirname(__file__), "generate_graph.py")):
# Construire le chemin de sortie pour le graphique
output_path = os.path.splitext(csv_file)[0] + "_graph.png"
# Exécuter le script pour générer le graphique
command = f"python3 {os.path.join(os.path.dirname(__file__), 'generate_graph.py')} {csv_file} --output {output_path}"
run_command(command)
print(f"Graphique généré: {output_path}")
else:
print(
"Le script generate_graph.py n'a pas été trouvé. Aucun graphique n'a été généré."
)
def main():
"""Fonction principale"""
parser = argparse.ArgumentParser(
description="Compte les objets OSM par thématique sur une zone donnée à différentes dates."
)
parser.add_argument(
"--input", "-i", required=True, help="Fichier d'historique OSM (.osh.pbf)"
)
parser.add_argument(
"--poly",
"-p",
required=True,
help="Fichier de polygone (.poly) définissant la zone",
)
parser.add_argument(
"--output-dir",
"-o",
default="resultats",
help="Répertoire pour les fichiers de sortie",
)
parser.add_argument(
"--temp-dir",
"-t",
default="temp",
help="Répertoire pour les fichiers temporaires",
)
parser.add_argument(
"--max-dates",
"-m",
type=int,
default=None,
help="Nombre maximum de dates à traiter (pour les tests, par défaut: toutes les dates)",
)
parser.add_argument(
"--cleanup",
"-c",
action="store_true",
help="Nettoyer les fichiers temporaires après traitement",
)
parser.add_argument(
"--benchmark",
"-b",
action="store_true",
help="Afficher des informations de performance détaillées",
)
args = parser.parse_args()
# Vérifier que les fichiers d'entrée existent
if not os.path.isfile(args.input):
print(f"Erreur: Le fichier d'entrée {args.input} n'existe pas.")
sys.exit(1)
if not os.path.isfile(args.poly):
print(f"Erreur: Le fichier de polygone {args.poly} n'existe pas.")
sys.exit(1)
# Afficher des informations sur la configuration
if args.benchmark:
print("\n=== Configuration ===")
print(f"Nombre de processeurs: {multiprocessing.cpu_count()}")
print(f"Nombre de thématiques: {len(THEMES)}")
print(
f"Nettoyage des fichiers temporaires: {'Activé' if args.cleanup else 'Désactivé'}"
)
print("=====================\n")
# Mesurer le temps d'exécution
start_time = time.time()
# Traiter la zone
total_time = process_zone(
args.input,
args.poly,
args.output_dir,
args.temp_dir,
args.max_dates,
args.cleanup,
)
# Afficher des informations de performance
if args.benchmark:
print("\n=== Performance ===")
print(f"Temps total d'exécution: {total_time:.2f} secondes")
print(f"Temps moyen par thématique: {total_time / len(THEMES):.2f} secondes")
print("===================\n")
print("Traitement terminé.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,194 @@
zone,name,lat,lon,population,budgetAnnuel,completionPercent,placesCount,avecHoraires,avecAdresse,avecSite,avecAccessibilite,avecNote,siren,codeEpci,codesPostaux
91111,Briis-sous-Forges,48.6246916,2.1243349,3375,4708125.00,30,67,12,8,9,7,3,219101110,249100074,91640
79034,Bessines,46.3020750,-0.5167969,1882,2676204.00,58,56,36,26,34,4,1,217900349,200041317,79000
76216,Déville-lès-Rouen,49.4695338,1.0495889,10690,18386800.00,20,110,16,3,11,1,1,217602168,200023414,76250
91249,Forges-les-Bains,48.6290401,2.0996273,4138,6310450.00,33,31,7,5,2,5,0,219102498,249100074,91470
59140,Caullery,50.0817000,3.3737000,464,824528.00,,,,,,,,,,
08122,Chooz,50.0924000,4.7996000,792,1375704.00,,,,,,,,,,
12084,Creissels,44.0621000,3.0572000,1564,2493016.00,,,,,,,,,,
59183,Dunkerque,51.0183000,2.3431000,87013,153229893.00,,,,,,,,,,
86116,Jazeneuil,46.4749000,0.0735000,797,1212237.00,,,,,,,,,,
75113,,48.8303000,2.3656000,177735,239764515.00,,,,,,,,,,
06088,Nice,43.7032000,7.2528000,353701,557079075.00,,,,,,,,,,
38185,Grenoble,45.1842000,5.7155000,156389,231299331.00,,,,,,,,,,
75117,,48.8874000,2.3050000,161206,231975434.00,,,,,,,,,,
75116,,48.8572000,2.2630000,159733,287199934.00,,,,,,,,,,
35236,Redon,47.6557000,-2.0787000,9336,15030960.00,,,,,,,,,,
35238,Rennes,48.1159000,-1.6884000,227830,408727020.00,,,,,,,,,,
78646,Versailles,48.8039000,2.1191000,83918,142912354.00,,,,,,,,,,
12230,Saint-Jean-Delnous,44.0391000,2.4912000,382,665062.00,,,,,,,,,,
76193,"La Crique",49.6929000,1.2051000,367,651425.00,,,,,,,,,,
49007,Angers,47.4819000,-0.5629000,157555,207815045.00,,,,,,,,,,
79003,Aiffres,46.2831000,-0.4147000,5423,9631248.00,,,,,,,,,,
29151,Morlaix,48.5971000,-3.8215000,15220,22129880.00,,,,,,,,,,
38544,Vienne,45.5221000,4.8803000,31555,55252805.00,,,,,,,,,,
42100,"La Gimond",45.5551000,4.4144000,278,428676.00,,,,,,,,,,
76008,Ancourt,49.9110000,1.1835000,627,850839.00,,,,,,,,,,
76618,Petit-Caux,49.9612000,1.2343000,9626,15334218.00,,,,,,,,,,
13202,Marseille,43.3225000,5.3497000,24153,40842723.00,,,,,,,,,,
46102,Figeac,44.6067000,2.0231000,9757,17025965.00,,,,,,,,,,
75107,,48.8548000,2.3115000,48196,70992708.00,,,,,,,,,,
7812,"Les Clayes-sous-Bois",,,,,,,,,,,,,,
76192,Criel-sur-Mer,50.0221000,1.3215000,2592,3659904.00,,,,,,,,,,
94080,Vincennes,48.8471000,2.4383000,48368,64232704.00,,,,,,,,,,
13055,Marseille,43.2803000,5.3806000,877215,1184240250.00,,,,,,,,,,
93055,Pantin,48.9006000,2.4085000,60954,90272874.00,,,,,,,,,,
69385,,45.7560000,4.8012000,48277,83277825.00,,,,,,,,,,
75109,,48.8771000,2.3379000,58419,79625097.00,,,,,,,,,,
44184,Saint-Nazaire,47.2768000,-2.2392000,73111,104694952.00,,,,,,,,,,
13208,,43.2150000,5.3256000,83414,120866886.00,,,,,,,,,,
59271,Grande-Synthe,51.0157000,2.2938000,20347,34284695.00,,,,,,,,,,
77379,Provins,48.5629000,3.2845000,11824,20183568.00,,,,,,,,,,
75108,,48.8732000,2.3111000,35418,51710280.00,,,,,,,,,,
77122,Combs-la-Ville,48.6602000,2.5765000,22712,29661872.00,,,,,,,,,,
79298,Saint-Symphorien,46.2688000,-0.4842000,1986,2909490.00,,,,,,,,,,
51454,Reims,49.2535000,4.0551000,178478,310908676.00,,,,,,,,,,
64102,Bayonne,43.4844000,-1.4611000,53312,82740224.00,,,,,,,,,,
39300,Lons-le-Saunier,46.6758000,5.5574000,16942,25379116.00,,,,,,,,,,
85288,Talmont-Saint-Hilaire,46.4775000,-1.6299000,8327,10908370.00,,,,,,,,,,
13203,,43.3113000,5.3806000,55653,81642951.00,,,,,,,,,,
69387,,45.7321000,4.8393000,87491,134998613.00,,,,,,,,,,
64024,Anglet,43.4893000,-1.5193000,42288,73665696.00,,,,,,,,,,
69265,Ville-sur-Jarnioux,45.9693000,4.5942000,820,1209500.00,,,,,,,,,,
75112,,48.8342000,2.4173000,139788,228273804.00,,,,,,,,,,
69388,,45.7342000,4.8695000,84956,119703004.00,,,,,,,,,,
74010,Annecy,45.9024000,6.1264000,131272,235764512.00,,,,,,,,,,
74100,Desingy,46.0022000,5.8870000,759,1032240.00,,,,,,,,,,
75114,,48.8297000,2.3230000,137581,190412104.00,,,,,,,,,,
75118,,48.8919000,2.3487000,185825,275578475.00,,,,,,,,,,
85191,"La Roche-sur-Yon",46.6659000,-1.4162000,54699,80899821.00,,,,,,,,,,
85194,"Les Sables-d'Olonne",46.5264000,-1.7611000,48740,85002560.00,,,,,,,,,,
12220,Sainte-Eulalie-de-Cernon,43.9605000,3.1550000,321,558540.00,,,,,,,,,,
14341,Ifs,49.1444000,-0.3385000,11868,16377840.00,,,,,,,,,,
35288,Saint-Malo,48.6465000,-2.0066000,47255,81562130.00,,,,,,,,,,
86194,Poitiers,46.5846000,0.3715000,89472,130718592.00,,,,,,,,,,
91338,Limours,48.6463000,2.0823000,6408,8676432.00,,,,,,,,,,
91640,,,,,,,,,,,,,,,
79191,Niort,46.3274000,-0.4613000,60074,92934478.00,,,,,,,,,,
06004,Antibes,43.5823000,7.1048000,76612,134147612.00,,,,,,,,,,
13207,,43.2796000,5.3274000,34866,53031186.00,,,,,,,,,,
91657,Vigneux-sur-Seine,48.7021000,2.4274000,31233,41477424.00,,,,,,,,,,
73124,Gilly-sur-Isère,45.6549000,6.3487000,3109,4651064.00,,,,,,,,,,
92040,Issy-les-Moulineaux,48.8240000,2.2628000,67695,108108915.00,,,,,,,,,,
93051,Noisy-le-Grand,48.8327000,2.5560000,71632,104654352.00,,,,,,,,,,
14675,Soliers,49.1315000,-0.2898000,2190,2923650.00,,,,,,,,,,
76655,Saint-Valery-en-Caux,49.8582000,0.7094000,3884,5985244.00,,,,,,,,,,
34172,Montpellier,43.6100000,3.8742000,307101,508866357.00,,,,,,,,,,
76351,"Le Havre",49.4958000,0.1312000,166462,260346568.00,,,,,,,,,,
76665,Sauchay,49.9179000,1.2073000,448,618240.00,,,,,,,,,,
13204,,43.3063000,5.4002000,49744,75312416.00,,,,,,,,,,
75104,Paris,48.8541000,2.3569000,28039,44161425.00,,,,,,,,,,
45234,Orléans,47.8734000,1.9122000,116344,178355352.00,,,,,,,,,,
13100,Saint-Rémy-de-Provence,43.7815000,4.8455000,9547,16334917.00,,,,,,,,,,
27275,Gaillon,49.1602000,1.3405000,6785,11995880.00,,,,,,,,,,
44047,Couëron,47.2391000,-1.7472000,23541,31521399.00,,,,,,,,,,
16070,Chabanais,45.8656000,0.7076000,1564,2744820.00,,,,,,,,,,
12029,Bor-et-Bar,44.1971000,2.0822000,198,304128.00,,,,,,,,,,
59155,Coudekerque-Branche,51.0183000,2.3986000,20833,36707746.00,,,,,,,,,,
16106,Confolens,46.0245000,0.6639000,2726,3595594.00,,,,,,,,,,
75020,,,,,,,,,,,,,,,
76217,Dieppe,49.9199000,1.0838000,28599,48446706.00,,,,,,,,,,
95128,,,,,,,,,,,,,,,
44000,,,,,,,,,,,,,,,
79000,,,,,,,,,,,,,,,
27562,Saint-Marcel,49.0927000,1.4395000,4474,6259126.00,,,,,,,,,,
87011,Bellac,46.1013000,1.0325000,3569,5117946.00,,,,,,,,,,
94016,Cachan,48.7914000,2.3318000,30526,50642634.00,,,,,,,,,,
50237,"La Haye-Pesnel",48.8134000,-1.3732000,1261,2220621.00,,,,,,,,,,
91174,Corbeil-Essonnes,48.5973000,2.4646000,53712,72081504.00,,,,,,,,,,
11012,Argeliers,43.3090000,2.9137000,2128,3562272.00,,,,,,,,,,
94041,Ivry-sur-Seine,48.8125000,2.3872000,64526,85432424.00,,,,,,,,,,
95127,Cergy,49.0373000,2.0455000,69578,108263368.00,,,,,,,,,,
69001,Affoux,45.8448000,4.4116000,397,648698.00,,,,,,,,,,
44190,Saint-Sébastien-sur-Loire,47.2065000,-1.5023000,28373,39920811.00,,,,,,,,,,
69123,Lyon,45.7580000,4.8351000,520774,755643074.00,,,,,,,,,,
07336,Vernon,44.5074000,4.2251000,223,371295.00,,,,,,,,,,
91201,Draveil,48.6777000,2.4249000,29824,51506048.00,,,,,,,,,,
29174,Plonéour-Lanvern,47.9066000,-4.2678000,6403,9931053.00,,,,,,,,,,
75017,,,,,,,,,,,,,,,
17197,Jonzac,45.4413000,-0.4237000,3576,5188776.00,,,,,,,,,,
85195,,,,,,,,,,,,,,,
44162,Saint-Herblain,47.2246000,-1.6306000,50561,88481750.00,,,,,,,,,,
00000,"toutes les villes",,,,,,,,,,,,,,
57463,Metz,49.1048000,6.1962000,121695,192643185.00,,,,,,,,,,
57466,Metzing,49.1008000,6.9605000,693,945945.00,,,,,,,,,,
37000,,,,,,,,,,,,,,,
91312,Igny,48.7375000,2.2229000,10571,14831113.00,,,,,,,,,,
25462,Pontarlier,46.9167000,6.3796000,17928,23449824.00,,,,,,,,,,
33063,Bordeaux,44.8624000,-0.5848000,265328,467773264.00,,,,,,,,,,
16015,Angoulême,45.6458000,0.1450000,41423,66069685.00,,,,,,,,,,
02196,Clacy-et-Thierret,49.5546000,3.5651000,294,458934.00,,,,,,,,,,
24037,Bergerac,44.8519000,0.4883000,26852,47850264.00,,,,,,,,,,
78686,Viroflay,48.8017000,2.1725000,16943,27091857.00,,,,,,,,,,
83137,Toulon,43.1364000,5.9334000,180834,319172010.00,,,,,,,,,,
44026,Carquefou,47.2968000,-1.4687000,20535,28707930.00,,,,,,,,,,
87154,Saint-Junien,45.8965000,0.8853000,11382,19406310.00,,,,,,,,,,
79081,Chauray,46.3537000,-0.3862000,7173,12387771.00,,,,,,,,,,
73008,Aix-les-Bains,45.6943000,5.9035000,32175,53539200.00,,,,,,,,,,
86195,Port-de-Piles,46.9989000,0.5956000,548,744184.00,,,,,,,,,,
91470,,,,,,,,,,,,,,,
34008,"Les Aires",43.5687000,3.0676000,613,1081332.00,,,,,,,,,,
44270,,,,,,,,,,,,,,,
25056,Besançon,47.2602000,6.0123000,120057,158355183.00,,,,,,,,,,
75019,,,,,,,,,,,,,,,
78712,,,,,,,,,,,,,,,
11262,Narbonne,43.1493000,3.0337000,56692,79482184.00,,,,,,,,,,
60602,Saint-Valery,49.7251000,1.7303000,54,90450.00,,,,,,,,,,
91421,Montgeron,48.6952000,2.4638000,23890,40851900.00,,,,,,,,,,
82112,Moissac,44.1219000,1.1002000,13652,24027520.00,,,,,,,,,,
92033,Garches,48.8469000,2.1861000,17705,25442085.00,,,,,,,,,,
38053,Bourgoin-Jallieu,45.6025000,5.2747000,29816,41593320.00,,,,,,,,,,
34335,Villemagne-l'Argentière,43.6189000,3.1208000,420,586320.00,,,,,,,,,,
13213,,43.3528000,5.4301000,93425,144528475.00,,,,,,,,,,
44020,Bouguenais,47.1710000,-1.6181000,20590,34282350.00,,,,,,,,,,
36044,Châteauroux,46.8023000,1.6903000,43079,70864955.00,,,,,,,,,,
11164,Ginestas,43.2779000,2.8830000,1579,2837463.00,,,,,,,,,,
60009,Allonne,49.3952000,2.1157000,1737,2883420.00,,,,,,,,,,
41151,"Montrichard Val de Cher",47.3594000,1.1998000,3641,5559807.00,,,,,,,,,,
27554,"La Chapelle-Longueville",49.1085000,1.4115000,3283,4796463.00,,,,,,,,,,
34189,Olonzac,43.2816000,2.7431000,1683,2511036.00,,,,,,,,,,
34028,Bédarieux,43.6113000,3.1637000,5820,9550620.00,,,,,,,,,,
74112,"Épagny Metz-Tessy",45.9430000,6.0934000,8642,13956830.00,,,,,,,,,,
75102,,48.8677000,2.3411000,20433,35103894.00,,,,,,,,,,
60057,Beauvais,49.4425000,2.0877000,55906,84082624.00,,,,,,,,,,
59350,Lille,50.6311000,3.0468000,238695,403871940.00,,,,,,,,,,
91477,Igny,48.7155000,2.2293000,36067,46923167.00,,,,,,,,,,
12145,Millau,44.0982000,3.1176000,21859,34865105.00,,,,,,,,,,
12115,L'Hospitalet-du-Larzac,43.9755000,3.2074000,344,569320.00,,,,,,,,,,
79004,,,,,,,,,,,,,,,
75014,,,,,,,,,,,,,,,
75018,,,,,,,,,,,,,,,
55154,Dieue-sur-Meuse,49.0790000,5.4293000,1452,2099592.00,,,,,,,,,,
79192,,,,,,,,,,,,,,,
06018,Biot,43.6273000,7.0821000,10196,15192040.00,,,,,,,,,,
25393,Montécheroux,47.3469000,6.7965000,557,839399.00,,,,,,,,,,
14554,"Le Castelet",49.0870000,-0.2811000,1829,3076378.00,,,,,,,,,,
69384,,45.7805000,4.8260000,35232,49782816.00,,,,,,,,,,
78672,Villennes-sur-Seine,48.9372000,1.9975000,5792,9359872.00,,,,,,,,,,
78123,Carrières-sous-Poissy,48.9469000,2.0264000,19951,35752192.00,,,,,,,,,,
77000,,,,,,,,,,,,,,,
31056,Beauzelle,43.6680000,1.3753000,8184,11670384.00,,,,,,,,,,
38553,Villefontaine,45.6161000,5.1549000,19018,25579210.00,,,,,,,,,,
47001,Agen,44.2010000,0.6302000,32193,48965553.00,,,,,,,,,,
54700,,,,,,,,,,,,,,,
70279,Gray,47.4310000,5.6153000,5455,9071665.00,,,,,,,,,,
74000,,,,,,,,,,,,,,,
91339,Linas,48.6261000,2.2525000,7310,12412380.00,,,,,,,,,,
17306,Royan,45.6343000,-1.0127000,19322,33311128.00,,,,,,,,,,
17300,"La Rochelle",46.1620000,-1.1765000,79961,118182358.00,,,,,,,,,,
59000,,,,,,,,,,,,,,,
77284,Meaux,48.9573000,2.9035000,56659,84025297.00,,,,,,,,,,
76414,Martin-Église,49.9105000,1.1279000,1595,2177175.00,,,,,,,,,,
54528,Toul,48.6794000,5.8980000,15570,21626730.00,,,,,,,,,,
63124,Cournon-d'Auvergne,45.7420000,3.1885000,20020,30930900.00,,,,,,,,,,
87085,Limoges,45.8567000,1.2260000,129754,203194764.00,,,,,,,,,,
78000,,,,,,,,,,,,,,,
13205,,43.2925000,5.4006000,45020,61632380.00,,,,,,,,,,
31584,Villemur-sur-Tarn,43.8582000,1.4947000,6235,9813890.00,,,,,,,,,,
91228,Évry-Courcouronnes,48.6287000,2.4313000,66700,90245100.00,,,,,,,,,,
41018,Blois,47.5813000,1.3049000,47092,79350020.00,,,,,,,,,,
66136,Perpignan,42.6990000,2.9045000,120996,193593600.00,,,,,,,,,,
11041,Bize-Minervois,43.3364000,2.8719000,1292,1758412.00,,,,,,,,,,
75016,,,,,,,,,,,,,,,
76540,Rouen,
Can't render this file because it has a wrong number of fields in line 193.

View file

@ -0,0 +1,3 @@
<?xml version='1.0' encoding='UTF-8'?>
<osm version="0.6" generator="empty">
</osm>

View file

@ -0,0 +1,110 @@
local tables = {}
-- Table pour les bornes incendie
tables.fire_hydrants = osm2pgsql.define_node_table('fire_hydrants', {
{ column = 'id_column', type = 'id_type' },
{ column = 'geom', type = 'point', projection = 4326 },
{ column = 'tags', type = 'hstore' },
{ column = 'ref', type = 'text' },
{ column = 'color', type = 'text' },
{ column = 'insee', type = 'text' },
})
-- Table pour les arbres
tables.trees = osm2pgsql.define_node_table('trees', {
{ column = 'id_column', type = 'id_type' },
{ column = 'geom', type = 'point', projection = 4326 },
{ column = 'tags', type = 'hstore' },
{ column = 'species', type = 'text' },
{ column = 'height', type = 'text' },
{ column = 'insee', type = 'text' },
})
-- Table pour les bornes de recharge (nodes)
tables.charging_stations = osm2pgsql.define_node_table('charging_stations', {
{ column = 'id_column', type = 'id_type' },
{ column = 'geom', type = 'point', projection = 4326 },
{ column = 'tags', type = 'hstore' },
{ column = 'operator', type = 'text' },
{ column = 'capacity', type = 'text' },
{ column = 'insee', type = 'text' },
})
-- Table pour les bornes de recharge (ways)
tables.charging_stations_ways = osm2pgsql.define_way_table('charging_stations_ways', {
{ column = 'id_column', type = 'id_type' },
{ column = 'geom', type = 'linestring', projection = 4326 },
{ column = 'tags', type = 'hstore' },
{ column = 'operator', type = 'text' },
{ column = 'capacity', type = 'text' },
{ column = 'insee', type = 'text' },
})
-- Function to determine the INSEE code from multiple possible sources
function get_insee_code(tags)
-- Try to get INSEE code from different tags
if tags['ref:INSEE'] then
return tags['ref:INSEE']
elseif tags['addr:postcode'] then
-- French postal codes often start with the department code
-- For example, 91150 is in department 91, which can help identify the INSEE code
return tags['addr:postcode'] and string.sub(tags['addr:postcode'], 1, 2) .. "111"
elseif tags['addr:city'] and tags['addr:city'] == 'Étampes' then
-- If the city is Étampes, use the INSEE code 91111
return "91111"
else
-- Default to 91111 (Étampes) for this specific use case
-- In a production environment, you would use a spatial query to determine the INSEE code
return "91111"
end
end
function osm2pgsql.process_node(object)
-- Check for fire hydrants with different tagging schemes
if object.tags.emergency == 'fire_hydrant' or object.tags.amenity == 'fire_hydrant' then
tables.fire_hydrants:insert({
tags = object.tags,
ref = object.tags.ref,
color = object.tags.color,
insee = get_insee_code(object.tags)
})
end
-- Check for trees
if object.tags.natural == 'tree' then
tables.trees:insert({
tags = object.tags,
species = object.tags.species,
height = object.tags.height,
insee = get_insee_code(object.tags)
})
end
-- Check for charging stations
if object.tags.amenity == 'charging_station' then
tables.charging_stations:insert({
tags = object.tags,
operator = object.tags.operator,
capacity = object.tags.capacity,
insee = get_insee_code(object.tags)
})
end
end
function osm2pgsql.process_way(object)
-- Check for charging stations that might be mapped as ways
if object.tags.amenity == 'charging_station' then
tables.charging_stations_ways:insert({
tags = object.tags,
operator = object.tags.operator,
capacity = object.tags.capacity,
insee = get_insee_code(object.tags)
})
end
end
function osm2pgsql.process_relation(object)
return
end

View file

@ -0,0 +1,309 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script pour générer un graphique interactif avec plotly montrant l'évolution du nombre d'objets OSM
à partir d'un fichier CSV thématique d'une ville (code INSEE 91111 par défaut).
Ce script utilise la bibliothèque plotly pour créer des graphiques interactifs à partir des données
contenues dans des fichiers CSV thématiques. Par défaut, il utilise le code INSEE 91111.
Le titre du graphique inclut le tag principal (thème) et le nom de la ville.
Utilisation:
python plotly_city.py chemin/vers/fichier.csv [options]
Options:
--output, -o : Chemin de sortie pour le graphique HTML (optionnel)
--insee, -i : Code INSEE de la commune à analyser (par défaut: 91111)
--city_name, -c : Nom de la ville (si non spécifié, sera généré à partir du code INSEE)
Exemple:
python plotly_city.py test_results/commune_91111_borne-de-recharge.csv
Dépendances requises:
- pandas
- plotly
Installation des dépendances:
pip install pandas plotly
"""
import sys
import os
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import argparse
from datetime import datetime
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Génère un graphique interactif avec plotly à partir des données CSV d'objets OSM."
)
parser.add_argument(
"csv_file", help="Chemin vers le fichier CSV contenant les données"
)
parser.add_argument(
"--output", "-o", help="Chemin de sortie pour le graphique (HTML)", default=None
)
parser.add_argument(
"--insee", "-i", help="Code INSEE de la commune à analyser", default="91111"
)
parser.add_argument(
"--city_name",
"-c",
help="Nom de la ville (si non spécifié, sera extrait du CSV)",
default=None,
)
return parser.parse_args()
def get_city_name(insee_code):
"""
Récupère le nom de la ville à partir du code INSEE.
Cette fonction pourrait être améliorée pour utiliser une API ou une base de données.
Args:
insee_code: Code INSEE de la commune
Returns:
Nom de la ville ou le code INSEE si le nom n'est pas trouvé
"""
# Pour l'instant, on retourne simplement le code INSEE
# Dans une version future, on pourrait implémenter une recherche dans une base de données
return f"Commune {insee_code}"
def load_data(csv_file, insee_code="91111"):
"""
Charge les données depuis le fichier CSV.
Args:
csv_file: Chemin vers le fichier CSV
insee_code: Code INSEE de la commune à filtrer
Returns:
DataFrame pandas contenant les données filtrées
"""
# Charger le CSV avec gestion des erreurs pour les lignes mal formatées
try:
df = pd.read_csv(csv_file, on_bad_lines="skip")
except TypeError: # Pour les versions plus anciennes de pandas
df = pd.read_csv(csv_file, error_bad_lines=False, warn_bad_lines=True)
# Vérifier si le CSV a la structure attendue
if "date" in df.columns:
# Format de CSV avec colonne 'date' directement
df["date"] = pd.to_datetime(df["date"])
else:
# Si aucune colonne de date n'est trouvée, essayer d'utiliser la première colonne
try:
df["date"] = pd.to_datetime(df.iloc[:, 0])
except:
print("Erreur: Impossible de trouver ou convertir une colonne de date.")
sys.exit(1)
# Filtrer par code INSEE si la colonne 'zone' contient des codes INSEE
if "zone" in df.columns:
# Vérifier si la zone contient le code INSEE
if any(
zone.endswith(insee_code) for zone in df["zone"] if isinstance(zone, str)
):
df = df[df["zone"].str.endswith(insee_code)]
# Trier par date
df = df.sort_values("date")
return df
def generate_plotly_graph(
df, city_name=None, output_path=None, insee_code="91111", csv_file=None
):
"""
Génère un graphique interactif avec plotly montrant l'évolution du nombre d'objets dans le temps.
Args:
df: DataFrame pandas contenant les données
city_name: Nom de la ville (optionnel)
output_path: Chemin de sortie pour le graphique (optionnel)
insee_code: Code INSEE de la commune
csv_file: Chemin vers le fichier CSV source (pour générer un nom de fichier de sortie par défaut)
"""
# Si le nom de la ville n'est pas fourni, essayer de le récupérer
if not city_name:
city_name = get_city_name(insee_code)
# Déterminer la colonne pour les types d'objets (theme)
theme_column = "theme"
# Créer une figure avec deux sous-graphiques (nombre total et taux de complétion)
fig = make_subplots(
rows=2,
cols=1,
subplot_titles=("Nombre d'objets OSM", "Taux de complétion des attributs (%)"),
vertical_spacing=0.15,
)
# Obtenir la liste des thèmes uniques
if theme_column in df.columns:
themes = df[theme_column].unique()
# Créer un graphique pour chaque thème
for theme in themes:
# Filtrer les données pour ce thème
theme_data = df[df[theme_column] == theme]
# Tracer la ligne pour le nombre total d'objets
fig.add_trace(
go.Scatter(
x=theme_data["date"],
y=theme_data["nombre_total"],
mode="lines+markers",
name=f"{theme} - Total",
hovertemplate="%{x}<br>Nombre: %{y}<extra></extra>",
),
row=1,
col=1,
)
# Tracer la ligne pour le taux de complétion si disponible
if "pourcentage_completion" in theme_data.columns:
fig.add_trace(
go.Scatter(
x=theme_data["date"],
y=theme_data["pourcentage_completion"],
mode="lines+markers",
name=f"{theme} - Complétion (%)",
hovertemplate="%{x}<br>Complétion: %{y}%<extra></extra>",
),
row=2,
col=1,
)
else:
# Si aucune colonne de thème n'est trouvée, tracer simplement le nombre total
fig.add_trace(
go.Scatter(
x=df["date"],
y=df["nombre_total"],
mode="lines+markers",
name="Total",
hovertemplate="%{x}<br>Nombre: %{y}<extra></extra>",
),
row=1,
col=1,
)
# Tracer la ligne pour le taux de complétion si disponible
if "pourcentage_completion" in df.columns:
fig.add_trace(
go.Scatter(
x=df["date"],
y=df["pourcentage_completion"],
mode="lines+markers",
name="Complétion (%)",
hovertemplate="%{x}<br>Complétion: %{y}%<extra></extra>",
),
row=2,
col=1,
)
# Configurer les axes et les légendes
fig.update_xaxes(title_text="Date", row=1, col=1)
fig.update_xaxes(title_text="Date", row=2, col=1)
fig.update_yaxes(title_text="Nombre d'objets", row=1, col=1)
fig.update_yaxes(title_text="Taux de complétion (%)", range=[0, 100], row=2, col=1)
# Obtenir le thème principal (premier thème trouvé)
main_tag = (
themes[0] if theme_column in df.columns and len(themes) > 0 else "Objets OSM"
)
# Mettre à jour le titre du graphique avec le tag principal et le nom de la ville
fig.update_layout(
title=f"{main_tag} - {city_name}",
hovermode="x unified",
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
height=800,
width=1000,
margin=dict(t=100, b=50, l=50, r=50),
)
# Ajouter des annotations pour les informations supplémentaires
fig.add_annotation(
text=f"Code INSEE: {insee_code}",
xref="paper",
yref="paper",
x=0.01,
y=-0.15,
showarrow=False,
font=dict(size=10),
)
# Ajouter la date de génération
now = datetime.now().strftime("%Y-%m-%d %H:%M")
fig.add_annotation(
text=f"Généré le: {now}",
xref="paper",
yref="paper",
x=0.99,
y=-0.15,
showarrow=False,
font=dict(size=8),
align="right",
)
# Sauvegarder ou afficher le graphique
if output_path:
fig.write_html(output_path)
print(f"Graphique interactif sauvegardé: {output_path}")
elif csv_file:
# Déterminer un chemin de sortie par défaut basé sur le fichier CSV
base_name = os.path.splitext(csv_file)[0]
default_output = f"{base_name}_plotly.html"
fig.write_html(default_output)
print(f"Graphique interactif sauvegardé: {default_output}")
else:
# Si aucun chemin de sortie n'est spécifié et aucun fichier CSV n'est fourni,
# utiliser un nom par défaut basé sur le code INSEE et le thème
default_output = f"commune_{insee_code}_{main_tag}_plotly.html"
fig.write_html(default_output)
print(f"Graphique interactif sauvegardé: {default_output}")
def main():
"""Fonction principale."""
# Analyser les arguments de la ligne de commande
args = parse_args()
# Vérifier que le fichier CSV existe
if not os.path.isfile(args.csv_file):
print(f"Erreur: Le fichier {args.csv_file} n'existe pas.")
sys.exit(1)
# Charger les données
df = load_data(args.csv_file, args.insee)
# Vérifier qu'il y a des données
if df.empty:
print(f"Aucune donnée trouvée pour le code INSEE {args.insee}.")
sys.exit(1)
# Déterminer le chemin de sortie si non spécifié
if not args.output:
# Utiliser le même nom que le fichier CSV mais avec l'extension .html
base_name = os.path.splitext(args.csv_file)[0]
output_path = f"{base_name}_plotly.html"
else:
output_path = args.output
# Générer le graphique
generate_plotly_graph(df, args.city_name, output_path, args.insee, args.csv_file)
print("Graphique interactif généré avec succès!")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,6 @@
osmupdate --verbose --keep-tempfiles --day -t=test_temp/ -v osm_data/france-internal.osh.pbf test_temp/changes.osc.gz
osmupdate --verbose --keep-tempfiles --day -t=test_temp/ -v osm_data/france-latest.osm.pbf test_temp/changes.osc.gz
#osmium extract -p polyons/commune_91111.poly -s simple changes.osc.gz -O -o test_temp/changes.local.osc.gz
osmium apply-changes -H osm_data/france-internal.osh.pbf test_temp/changes.osc.gz -O -o osm_data/france-internal_updated.osh.pbf
osmium apply-changes -H osm_data/france-latest.osh.pbf test_temp/changes.osc.gz -O -o osm_data/france-latest_updated.osh.pbf

194
counting_osm_objects/update.py Executable file
View file

@ -0,0 +1,194 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script pour mettre à jour le fichier historisé france internal.
Ce script utilise osmupdate pour mettre à jour le fichier france-internal.osh.pbf
avec les dernières modifications d'OpenStreetMap.
Usage:
python update.py [--verbose]
"""
import os
import sys
import argparse
import subprocess
import logging
from datetime import datetime
# Chemin vers le répertoire du script
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Chemin vers le fichier historisé france internal
FRANCE_INTERNAL_FILE = os.path.join(SCRIPT_DIR, "osm_data", "france-internal.osh.pbf")
# Chemin vers le répertoire temporaire pour osmupdate
TEMP_DIR = os.path.join(SCRIPT_DIR, "update_temp")
# Configurer le logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
def run_command(command, verbose=False):
"""
Exécute une commande shell et retourne la sortie.
Args:
command (str): Commande à exécuter
verbose (bool): Si True, affiche la sortie de la commande en temps réel
Returns:
tuple: (code de retour, sortie standard, sortie d'erreur)
"""
logger.info(f"Exécution: {command}")
if verbose:
# Exécuter la commande avec sortie en temps réel
process = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
bufsize=1
)
stdout_lines = []
stderr_lines = []
# Lire la sortie standard en temps réel
for line in process.stdout:
line = line.strip()
stdout_lines.append(line)
print(line)
# Lire la sortie d'erreur en temps réel
for line in process.stderr:
line = line.strip()
stderr_lines.append(line)
print(f"ERREUR: {line}", file=sys.stderr)
# Attendre la fin du processus
return_code = process.wait()
return return_code, "\n".join(stdout_lines), "\n".join(stderr_lines)
else:
# Exécuter la commande sans sortie en temps réel
try:
result = subprocess.run(
command,
shell=True,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
return result.returncode, result.stdout, result.stderr
except Exception as e:
logger.error(f"Erreur lors de l'exécution de la commande: {e}")
return 1, "", str(e)
def update_france_internal(verbose=False):
"""
Met à jour le fichier historisé france internal avec osmupdate.
Args:
verbose (bool): Si True, affiche la sortie de la commande en temps réel
Returns:
bool: True si la mise à jour a réussi, False sinon
"""
# Vérifier si le fichier existe
if not os.path.isfile(FRANCE_INTERNAL_FILE):
logger.error(f"Le fichier {FRANCE_INTERNAL_FILE} n'existe pas.")
logger.error("Veuillez télécharger le fichier initial depuis Geofabrik ou une autre source.")
return False
# Créer le répertoire temporaire s'il n'existe pas
os.makedirs(TEMP_DIR, exist_ok=True)
# Chemin vers le fichier mis à jour
updated_file = os.path.join(TEMP_DIR, "france-internal-updated.osh.pbf")
# Construire la commande osmupdate
command = f"osmupdate --verbose --keep-tempfiles -t={TEMP_DIR}/temp {FRANCE_INTERNAL_FILE} {updated_file}"
# Exécuter la commande
logger.info("Mise à jour du fichier france-internal.osh.pbf en cours...")
return_code, stdout, stderr = run_command(command, verbose)
if return_code != 0:
logger.error(f"Erreur lors de la mise à jour: {stderr}")
return False
# Remplacer l'ancien fichier par le nouveau
if os.path.isfile(updated_file):
# Créer une sauvegarde de l'ancien fichier
backup_file = f"{FRANCE_INTERNAL_FILE}.bak"
try:
os.rename(FRANCE_INTERNAL_FILE, backup_file)
logger.info(f"Sauvegarde de l'ancien fichier créée: {backup_file}")
except Exception as e:
logger.error(f"Erreur lors de la création de la sauvegarde: {e}")
return False
# Déplacer le nouveau fichier
try:
os.rename(updated_file, FRANCE_INTERNAL_FILE)
logger.info(f"Fichier mis à jour avec succès: {FRANCE_INTERNAL_FILE}")
return True
except Exception as e:
logger.error(f"Erreur lors du déplacement du fichier mis à jour: {e}")
# Restaurer l'ancien fichier en cas d'erreur
try:
os.rename(backup_file, FRANCE_INTERNAL_FILE)
logger.info("Restauration de l'ancien fichier réussie.")
except Exception as e2:
logger.error(f"Erreur lors de la restauration de l'ancien fichier: {e2}")
return False
else:
logger.error(f"Le fichier mis à jour {updated_file} n'a pas été créé.")
return False
def main():
"""Fonction principale"""
parser = argparse.ArgumentParser(
description="Met à jour le fichier historisé france internal avec osmupdate."
)
parser.add_argument(
"--verbose", "-v", action="store_true", help="Affiche la sortie des commandes en temps réel"
)
args = parser.parse_args()
# Afficher l'heure de début
start_time = datetime.now()
logger.info(f"Début de la mise à jour: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
# Mettre à jour le fichier
success = update_france_internal(args.verbose)
# Afficher l'heure de fin et la durée
end_time = datetime.now()
duration = end_time - start_time
logger.info(f"Fin de la mise à jour: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f"Durée totale: {duration}")
if success:
logger.info("Mise à jour terminée avec succès.")
return 0
else:
logger.error("Échec de la mise à jour.")
return 1
if __name__ == "__main__":
sys.exit(main())

220
docs/api-stats-export.md Normal file
View file

@ -0,0 +1,220 @@
# API - Export des objets Stats
## Endpoint
```
GET /api/v1/stats/export
```
## Description
Cet endpoint permet d'exporter les objets Stats au format JSON avec leurs propriétés de nom et de décomptes, similaire à la commande `app:export-stats`.
## Paramètres de requête
| Paramètre | Type | Défaut | Description |
|-----------|------|--------|-------------|
| `zone` | string | - | Code INSEE spécifique à exporter (optionnel) |
| `pretty` | boolean | false | Formater le JSON avec indentation |
| `include_followups` | boolean | true | Inclure les données de followup |
| `include_places` | boolean | false | Inclure les données des lieux (peut être volumineux) |
## Exemples d'utilisation
### Export de toutes les zones
```bash
curl "https://osm-commerces.cipherbliss.com/api/v1/stats/export"
```
### Export avec formatage JSON
```bash
curl "https://osm-commerces.cipherbliss.com/api/v1/stats/export?pretty=true"
```
### Export d'une zone spécifique
```bash
curl "https://osm-commerces.cipherbliss.com/api/v1/stats/export?zone=75056"
```
### Export d'une zone avec formatage et sans followups
```bash
curl "https://osm-commerces.cipherbliss.com/api/v1/stats/export?zone=75056&pretty=true&include_followups=false"
```
### Export complet avec lieux
```bash
curl "https://osm-commerces.cipherbliss.com/api/v1/stats/export?pretty=true&include_places=true"
```
## Réponse
### Succès (200 OK)
```json
[
{
"id": 1,
"zone": "75056",
"name": "Paris",
"dateCreated": "2024-01-15 10:30:00",
"dateModified": "2024-01-20 14:45:00",
"population": 2161000,
"budgetAnnuel": "8500000000",
"siren": 200054781,
"codeEpci": 200054781,
"codesPostaux": "75001;75002;75003;...",
"decomptes": {
"placesCount": 1250,
"avecHoraires": 980,
"avecAdresse": 1200,
"avecSite": 850,
"avecAccessibilite": 450,
"avecNote": 320,
"completionPercent": 75,
"placesCountReal": 1250
},
"followups": [
{
"name": "fire_hydrant_count",
"measure": 1250,
"date": "2024-01-20 14:45:00"
},
{
"name": "fire_hydrant_completion",
"measure": 85.5,
"date": "2024-01-20 14:45:00"
}
],
"places": [
{
"id": 1,
"name": "Boulangerie du Centre",
"mainTag": "shop",
"osmId": 123456,
"osmKind": "node",
"email": "contact@boulangerie.fr",
"note": "Boulangerie artisanale",
"zipCode": "75001",
"siret": "12345678901234",
"lat": 48.8566,
"lon": 2.3522,
"hasOpeningHours": true,
"hasAddress": true,
"hasWebsite": true,
"hasWheelchair": false,
"hasNote": true,
"completionPercentage": 85
}
]
}
]
```
### Erreur - Zone non trouvée (404 Not Found)
```json
{
"error": "Aucun objet Stats trouvé",
"message": "Aucune zone trouvée pour le code INSEE: 99999"
}
```
### Erreur - Erreur serveur (500 Internal Server Error)
```json
{
"error": "Erreur lors de l'export",
"message": "Description de l'erreur"
}
```
## Headers de réponse
| Header | Description |
|--------|-------------|
| `Content-Type` | `application/json` |
| `Content-Disposition` | `attachment; filename="stats_export.json"` |
| `X-Export-Count` | Nombre d'objets exportés |
| `X-Export-Generated` | Date/heure de génération (format ISO 8601) |
| `X-Export-Zone` | Code INSEE si export d'une zone spécifique |
## Structure des données
### Informations générales
- `id` : Identifiant unique de l'objet Stats
- `zone` : Code INSEE de la zone
- `name` : Nom de la ville/zone
- `dateCreated` : Date de création
- `dateModified` : Date de dernière modification
### Données démographiques et administratives
- `population` : Population de la zone
- `budgetAnnuel` : Budget annuel de la collectivité
- `siren` : Code SIREN
- `codeEpci` : Code EPCI
- `codesPostaux` : Codes postaux de la zone
### Décomptes
- `placesCount` : Nombre de lieux enregistrés
- `avecHoraires` : Nombre de lieux avec horaires d'ouverture
- `avecAdresse` : Nombre de lieux avec adresse complète
- `avecSite` : Nombre de lieux avec site web
- `avecAccessibilite` : Nombre de lieux avec accessibilité PMR
- `avecNote` : Nombre de lieux avec note
- `completionPercent` : Pourcentage de complétion global
- `placesCountReal` : Nombre réel de lieux (comptage direct)
### Followups (si `include_followups=true`)
- `followups` : Tableau des mesures de suivi (CityFollowUp)
- `name` : Nom de la mesure
- `measure` : Valeur de la mesure
- `date` : Date de la mesure
### Places (si `include_places=true`)
- `places` : Tableau des lieux de la zone
- `id` : Identifiant du lieu
- `name` : Nom du lieu
- `mainTag` : Tag principal OSM
- `osmId` : ID OSM
- `osmKind` : Type OSM (node/way)
- `email` : Email de contact
- `note` : Note
- `zipCode` : Code postal
- `siret` : Numéro SIRET
- `lat` : Latitude
- `lon` : Longitude
- `hasOpeningHours` : A des horaires d'ouverture
- `hasAddress` : A une adresse complète
- `hasWebsite` : A un site web
- `hasWheelchair` : A des informations d'accessibilité
- `hasNote` : A une note
- `completionPercentage` : Pourcentage de complétion du lieu
## Cas d'usage
### Export pour analyse
```bash
# Export de toutes les villes avec formatage
curl "https://osm-commerces.cipherbliss.com/api/v1/stats/export?pretty=true" > analyse_villes.json
# Export d'une ville spécifique
curl "https://osm-commerces.cipherbliss.com/api/v1/stats/export?zone=75056&pretty=true" > paris.json
```
### Export pour traitement automatisé
```bash
# Export compact pour traitement par script
curl "https://osm-commerces.cipherbliss.com/api/v1/stats/export" > stats_compact.json
```
### Export avec données complètes
```bash
# Export avec tous les lieux (attention: peut être volumineux)
curl "https://osm-commerces.cipherbliss.com/api/v1/stats/export?include_places=true&pretty=true" > stats_complet.json
```
## Limitations
- L'option `include_places=true` peut générer des fichiers très volumineux pour les grandes villes
- Les requêtes avec `include_places=true` peuvent être plus lentes
- Le formatage JSON (`pretty=true`) augmente la taille de la réponse

View file

@ -0,0 +1,68 @@
# Changes Implemented on 2025-08-02
## Issue Description
1. Create a command to remove duplicate Places, keeping only one. Sort places by OSM object type and OSM ID, and delete if the same information is found twice in a row.
2. When processing a city ("labourage"), use the city name found in the API that links INSEE code to city name to define the city name if it's different. Also use this for the command that creates Stats objects from Requests, not the place name from the request.
## Changes Made
### 1. New Command to Remove Duplicate Places
Created a new command `app:remove-duplicate-places` that:
- Gets all places sorted by OSM type and ID
- Finds duplicates by comparing consecutive places
- Removes the duplicates, keeping the first occurrence
The command supports:
- `--dry-run` option to show duplicates without removing them
- `--zip-code` option to process only places with a specific ZIP code
Usage:
```bash
# Show duplicates without removing them
php bin/console app:remove-duplicate-places --dry-run
# Remove duplicates for a specific ZIP code
php bin/console app:remove-duplicate-places --zip-code=75001
# Remove all duplicates
php bin/console app:remove-duplicate-places
```
### 2. City Name Updates from API
#### During Labourage Process
Modified `ProcessLabourageQueueCommand` to update the city name from the API after labourage:
- After setting the labourage completion date, it gets the city name from the API using the `get_city_osm_from_zip_code` method
- If the API returns a city name and it's different from the current name, it updates the Stats entity with the new name
- It logs the name change for information
#### When Creating Stats from Requests
Modified `CreateStatsFromDemandesCommand` to use the API-provided city name:
- It now tries to get the city name from the API based on the INSEE code
- If the API returns a city name, it uses that for the Stats object
- If the API doesn't return a name, it falls back to using the query from the first Demande (the previous behavior)
- It logs which source was used for the city name
## Testing
To test these changes:
1. Test the duplicate removal command:
```bash
php bin/console app:remove-duplicate-places --dry-run
```
2. Test the city name updates during labourage:
```bash
php bin/console app:process-labourage-queue
```
3. Test the Stats creation from Requests:
```bash
php bin/console app:create-stats-from-demandes
```

View file

@ -0,0 +1,79 @@
# Création des objets Stats manquants à partir du CSV des communes
Cette documentation explique comment utiliser la nouvelle fonctionnalité pour créer des objets Stats manquants à partir du fichier CSV des communes françaises.
## Fonctionnalité
La route `/admin/create-missing-stats-from-csv` permet d'examiner le fichier CSV des communes françaises (`communes_france.csv`) et de créer des objets Stats pour les communes qui n'en ont pas encore dans la base de données. Les objets Stats sont d'abord créés avec les informations du CSV, puis complétés avec des données supplémentaires (coordonnées, budget, etc.) lors de la sauvegarde par paquet de 100, sans effectuer de "labourage" (traitement complet des données OSM).
Les objets Stats sont sauvegardés par paquets de 100 pour optimiser les performances et éviter les problèmes de mémoire.
## Prérequis
1. Le fichier `communes_france.csv` doit exister à la racine du projet
2. Le fichier doit contenir au minimum les colonnes suivantes :
- `code` : Le code INSEE de la commune
- `nom` : Le nom de la commune
Si le fichier n'existe pas, vous pouvez le générer en exécutant le script `fetch_communes.py` :
```bash
python fetch_communes.py
```
## Utilisation
1. Accédez à la route `/admin/create-missing-stats-from-csv` dans votre navigateur
2. Le processus s'exécute automatiquement et affiche un message de succès une fois terminé
3. Vous serez redirigé vers la page d'administration principale
## Données importées
Les données suivantes sont importées du CSV pour chaque commune :
- `code``zone` (code INSEE)
- `nom``name` (nom de la commune)
- `population``population` (nombre d'habitants)
- `codesPostaux``codesPostaux` (codes postaux, séparés par des virgules)
- `siren``siren` (numéro SIREN)
- `codeEpci``codeEpci` (code EPCI)
Les données suivantes sont récupérées automatiquement lors de la sauvegarde par paquet de 100 :
- `lat` et `lon` : Coordonnées géographiques (récupérées via l'API geo.api.gouv.fr)
- `budget_annuel` : Budget annuel de la commune (récupéré via le service BudgetService)
- `completion_percent` : Pourcentage de complétion (calculé automatiquement)
Les objets Stats créés ont également les propriétés suivantes :
- `date_created` : Date de création (date actuelle)
- `date_modified` : Date de modification (date actuelle)
- `kind` : 'command' (indique que l'objet a été créé par une commande)
## Vérification
Après avoir exécuté la fonctionnalité, un message de succès s'affiche avec les informations suivantes :
- Nombre de communes ajoutées
- Nombre de communes déjà existantes (ignorées)
- Nombre d'erreurs rencontrées
Vous pouvez également vérifier dans la base de données que les objets Stats ont bien été créés pour les communes manquantes.
## Journalisation
Les actions et erreurs sont journalisées via le service `ActionLogger` :
- `admin/create_missing_stats_from_csv` : Journalise le début de l'action
- `error_create_missing_stats_from_csv` : Journalise les erreurs rencontrées lors de la création des objets Stats
- `error_complete_stats_data` : Journalise les erreurs rencontrées lors de la récupération des données supplémentaires (coordonnées, budget, etc.)
## Différence avec importStatsFromCsv
Cette fonctionnalité est similaire à `importStatsFromCsv`, mais avec quelques différences importantes :
- Elle est spécifiquement conçue pour créer des objets Stats manquants
- Elle utilise 'command' comme valeur pour le champ `kind` (au lieu de 'request')
- Elle journalise les erreurs de manière plus détaillée
## Exemple de message de succès
```
Création des Stats manquantes terminée : 123 communes ajoutées, 456 déjà existantes, 0 erreurs.
```

View file

@ -0,0 +1,142 @@
# Commande d'export des objets Stats
## Description
La commande `app:export-stats` permet d'exporter les objets Stats au format JSON avec leurs propriétés de nom et de décomptes.
## Utilisation
### Export de tous les objets Stats
```bash
php bin/console app:export-stats
```
### Export avec formatage JSON
```bash
php bin/console app:export-stats --pretty
```
### Export vers un fichier spécifique
```bash
php bin/console app:export-stats --output=mon_export.json
```
### Export d'une zone spécifique
```bash
php bin/console app:export-stats --zone=75056
```
### Export avec toutes les options
```bash
php bin/console app:export-stats --output=paris_stats.json --zone=75056 --pretty
```
### Export avec mode verbeux
```bash
php bin/console app:export-stats -v
```
## Options disponibles
- `--output, -o` : Fichier de sortie (défaut: `stats_export.json`)
- `--zone, -z` : Code INSEE spécifique à exporter (optionnel)
- `--pretty, -p` : Formater le JSON avec indentation
- `-v, --verbose` : Mode verbeux pour afficher un aperçu des données
## Structure des données exportées
Le fichier JSON contient un tableau d'objets avec la structure suivante :
```json
[
{
"id": 1,
"zone": "75056",
"name": "Paris",
"dateCreated": "2024-01-15 10:30:00",
"dateModified": "2024-01-20 14:45:00",
"population": 2161000,
"budgetAnnuel": "8500000000",
"siren": "200054781",
"codeEpci": "200054781",
"codesPostaux": "75001;75002;75003;...",
"decomptes": {
"placesCount": 1250,
"avecHoraires": 980,
"avecAdresse": 1200,
"avecSite": 850,
"avecAccessibilite": 450,
"avecNote": 320,
"completionPercent": 75,
"placesCountReal": 1250
},
"followups": [
{
"name": "fire_hydrant_count",
"measure": 1250,
"date": "2024-01-20 14:45:00"
},
{
"name": "fire_hydrant_completion",
"measure": 85.5,
"date": "2024-01-20 14:45:00"
}
]
}
]
```
## Propriétés exportées
### Informations générales
- `id` : Identifiant unique de l'objet Stats
- `zone` : Code INSEE de la zone
- `name` : Nom de la ville/zone
- `dateCreated` : Date de création
- `dateModified` : Date de dernière modification
### Données démographiques et administratives
- `population` : Population de la zone
- `budgetAnnuel` : Budget annuel de la collectivité
- `siren` : Code SIREN
- `codeEpci` : Code EPCI
- `codesPostaux` : Codes postaux de la zone
### Décomptes
- `placesCount` : Nombre de lieux enregistrés
- `avecHoraires` : Nombre de lieux avec horaires d'ouverture
- `avecAdresse` : Nombre de lieux avec adresse complète
- `avecSite` : Nombre de lieux avec site web
- `avecAccessibilite` : Nombre de lieux avec accessibilité PMR
- `avecNote` : Nombre de lieux avec note
- `completionPercent` : Pourcentage de complétion global
- `placesCountReal` : Nombre réel de lieux (comptage direct)
### Followups
- `followups` : Tableau des mesures de suivi (CityFollowUp)
- `name` : Nom de la mesure
- `measure` : Valeur de la mesure
- `date` : Date de la mesure
## Exemples d'utilisation
### Export pour analyse
```bash
# Export de toutes les villes avec formatage
php bin/console app:export-stats --pretty --output=analyse_villes.json
# Export d'une ville spécifique
php bin/console app:export-stats --zone=75056 --pretty --output=paris.json
```
### Export pour traitement automatisé
```bash
# Export compact pour traitement par script
php bin/console app:export-stats --output=stats_compact.json
```
### Vérification des données
```bash
# Export avec aperçu des données
php bin/console app:export-stats --pretty -v
```

View file

@ -0,0 +1,81 @@
# Commande d'import des mesures thématiques depuis CSV
Cette documentation décrit l'utilisation de la commande `app:import-cityfollowup-csv` qui permet d'importer des mesures thématiques depuis des fichiers CSV pour une ville donnée.
## Description
La commande `app:import-cityfollowup-csv` permet d'importer des données de suivi thématique (CityFollowUp) depuis des fichiers CSV générés par les scripts de comptage d'objets OSM. Elle compare les mesures existantes en base de données avec celles du fichier CSV et ajoute uniquement les nouvelles mesures, en excluant celles qui ont une valeur de 0.
## Prérequis
- Les fichiers CSV doivent être présents dans le répertoire `/counting_osm_objects/test_results/`
- Le format du nom de fichier doit être `commune_{code_insee}_{theme}.csv`
- La ville (entité Stats) doit exister en base de données avec le code INSEE correspondant
## Format du fichier CSV
Le fichier CSV doit contenir les colonnes suivantes :
- `date` : Date de la mesure (format YYYY-MM-DD)
- `zone` : Zone de la mesure (format commune_{code_insee})
- `theme` : Thématique de la mesure (ex: borne-de-recharge)
- `nombre_total` : Nombre total d'objets
- `nombre_avec_operator` : Nombre d'objets avec un opérateur (optionnel)
- `nombre_avec_capacity` : Nombre d'objets avec une capacité (optionnel)
- `pourcentage_completion` : Pourcentage de complétion (optionnel)
## Utilisation
```bash
php bin/console app:import-cityfollowup-csv <insee> <theme> [--dry-run]
```
### Arguments
- `insee` : Code INSEE de la ville (obligatoire)
- `theme` : Thématique à importer (ex: borne-de-recharge, defibrillator) (obligatoire)
### Options
- `--dry-run` : Simule l'import sans modifier la base de données
## Exemples
### Import des bornes de recharge pour Paris
```bash
php bin/console app:import-cityfollowup-csv 75056 borne-de-recharge
```
### Simulation d'import des défibrillateurs pour Paris
```bash
php bin/console app:import-cityfollowup-csv 75056 defibrillator --dry-run
```
## Comportement
1. La commande vérifie d'abord que la ville existe en base de données avec le code INSEE fourni.
2. Elle recherche ensuite le fichier CSV correspondant dans le répertoire des résultats.
3. Elle lit le contenu du fichier CSV et récupère les mesures existantes en base de données.
4. Pour chaque ligne du CSV :
- Les lignes avec des valeurs manquantes sont ignorées
- Les mesures avec une valeur de 0 sont ignorées
- Les mesures déjà existantes en base de données sont ignorées
- Les nouvelles mesures sont ajoutées en base de données
5. La commande affiche un résumé des opérations effectuées.
## Correspondance des thématiques
La commande effectue une conversion entre les noms de thématiques utilisés dans les fichiers CSV et ceux utilisés dans la base de données :
| Nom dans le CSV | Nom dans CityFollowUp |
|-----------------|------------------------|
| borne-de-recharge | charging_station |
| defibrillator | defibrillator |
| ... | ... |
## Notes
- Les mesures sont importées par lots de 50 pour éviter de surcharger la mémoire.
- Seules les mesures qui n'existent pas déjà en base de données sont importées.
- Les mesures avec une valeur de 0 sont ignorées conformément aux spécifications.

124
docs/import-stats.md Normal file
View file

@ -0,0 +1,124 @@
# Import d'objets Stats
Cette fonctionnalité permet d'importer des objets Stats à partir d'un fichier JSON via l'interface d'administration.
## Accès
La page d'import est accessible via :
- L'URL : `/admin/import-stats`
- Le menu de navigation : "Import Stats"
## Fonctionnalités
### Sécurité
- **Aucune modification** des objets Stats existants
- Seuls les nouveaux objets sont créés
- Vérification de l'existence par le code INSEE (`zone`)
### Validation
- Vérification du format JSON
- Validation des champs requis (`zone` et `name`)
- Gestion des erreurs par ligne
- Rapport détaillé des résultats
### Champs supportés
#### Champs requis
- `zone` : Code INSEE de la zone (ex: "75056")
- `name` : Nom de la ville/zone (ex: "Paris")
#### Champs optionnels
- `population` : Population de la zone (nombre)
- `budgetAnnuel` : Budget annuel de la collectivité (chaîne)
- `siren` : Code SIREN (nombre)
- `codeEpci` : Code EPCI (nombre)
- `codesPostaux` : Codes postaux séparés par des points-virgules (ex: "75001;75002;75003")
#### Objet `decomptes` (optionnel)
- `placesCount` : Nombre total de lieux
- `avecHoraires` : Nombre de lieux avec horaires
- `avecAdresse` : Nombre de lieux avec adresse
- `avecSite` : Nombre de lieux avec site web
- `avecAccessibilite` : Nombre de lieux avec accessibilité
- `avecNote` : Nombre de lieux avec note
- `completionPercent` : Pourcentage de complétion
## Format JSON attendu
```json
[
{
"zone": "75056",
"name": "Paris",
"population": 2161000,
"budgetAnnuel": "8500000000",
"siren": 200054781,
"codeEpci": 200054781,
"codesPostaux": "75001;75002;75003",
"decomptes": {
"placesCount": 1250,
"avecHoraires": 980,
"avecAdresse": 1200,
"avecSite": 850,
"avecAccessibilite": 450,
"avecNote": 320,
"completionPercent": 75
}
}
]
```
## Utilisation
1. **Préparer le fichier JSON**
- Créer un tableau d'objets Stats
- Inclure au minimum les champs `zone` et `name`
- Valider le format JSON
2. **Accéder à la page d'import**
- Aller sur `/admin/import-stats`
- Ou cliquer sur "Import Stats" dans le menu
3. **Importer le fichier**
- Sélectionner le fichier JSON
- Cliquer sur "Importer"
- Vérifier les messages de résultat
4. **Vérifier les résultats**
- Nombre d'objets créés
- Nombre d'objets ignorés (déjà existants)
- Liste des erreurs éventuelles
## Messages de retour
### Succès
```
Import terminé : X objet(s) créé(s), Y objet(s) ignoré(s) (déjà existants).
```
### Erreurs possibles
- "Aucun fichier JSON n'a été fourni."
- "Le fichier doit être au format JSON."
- "Erreur lors du décodage JSON: [message]"
- "Le fichier JSON doit contenir un tableau d'objets Stats."
- "Ligne X: Champs 'zone' et 'name' requis"
- "Ligne X: [message d'erreur spécifique]"
## Logs
Toutes les actions d'import sont loggées via le service `ActionLogger` :
- `admin/import_stats` : Accès à la page
- `admin/import_stats_success` : Import réussi avec statistiques
- `admin/import_stats_error` : Erreur lors de l'import
## Exemple de fichier de test
Un fichier `test_import_stats.json` est fourni avec des exemples pour Paris, Lyon et Marseille.
## Notes importantes
- Les objets existants ne sont jamais modifiés
- Seuls les nouveaux objets sont créés
- Les dates de création et modification sont automatiquement définies
- Les erreurs sont affichées par ligne pour faciliter le débogage
- Le système est conçu pour être sûr et non destructif

126
docs/osmose_integration.md Normal file
View file

@ -0,0 +1,126 @@
# Intégration des analyses Osmose
Ce document décrit l'intégration des analyses Osmose dans les pages détaillées de thématique, à la fois pour les routes publiques et administratives.
## Fonctionnalités implémentées
1. Affichage d'une carte avec les analyses Osmose pour la thématique courante
2. Affichage des analyses sous forme de points violets sur la carte
3. Popups interactives avec:
- Titre et description de l'analyse
- Tags proposés (tableau clé-valeur)
- Bouton pour voir l'analyse sur Osmose
- Bouton pour réparer dans JOSM via la télécommande
## Thèmes supportés
Les thèmes suivants sont actuellement supportés avec leurs IDs d'items Osmose correspondants:
| Thème | IDs Osmose |
|-------|------------|
| charging_station (Bornes de recharge) | 8410, 8411 |
| school (Écoles) | 8031 |
| healthcare (Santé) | 8211, 7220, 8331 |
| laboratory (Laboratoires d'analyses) | 7240, 8351 |
| police (Commissariats) | 8190, 8191 |
| defibrillator (Défibrillateurs) | 8370 |
Pour ajouter de nouveaux thèmes, modifiez la constante `osmoseItemsMapping` dans les deux fichiers suivants:
- `templates/public/followup_graph.html.twig` (route publique)
- `templates/admin/followup_theme_graph.html.twig` (route administrative)
Assurez-vous que les mappages sont identiques dans les deux fichiers pour garantir un comportement cohérent.
## Fonctionnement technique
L'intégration est implémentée à la fois sur la route publique (`templates/public/followup_graph.html.twig`) et sur la route administrative (`templates/admin/followup_theme_graph.html.twig`). Le fonctionnement est similaire sur les deux routes:
1. La carte est initialisée avec MapLibre GL JS
2. Les coordonnées de la commune sont récupérées via l'API Geo.gouv.fr
3. Une bounding box est calculée autour du centre de la commune
4. Les analyses Osmose sont récupérées via l'API Osmose avec les paramètres:
- Les IDs d'items correspondant au thème
- La bounding box calculée
- Les niveaux de sévérité 1, 2 et 3
- Une limite de 500 résultats
5. Les analyses sont affichées sous forme de points violets sur la carte
6. Au clic sur un point, une popup s'ouvre et charge les détails de l'analyse via l'API Osmose
7. La popup affiche les tags proposés et des boutons d'action
### Différences entre les routes
- **Route publique**: La carte affiche uniquement les analyses Osmose.
- **Route administrative**: La carte affiche à la fois les objets OSM existants (récupérés via Overpass API) et les analyses Osmose, permettant une comparaison directe entre les objets existants et les suggestions d'ajout.
## Exemple d'URL d'API Osmose
Pour les bornes de recharge (charging_station):
```
https://osmose.openstreetmap.fr/api/0.3/issues?zoom=12&item=8410,8411&level=1,2,3&limit=500&bbox=-0.789642333984375,47.35905994178323,-0.3203201293945313,47.598060753627195
```
Pour récupérer les détails d'une analyse:
```
https://osmose.openstreetmap.fr/api/0.3/issue/5c319a16-3689-b8c7-5427-187e04a6042a?langs=auto
```
## Tests
Pour tester l'intégration:
### Route publique
1. Accédez à une page de thématique détaillée publique, par exemple:
- `/stats/12345/followup-graph/charging_station` pour les bornes de recharge
- `/stats/12345/followup-graph/school` pour les écoles
- `/stats/12345/followup-graph/healthcare` pour les lieux de santé
2. Vérifiez que:
- La carte s'affiche correctement
- Les points violets apparaissent sur la carte (s'il y a des analyses Osmose pour cette thématique dans la zone)
- Au clic sur un point, une popup s'ouvre avec les détails de l'analyse
- Les boutons "Voir sur Osmose" et "Réparer dans JOSM" fonctionnent correctement
### Route administrative
1. Accédez à une page de thématique détaillée administrative, par exemple:
- `/admin/stats/12345/followup-graph/charging_station` pour les bornes de recharge
- `/admin/stats/12345/followup-graph/school` pour les écoles
- `/admin/stats/12345/followup-graph/healthcare` pour les lieux de santé
2. Vérifiez que:
- La carte s'affiche correctement avec les objets OSM existants
- Les points violets des analyses Osmose apparaissent également sur la carte
- Au clic sur un point violet, une popup s'ouvre avec les détails de l'analyse
- Les boutons "Voir sur Osmose" et "Réparer dans JOSM" fonctionnent correctement
## Dépannage
### Problèmes généraux
Si la carte ne s'affiche pas correctement:
- Vérifiez que la variable d'environnement `MAPTILER_TOKEN` est correctement définie
- Vérifiez les erreurs dans la console JavaScript du navigateur
Si les analyses Osmose ne s'affichent pas:
- Vérifiez que le thème a des IDs d'items Osmose associés dans `osmoseItemsMapping`
- Vérifiez qu'il y a des analyses Osmose pour ce thème dans la zone
- Vérifiez les erreurs dans la console JavaScript du navigateur
### Erreurs liées à l'API Osmose
Si vous rencontrez l'erreur "TypeError: right-hand side of 'in' should be an object, got undefined":
- Cette erreur peut se produire si l'API Osmose renvoie une réponse où `data.issue` est undefined
- Vérifiez que la fonction `loadOsmoseIssueDetails` contient bien la vérification `if (!data || !data.issue) return;` avant d'utiliser `data.issue`
- Si l'erreur persiste, vérifiez la structure de la réponse de l'API Osmose en ajoutant `console.log(data)` avant d'utiliser `data.issue`
### Problèmes spécifiques à la route administrative
Si les analyses Osmose s'affichent sur la route publique mais pas sur la route administrative:
- Vérifiez que la constante `osmoseItemsMapping` est correctement définie dans `templates/admin/followup_theme_graph.html.twig`
- Vérifiez que le code d'initialisation des analyses Osmose est appelé après l'initialisation de la carte
- Vérifiez que les analyses Osmose ne sont pas masquées par d'autres éléments de la carte (comme les marqueurs des objets OSM existants)
Si les objets OSM existants et les analyses Osmose se chevauchent de manière confuse:
- Les analyses Osmose sont affichées avec des marqueurs violets pour les distinguer des objets OSM existants
- Vous pouvez ajuster la couleur des marqueurs Osmose en modifiant la valeur `color: '#8A2BE2'` dans la fonction `loadOsmoseAnalyses`

244
fetch_communes.py Normal file
View file

@ -0,0 +1,244 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script to fetch all communes in France from the geo.api.gouv.fr API
and save them to a CSV file with all available information.
"""
import csv
import json
import requests
import time
from pathlib import Path
# Configuration
BASE_URL = "https://geo.api.gouv.fr"
OUTPUT_FILE = "communes_france.csv"
REQUEST_DELAY = 0.5 # Delay between API requests in seconds to avoid rate limiting
def fetch_departments():
"""Fetch the list of all departments in France.
Department numbers go from 1 to 95 (metropolitan France),
then from 971 to 976 (overseas departments).
"""
# Create a list of all department codes
dept_codes = []
# Metropolitan departments (01-95)
for i in range(1, 96):
# Format with leading zero for single-digit departments
dept_codes.append(f"{i:02d}")
# Special case for Corsica (2A and 2B instead of 20)
if "20" in dept_codes:
dept_codes.remove("20")
dept_codes.extend(["2A", "2B"])
# Overseas departments (971-976)
for i in range(971, 977):
dept_codes.append(str(i))
# Fetch department details from the API
url = f"{BASE_URL}/departements"
response = requests.get(url)
response.raise_for_status() # Raise an exception for HTTP errors
api_departments = response.json()
# Create a mapping of department code to full department info
dept_map = {dept["code"]: dept for dept in api_departments}
# Build the final list of departments, ensuring all required codes are included
departments = []
for code in dept_codes:
if code in dept_map:
# Use the data from the API if available
departments.append(dept_map[code])
else:
# Create a minimal department object if not in the API
departments.append({
"nom": f"Département {code}",
"code": code,
"codeRegion": ""
})
print(f"Warning: Department {code} not found in API, using placeholder data")
return departments
def fetch_communes_for_department(dept_code):
"""Fetch all communes for a specific department."""
url = f"{BASE_URL}/departements/{dept_code}/communes"
print(f"Fetching communes for department {dept_code}...")
response = requests.get(url)
response.raise_for_status()
return response.json()
def main():
# Create output directory if it doesn't exist
output_path = Path(OUTPUT_FILE)
output_path.parent.mkdir(exist_ok=True)
# Check if the CSV file already exists
existing_communes = {}
existing_headers = []
if output_path.exists():
print(f"CSV file {OUTPUT_FILE} already exists. Reading existing communes...")
try:
with open(output_path, 'r', newline='', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile)
existing_headers = reader.fieldnames
for row in reader:
# Use the INSEE code as the key to avoid duplicates
if 'code' in row and row['code']:
existing_communes[row['code']] = row
print(f"Read {len(existing_communes)} existing communes from CSV file.")
except Exception as e:
print(f"Error reading existing CSV file: {e}")
print("Will create a new file.")
existing_communes = {}
# Fetch all departments
try:
departments = fetch_departments()
print(f"Found {len(departments)} departments")
# Prepare to collect all communes
new_communes = []
# Fetch communes for each department
for dept in departments:
dept_code = dept['code']
try:
# Skip department 975 (Saint-Pierre-et-Miquelon) if it's a placeholder
# as it might not be available in the API
if dept_code == "975" and dept['nom'] == "Département 975":
print(f" - Skipping department {dept_code} (placeholder, not available in API)")
continue
communes = fetch_communes_for_department(dept_code)
# Filter out communes that already exist in the CSV
new_dept_communes = []
for commune in communes:
if commune['code'] not in existing_communes:
new_dept_communes.append(commune)
if new_dept_communes:
new_communes.extend(new_dept_communes)
print(f" - Added {len(new_dept_communes)} new communes from department {dept_code} ({dept['nom']})")
else:
print(f" - No new communes found for department {dept_code} ({dept['nom']})")
time.sleep(REQUEST_DELAY) # Be nice to the API
except Exception as e:
print(f"Error fetching communes for department {dept_code}: {e}")
print(f"Total new communes found: {len(new_communes)}")
# If no new communes and no existing communes, exit
if not new_communes and not existing_communes:
print("No communes found. Exiting.")
return
# Process new communes
if new_communes:
# Get all possible fields from the first commune
first_commune = new_communes[0]
headers = list(first_commune.keys())
# Special handling for nested fields like codesPostaux
for commune in new_communes:
for key, value in commune.items():
if isinstance(value, list) and key == "codesPostaux":
commune[key] = "|".join(str(v) for v in value)
elif isinstance(value, dict) and key == "centre":
# Handle coordinates if they exist
if "coordinates" in value:
commune["longitude"] = value["coordinates"][0]
commune["latitude"] = value["coordinates"][1]
commune.pop(key, None) # Remove the original nested dict
# Update headers if we added new fields
if "centre" in headers:
headers.remove("centre")
if any("longitude" in c for c in new_communes):
headers.extend(["longitude", "latitude"])
else:
# If no new communes, use existing headers
headers = existing_headers
# Combine existing and new communes
all_communes = list(existing_communes.values())
# Add new communes to the list
for commune in new_communes:
# Convert commune to a row with all headers
row = {header: commune.get(header, '') for header in headers}
all_communes.append(row)
# Write to CSV
with open(output_path, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=headers)
writer.writeheader()
for commune in all_communes:
writer.writerow(commune)
if new_communes:
print(f"CSV file updated successfully with {len(new_communes)} new communes: {output_path}")
else:
print(f"No new communes added. CSV file remains unchanged: {output_path}")
except Exception as e:
print(f"An error occurred: {e}")
def test_sample():
"""Run a test with a small sample of departments."""
# Sample departments: one metropolitan (01), Corsica (2A), and one overseas (971)
sample_dept_codes = ["01", "2A", "971"]
print(f"Testing with sample departments: {', '.join(sample_dept_codes)}")
# Fetch department details from the API
url = f"{BASE_URL}/departements"
response = requests.get(url)
response.raise_for_status()
api_departments = response.json()
# Create a mapping of department code to full department info
dept_map = {dept["code"]: dept for dept in api_departments}
# Prepare to collect all communes
all_communes = []
# Fetch communes for each sample department
for dept_code in sample_dept_codes:
if dept_code in dept_map:
dept = dept_map[dept_code]
try:
communes = fetch_communes_for_department(dept_code)
all_communes.extend(communes)
print(f" - Added {len(communes)} communes from department {dept_code} ({dept['nom']})")
time.sleep(REQUEST_DELAY)
except Exception as e:
print(f"Error fetching communes for department {dept_code}: {e}")
else:
print(f"Department {dept_code} not found in API")
print(f"Total communes found in sample: {len(all_communes)}")
# Print a few communes from each department
for dept_code in sample_dept_codes:
dept_communes = [c for c in all_communes if c.get('codeDepartement') == dept_code]
if dept_communes:
print(f"\nSample communes from department {dept_code}:")
for commune in dept_communes[:3]: # Show first 3 communes
print(f" - {commune.get('nom')} (code: {commune.get('code')})")
if __name__ == "__main__":
# Uncomment to run the test with sample departments
# test_sample()
# Run the full script
main()

View file

@ -1,5 +1,10 @@
#!/bin/bash
curl -X GET "https://osm-commerces.cipherbliss.com/admin/followup/global"
# Les 10 codes postaux des villes les plus peuplées de France
codes_insee=(
"06088" # Nice
@ -238,4 +243,7 @@ for code in "${codes_insee[@]}"; do
sleep 5
done
echo "Traitement terminé"
echo "Traitement terminé pour les codes insee"
curl -X GET "https://osm-commerces.cipherbliss.com/admin/followup/global"

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250622224949 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL
SQL);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250622225249 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE stats CHANGE name name VARCHAR(255) DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note VARCHAR(255) DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250623224321 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD budget_annuel NUMERIC(15, 2) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP budget_annuel
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250624103515 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place ADD email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, ADD place_count INT DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place DROP email_content, DROP place_count
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250626204942 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE action_log (id INT AUTO_INCREMENT NOT NULL, kind VARCHAR(255) NOT NULL, from_url VARCHAR(500) NOT NULL, who VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE action_log
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250626205820 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE action_log ADD from_url VARCHAR(500) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE action_log DROP from_url
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250626210012 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE action_log ADD who VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE action_log DROP who
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250629080450 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE email_content email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note VARCHAR(255) DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL, CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL, CHANGE email_content email_content LONGTEXT DEFAULT NULL
SQL);
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250629130159 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE city_follow_up (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, measure DOUBLE PRECISION NOT NULL, date DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE email_content email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD city_follow_ups_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD CONSTRAINT FK_574767AAA543722E FOREIGN KEY (city_follow_ups_id) REFERENCES city_follow_up (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_574767AAA543722E ON stats (city_follow_ups_id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE city_follow_up
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP FOREIGN KEY FK_574767AAA543722E
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_574767AAA543722E ON stats
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP city_follow_ups_id
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note VARCHAR(255) DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL, CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL, CHANGE email_content email_content LONGTEXT DEFAULT NULL
SQL);
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250629130947 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE city_follow_up ADD stats_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE city_follow_up ADD CONSTRAINT FK_DFE8468970AA3482 FOREIGN KEY (stats_id) REFERENCES stats (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_DFE8468970AA3482 ON city_follow_up (stats_id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE email_content email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP FOREIGN KEY FK_574767AAA543722E
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_574767AAA543722E ON stats
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP city_follow_ups_id
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD city_follow_ups_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD CONSTRAINT FK_574767AAA543722E FOREIGN KEY (city_follow_ups_id) REFERENCES city_follow_up (id) ON UPDATE NO ACTION ON DELETE NO ACTION
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_574767AAA543722E ON stats (city_follow_ups_id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE city_follow_up DROP FOREIGN KEY FK_DFE8468970AA3482
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_DFE8468970AA3482 ON city_follow_up
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE city_follow_up DROP stats_id
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note VARCHAR(255) DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL, CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL, CHANGE email_content email_content LONGTEXT DEFAULT NULL
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250629142706 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE email_content email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note VARCHAR(255) DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL, CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL, CHANGE email_content email_content LONGTEXT DEFAULT NULL
SQL);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250705104136 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE email_content email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD lat NUMERIC(10, 7) DEFAULT NULL, ADD lon NUMERIC(10, 7) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP lat, DROP lon
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note VARCHAR(255) DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL, CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL, CHANGE email_content email_content LONGTEXT DEFAULT NULL
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250712121647 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE email_content email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note VARCHAR(255) DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL, CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL, CHANGE email_content email_content LONGTEXT DEFAULT NULL
SQL);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250714154749 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE email_content email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uniq_stats_zone ON stats (zone)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP INDEX uniq_stats_zone ON stats
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note LONGTEXT DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL, CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL, CHANGE email_content email_content LONGTEXT DEFAULT NULL
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250714165523 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE email_content email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note LONGTEXT DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL, CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL, CHANGE email_content email_content LONGTEXT DEFAULT NULL
SQL);
}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250716124008 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE demande (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, query VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, insee INT DEFAULT NULL, PRIMARY KEY(id))
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER email TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER note TYPE TEXT
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER name TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER note_content TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER street TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER housenumber TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER siret TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER osm_user TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER email_content TYPE TEXT
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ALTER name TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uniq_stats_zone ON stats (zone)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE demande
SQL);
$this->addSql(<<<'SQL'
DROP INDEX uniq_stats_zone
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ALTER name TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER email TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER note TYPE TEXT
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER name TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER note_content TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER street TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER housenumber TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER siret TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER osm_user TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER email_content TYPE TEXT
SQL);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Add osmObjectType and osmId fields to demande table
*/
final class Version20250716160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add osmObjectType and osmId fields to demande table';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE demande ADD osm_object_type VARCHAR(10) DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE demande ADD osm_id INT DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE demande DROP osm_object_type
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE demande DROP osm_id
SQL);
}
}

495
package-lock.json generated
View file

@ -1,16 +1,27 @@
{
"name": "osm-commerces",
"name": "osm-commerce-sf",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"license": "UNLICENSED",
"dependencies": {
"charjs": "^0.0.1-security",
"chartjs-adapter-date-fns": "^3.0.0",
"jquery": "^3.7.1",
"table-sort": "^1.0.16"
},
"devDependencies": {
"@babel/core": "^7.17.0",
"@babel/preset-env": "^7.16.0",
"@symfony/webpack-encore": "^5.0.0",
"chart.js": "^4.5.0",
"chartjs-plugin-datalabels": "^2.2.0",
"core-js": "^3.38.0",
"maplibre-gl": "^5.6.0",
"regenerator-runtime": "^0.13.9",
"table-sort-js": "^1.22.2",
"tablesort": "^5.6.0",
"webpack": "^5.74.0",
"webpack-cli": "^5.1.0"
}
@ -1694,6 +1705,97 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
"dev": true,
"license": "ISC",
"dependencies": {
"get-stream": "^6.0.1",
"minimist": "^1.2.6"
},
"bin": {
"geojson-rewind": "geojson-rewind"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
"integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
"integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
"integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~0.1.0"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "23.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.3.0.tgz",
"integrity": "sha512-IGJtuBbaGzOUgODdBRg66p8stnwj9iDXkgbYKoYcNiiQmaez5WVRfXm4b03MCDwmZyX93csbfHFWEJJYHnn5oA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"rw": "^1.3.3",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@nuxt/friendly-errors-webpack-plugin": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@nuxt/friendly-errors-webpack-plugin/-/friendly-errors-webpack-plugin-2.6.0.tgz",
@ -1922,6 +2024,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson-vt": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -1956,6 +2075,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mapbox__point-geometry": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
"integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mapbox__vector-tile": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
"integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*",
"@types/mapbox__point-geometry": "*",
"@types/pbf": "*"
}
},
"node_modules/@types/node": {
"version": "22.15.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
@ -1966,6 +2104,23 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pbf": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@ -2469,6 +2624,43 @@
"node": ">=4"
}
},
"node_modules/charjs": {
"version": "0.0.1-security",
"resolved": "https://registry.npmjs.org/charjs/-/charjs-0.0.1-security.tgz",
"integrity": "sha512-O3j2a0oEM2LXTyFdAq5Y2ntGaSekgeAW7FtjOGU78PNe5GReNGQvoEpyGmd8Xv3/8VS+2HbgWnxntMvVGrY4Cg=="
},
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=2.8.0",
"date-fns": ">=2.0.0"
}
},
"node_modules/chartjs-plugin-datalabels": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
"integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"chart.js": ">=3.0.0"
}
},
"node_modules/chrome-trace-event": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
@ -2895,6 +3087,17 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -2982,6 +3185,13 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/earcut": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz",
"integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==",
"dev": true,
"license": "ISC"
},
"node_modules/electron-to-chromium": {
"version": "1.5.158",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.158.tgz",
@ -3245,6 +3455,33 @@
"node": ">=6.9.0"
}
},
"node_modules/geojson-vt": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
"dev": true,
"license": "ISC"
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gl-matrix": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz",
"integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==",
"dev": true,
"license": "MIT"
},
"node_modules/glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
@ -3252,6 +3489,47 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/global-prefix": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
"integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ini": "^4.1.3",
"kind-of": "^6.0.3",
"which": "^4.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/global-prefix/node_modules/isexe": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16"
}
},
"node_modules/global-prefix/node_modules/which": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
"integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^16.13.0 || >=18.0.0"
}
},
"node_modules/globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
@ -3325,6 +3603,27 @@
"postcss": "^8.1.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/import-local": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@ -3424,6 +3723,16 @@
"node": ">=8"
}
},
"node_modules/ini": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
"integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/interpret": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
@ -3626,6 +3935,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/jquery": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -3660,6 +3975,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"dev": true,
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@ -3673,6 +3995,13 @@
"node": ">=6"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"dev": true,
"license": "ISC"
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -3775,6 +4104,48 @@
"yallist": "^3.0.2"
}
},
"node_modules/maplibre-gl": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.6.0.tgz",
"integrity": "sha512-7TuHMozUC4rlIp08bSsxCixFn18P24otrlZU/7UGCO5RufFTJadFzauTrvBHr9FB67MbJ6nvFXEftGd0bUl4Iw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^0.1.0",
"@mapbox/tiny-sdf": "^2.0.6",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^1.3.1",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/maplibre-gl-style-spec": "^23.3.0",
"@types/geojson": "^7946.0.16",
"@types/geojson-vt": "3.2.5",
"@types/mapbox__point-geometry": "^0.1.4",
"@types/mapbox__vector-tile": "^1.3.4",
"@types/pbf": "^3.0.5",
"@types/supercluster": "^7.1.3",
"earcut": "^3.0.1",
"geojson-vt": "^4.0.2",
"gl-matrix": "^3.4.3",
"global-prefix": "^4.0.0",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^3.3.0",
"potpack": "^2.0.0",
"quickselect": "^3.0.0",
"supercluster": "^8.0.1",
"tinyqueue": "^3.0.0",
"vt-pbf": "^3.1.3"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
@ -3833,6 +4204,16 @@
"webpack": "^5.0.0"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3840,6 +4221,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -3955,6 +4343,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/pbf": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
"integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"ieee754": "^1.1.12",
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -4542,6 +4944,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/potpack": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz",
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==",
"dev": true,
"license": "ISC"
},
"node_modules/pretty-error": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
@ -4553,6 +4962,20 @@
"renderkid": "^3.0.0"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
"dev": true,
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"dev": true,
"license": "ISC"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -4729,6 +5152,16 @@
"node": ">=8"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/resolve-url-loader": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz",
@ -4753,6 +5186,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -4950,6 +5390,16 @@
"postcss": "^8.4.32"
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@ -5078,6 +5528,30 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/table-sort": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/table-sort/-/table-sort-1.0.16.tgz",
"integrity": "sha512-w7TDMfszdFY36aWQKRiAg0qQjOmvIy1IQKplmgpOCimOZ69BP4y5Ne4+jBQeYn990Rn40/wCALR0eAcLqxECWA==",
"license": "ISC"
},
"node_modules/table-sort-js": {
"version": "1.22.2",
"resolved": "https://registry.npmjs.org/table-sort-js/-/table-sort-js-1.22.2.tgz",
"integrity": "sha512-KUpmoYWH1TCnyiylE0HMCtMeAisl0KYBFjZfBL3CPHOlnhA8jy+RFfZbH6DwCpXAvmK73vsDAX54hg9J4DhuRQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tablesort": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/tablesort/-/tablesort-5.6.0.tgz",
"integrity": "sha512-cZZXK3G089PbpxH8N7vN7Z21SEKqXAaCiSVOmZdR/v7z8TFCsF/OFr0rzjhQuFlQQHy9uQtW9P2oQFJzJFGVrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16",
"npm": ">= 8"
}
},
"node_modules/tapable": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
@ -5190,6 +5664,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"dev": true,
"license": "ISC"
},
"node_modules/tmp": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz",
@ -5296,6 +5777,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/vt-pbf": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
"integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "0.1.0",
"@mapbox/vector-tile": "^1.3.1",
"pbf": "^3.2.1"
}
},
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",

View file

@ -3,8 +3,13 @@
"@babel/core": "^7.17.0",
"@babel/preset-env": "^7.16.0",
"@symfony/webpack-encore": "^5.0.0",
"chart.js": "^4.5.0",
"chartjs-plugin-datalabels": "^2.2.0",
"core-js": "^3.38.0",
"maplibre-gl": "^5.6.0",
"regenerator-runtime": "^0.13.9",
"table-sort-js": "^1.22.2",
"tablesort": "^5.6.0",
"webpack": "^5.74.0",
"webpack-cli": "^5.1.0"
},
@ -15,5 +20,11 @@
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress"
},
"dependencies": {
"charjs": "^0.0.1-security",
"chartjs-adapter-date-fns": "^3.0.0",
"jquery": "^3.7.1",
"table-sort": "^1.0.16"
}
}

View file

@ -0,0 +1,92 @@
/* Styles pour la sidebar */
.city-sidebar {
background-color: #f8f9fa;
border-right: 1px solid #dee2e6;
padding: 1rem;
height: 100%;
position: fixed;
top: 0;
left: 0;
width: 100%;
overflow-y: auto;
z-index: 1000;
}
/* Desktop styles */
@media (min-width: 768px) {
.city-sidebar {
width: 25%;
max-width: 280px;
}
.main-content {
margin-left: 25%;
width: 75%;
padding-top: 20px;
}
.main-header {
margin-left: 25%;
width: 75%;
z-index: 1001;
}
.main-footer {
margin-left: 25%;
width: 75%;
}
}
/* Mobile styles */
@media (max-width: 767px) {
.city-sidebar {
position: relative;
width: 100%;
max-height: none;
}
.main-content {
margin-left: 0;
width: 100%;
}
.main-header {
margin-left: 0;
width: 100%;
}
.main-footer {
margin-left: 0;
width: 100%;
}
}
.city-sidebar .nav-link {
color: #495057;
border-radius: 0.25rem;
padding: 0.5rem 1rem;
margin-bottom: 0.25rem;
}
.city-sidebar .nav-link:hover {
background-color: #e9ecef;
}
.city-sidebar .nav-link.active {
background-color: #0d6efd;
color: white;
}
.city-sidebar .nav-link i {
width: 1.5rem;
text-align: center;
margin-right: 0.5rem;
}
.city-sidebar .sidebar-heading {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.1rem;
color: #6c757d;
margin-top: 1rem;
margin-bottom: 0.5rem;
padding-left: 0.5rem;
}
.section-anchor {
scroll-margin-top: 2rem;
}

View file

@ -60,6 +60,48 @@
padding: 0;
}
/* Home page specific styles */
.hero-image {
max-width: 100%;
height: auto;
}
.feature-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
font-size: 2rem;
color: #fff;
border-radius: 0.75rem;
background-color: #0d6efd;
}
.step-circle {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: #0d6efd;
color: white;
font-weight: bold;
font-size: 1.25rem;
margin-right: 1rem;
}
.card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1) !important;
}
/* Media queries */
@media (max-width: 768px) {
.main-header h1 {
@ -69,4 +111,13 @@
.main-footer {
padding: 1.5rem 0;
}
.display-4 {
font-size: 2.5rem;
}
.hero-image {
max-height: 200px;
margin-top: 2rem;
}
}

View file

@ -4,6 +4,11 @@ use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
// Optimisations pour éviter les timeouts
ini_set('max_execution_time', 300);
ini_set('memory_limit', '1024M');
set_time_limit(300);
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

File diff suppressed because one or more lines are too long

View file

@ -1,35 +0,0 @@
/* These style add the up/down arrows for the current sorted column
* To support more columns, just augment these styles in like form.
*/
table.js-sort-asc.js-sort-0 thead tr:last-child > :nth-child(1):after,
table.js-sort-asc.js-sort-1 thead tr:last-child > :nth-child(2):after,
table.js-sort-asc.js-sort-2 thead tr:last-child > :nth-child(3):after,
table.js-sort-asc.js-sort-3 thead tr:last-child > :nth-child(4):after,
table.js-sort-asc.js-sort-4 thead tr:last-child > :nth-child(5):after,
table.js-sort-asc.js-sort-5 thead tr:last-child > :nth-child(6):after,
table.js-sort-asc.js-sort-6 thead tr:last-child > :nth-child(7):after,
table.js-sort-asc.js-sort-7 thead tr:last-child > :nth-child(8):after,
table.js-sort-asc.js-sort-8 thead tr:last-child > :nth-child(9):after,
table.js-sort-asc.js-sort-9 thead tr:last-child > :nth-child(10):after
{
content: "\25b2";
font-size: 0.7em;
padding-left: 3px;
line-height: 0.7em;
}
table.js-sort-desc.js-sort-0 thead tr:last-child > :nth-child(1):after,
table.js-sort-desc.js-sort-1 thead tr:last-child > :nth-child(2):after,
table.js-sort-desc.js-sort-2 thead tr:last-child > :nth-child(3):after,
table.js-sort-desc.js-sort-3 thead tr:last-child > :nth-child(4):after,
table.js-sort-desc.js-sort-4 thead tr:last-child > :nth-child(5):after,
table.js-sort-desc.js-sort-5 thead tr:last-child > :nth-child(6):after,
table.js-sort-desc.js-sort-6 thead tr:last-child > :nth-child(7):after,
table.js-sort-desc.js-sort-7 thead tr:last-child > :nth-child(8):after,
table.js-sort-desc.js-sort-8 thead tr:last-child > :nth-child(9):after,
table.js-sort-desc.js-sort-9 thead tr:last-child > :nth-child(10):after
{
content: "\25bc";
font-size: 0.7em;
padding-left: 3px;
line-height: 0.7em;
}

View file

@ -1,251 +0,0 @@
/**
* sort-table.js
* A pure JavaScript (no dependencies) solution to make HTML
* Tables sortable
*
* Copyright (c) 2013 Tyler Uebele
* Released under the MIT license. See included LICENSE.txt
* or http://opensource.org/licenses/MIT
*
* latest version available at https://github.com/tyleruebele/sort-table
*/
/**
* Sort the rows in a HTML Table
*
* @param Table The Table DOM object
* @param col The zero-based column number by which to sort
* @param dir Optional. The sort direction; pass 1 for asc; -1 for desc
* @returns void
*/
function sortTable(Table, col, dir) {
var sortClass, i;
if (!Table) {
return;
}
// get previous sort column
sortTable.sortCol = -1;
sortClass = Table.className.match(/js-sort-\d+/);
if (null != sortClass) {
sortTable.sortCol = sortClass[0].replace(/js-sort-/, '');
Table.className = Table.className.replace(new RegExp(' ?' + sortClass[0] + '\\b'), '');
}
// If sort column was not passed, use previous
if ('undefined' === typeof col) {
col = sortTable.sortCol;
}
if ('undefined' !== typeof dir) {
// Accept -1 or 'desc' for descending. All else is ascending
sortTable.sortDir = dir == -1 || dir == 'desc' ? -1 : 1;
} else {
// sort direction was not passed, use opposite of previous
sortClass = Table.className.match(/js-sort-(a|de)sc/);
if (null != sortClass && sortTable.sortCol == col) {
sortTable.sortDir = 'js-sort-asc' == sortClass[0] ? -1 : 1;
} else {
sortTable.sortDir = 1;
}
}
Table.className = Table.className.replace(/ ?js-sort-(a|de)sc/g, '');
// update sort column
Table.className += ' js-sort-' + col;
sortTable.sortCol = col;
// update sort direction
Table.className += ' js-sort-' + (sortTable.sortDir == -1 ? 'desc' : 'asc');
// get sort type
if (col < Table.tHead.rows[Table.tHead.rows.length - 1].cells.length) {
sortClass = Table.tHead.rows[Table.tHead.rows.length - 1].cells[col].className.match(/js-sort-[-\w]+/);
}
// Improved support for colspan'd headers
for (i = 0; i < Table.tHead.rows[Table.tHead.rows.length - 1].cells.length; i++) {
if (col == Table.tHead.rows[Table.tHead.rows.length - 1].cells[i].getAttribute('data-js-sort-colNum')) {
sortClass = Table.tHead.rows[Table.tHead.rows.length - 1].cells[i].className.match(/js-sort-[-\w]+/);
}
}
if (null != sortClass) {
sortTable.sortFunc = sortClass[0].replace(/js-sort-/, '');
} else {
sortTable.sortFunc = 'string';
}
// sort!
var rows = [],
TBody = Table.tBodies[0];
for (i = 0; i < TBody.rows.length; i++) {
rows[i] = TBody.rows[i];
}
rows.sort(sortTable.compareRow);
while (TBody.firstChild) {
TBody.removeChild(TBody.firstChild);
}
for (i = 0; i < rows.length; i++) {
TBody.appendChild(rows[i]);
}
}
/**
* Compare two table rows based on current settings
*
* @param RowA A TR DOM object
* @param RowB A TR DOM object
* @returns {number} 1 if RowA is greater, -1 if RowB, 0 if equal
*/
sortTable.compareRow = function (RowA, RowB) {
var valA, valB;
if ('function' != typeof sortTable[sortTable.sortFunc]) {
sortTable.sortFunc = 'string';
}
valA = sortTable[sortTable.sortFunc](RowA.cells[sortTable.sortCol]);
valB = sortTable[sortTable.sortFunc](RowB.cells[sortTable.sortCol]);
return valA == valB ? 0 : sortTable.sortDir * (valA > valB ? 1 : -1);
};
/**
* Strip all HTML, no exceptions
* @param html
* @returns {string}
*/
sortTable.stripTags = function (html) {
return html.replace(/<\/?[a-z][a-z0-9]*\b[^>]*>/gi, '');
};
/**
* Helper function that converts a table cell (TD) to a comparable value
* Converts innerHTML to a JS Date object
*
* @param Cell A TD DOM object
* @returns {Date}
*/
sortTable.date = function (Cell) {
return new Date(sortTable.stripTags(Cell.innerHTML));
};
/**
* Helper function that converts a table cell (TD) to a comparable value
* Converts innerHTML to a JS Number object
*
* @param Cell A TD DOM object
* @returns {Number}
*/
sortTable.number = function (Cell) {
return Number(sortTable.stripTags(Cell.innerHTML).replace(/[^-\d.]/g, ''));
};
/**
* Helper function that converts a table cell (TD) to a comparable value
* Converts innerHTML to a lower case string for insensitive compare
*
* @param Cell A TD DOM object
* @returns {String}
*/
sortTable.string = function (Cell) {
return sortTable.stripTags(Cell.innerHTML).toLowerCase();
};
/**
* Helper function that converts a table cell (TD) to a comparable value
* Captures the last space-delimited token from innerHTML
*
* @param Cell A TD DOM object
* @returns {String}
*/
sortTable.last = function (Cell) {
return sortTable.stripTags(Cell.innerHTML).split(' ').pop().toLowerCase();
};
/**
* Helper function that converts a table cell (TD) to a comparable value
* Captures the value of the first childNode
*
* @param Cell A TD DOM object
* @returns {String}
*/
sortTable.input = function (Cell) {
for (var i = 0; i < Cell.children.length; i++) {
if ('object' == typeof Cell.children[i]
&& 'undefined' != typeof Cell.children[i].value
) {
return Cell.children[i].value.toLowerCase();
}
}
return sortTable.string(Cell);
};
/**
* Return the click handler appropriate to the specified Table and column
*
* @param Table Table to sort
* @param col Column to sort by
* @returns {Function} Click Handler
*/
sortTable.getClickHandler = function (Table, col) {
return function () {
sortTable(Table, col);
};
};
/**
* Attach sortTable() calls to table header cells' onclick events
* If the table(s) do not have a THead node, one will be created around the
* first row
*/
sortTable.init = function () {
var THead, Tables, Handler;
if (document.querySelectorAll) {
Tables = document.querySelectorAll('table.js-sort-table');
} else {
Tables = document.getElementsByTagName('table');
}
for (var i = 0; i < Tables.length; i++) {
// Because IE<8 doesn't support querySelectorAll, skip unclassed tables
if (!document.querySelectorAll && null === Tables[i].className.match(/\bjs-sort-table\b/)) {
continue;
}
// Prevent repeat processing
if (Tables[i].attributes['data-js-sort-table']) {
continue;
}
// Ensure table has a tHead element
if (!Tables[i].tHead) {
THead = document.createElement('thead');
THead.appendChild(Tables[i].rows[0]);
Tables[i].insertBefore(THead, Tables[i].children[0]);
} else {
THead = Tables[i].tHead;
}
// Attach click events to table header
for (var rowNum = 0; rowNum < THead.rows.length; rowNum++) {
for (var cellNum = 0, colNum = 0; cellNum < THead.rows[rowNum].cells.length; cellNum++) {
// Define which column the header should invoke sorting for
THead.rows[rowNum].cells[cellNum].setAttribute('data-js-sort-colNum', colNum);
Handler = sortTable.getClickHandler(Tables[i], colNum);
window.addEventListener
? THead.rows[rowNum].cells[cellNum].addEventListener('click', Handler)
: window.attachEvent && THead.rows[rowNum].cells[cellNum].attachEvent('onclick', Handler);
colNum += THead.rows[rowNum].cells[cellNum].colSpan;
}
}
// Mark table as processed
Tables[i].setAttribute('data-js-sort-table', 'true')
}
};
// Run sortTable.init() when the page loads
window.addEventListener
? window.addEventListener('load', sortTable.init, false)
: window.attachEvent && window.attachEvent('onload', sortTable.init)
;

104
public/osm_fr_groups.json Normal file
View file

@ -0,0 +1,104 @@
{
"last_updated": "2025-08-22T17:10:41.058478",
"local_groups": [
{
"name": "Liste des groupes locaux se réunissant régulièrement",
"url": "https://framacalc.org/osm-groupes-locaux",
"description": "",
"type": "local_group"
},
{
"name": "Carte des groupes locaux se réunissant régulièrement",
"url": "https://umap.openstreetmap.fr/fr/map/groupes-locaux-openstreetmap_152488",
"description": "",
"type": "local_group"
}
],
"working_groups": [
{
"name": "Que venir faire au sein de l'association ?",
"url": "https://forum.openstreetmap.fr/t/que-venir-faire-au-sein-de-lassociation/15454",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "GT Inclusivité",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#GT_Inclusivité",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "GT Technique",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#GT_Technique",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "GT Communication externe",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#GT_Communication",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "GT Animation de la communauté",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#GT_Animation_de_la_communauté",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "GT Communautés locales",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#GT_Communautés_locales",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "GT International",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#GT_International",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "GT Gestion et comptabilité",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#GT_Gestion_et_comptabilité",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "GT Soutiens",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#GT_Soutiens",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "GT Conférence SotM-FR",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#GT_Conférence_SotM-FR",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "Groupes spéciaux",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#Groupes_spéciaux",
"description": "",
"category": "Général",
"type": "working_group"
},
{
"name": "Groupes projets et thématiques",
"url": "https://wiki.openstreetmap.org/wiki/France/OSM-FR/Groupes_de_travail#Groupes_projets_et_thématiques",
"description": "",
"category": "Général",
"type": "working_group"
}
],
"umap_url": "https://umap.openstreetmap.fr/fr/map/groupes-locaux-openstreetmap_152488"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,4 @@
{
"last_updated": "2025-08-22T23:19:05.767890",
"recent_changes": []
}

View file

@ -0,0 +1,112 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:clean-duplicate-stats',
description: 'Supprime les doublons de Stats ayant le même code INSEE, en gardant le plus ancien. Option dry-run pour simuler.'
)]
class CleanDuplicateStatsCommand extends Command
{
public function __construct(private EntityManagerInterface $entityManager)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simule la suppression sans rien effacer')
->setHelp('Supprime les doublons de Stats (même code INSEE), en gardant le plus ancien. Utilisez --dry-run pour simuler.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
$repo = $this->entityManager->getRepository(Stats::class);
$allStats = $repo->findAll();
$io->title('Recherche des doublons Stats par code INSEE');
// Regrouper par code INSEE
$statsByZone = [];
foreach ($allStats as $stat) {
$zone = $stat->getZone();
if (!$zone) continue;
$statsByZone[$zone][] = $stat;
}
$toDelete = [];
$toKeep = [];
foreach ($statsByZone as $zone => $statsList) {
if (count($statsList) > 1) {
// Trier par date_created (le plus ancien d'abord), puis par id si date absente
usort($statsList, function($a, $b) {
$da = $a->getDateCreated();
$db = $b->getDateCreated();
if ($da && $db) {
return $da <=> $db;
} elseif ($da) {
return -1;
} elseif ($db) {
return 1;
} else {
return $a->getId() <=> $b->getId();
}
});
// Garder le premier, supprimer les autres
$toKeep[$zone] = $statsList[0];
$toDelete[$zone] = array_slice($statsList, 1);
}
}
$totalToDelete = array_sum(array_map('count', $toDelete));
if ($totalToDelete === 0) {
$io->success('Aucun doublon trouvé.');
return Command::SUCCESS;
}
$io->section('Résumé des actions par code INSEE :');
foreach ($toDelete as $zone => $statsList) {
$io->writeln("<info>Zone INSEE : $zone</info>");
$statKept = $toKeep[$zone];
$io->writeln(sprintf(" <fg=green>Gardé : [ID %d] %s | Créé: %s</>",
$statKept->getId(),
$statKept->getName(),
$statKept->getDateCreated() ? $statKept->getDateCreated()->format('Y-m-d H:i:s') : 'N/A'
));
foreach ($statsList as $stat) {
$io->writeln(sprintf(" <fg=red>Supprimé: [ID %d] %s | Créé: %s</>",
$stat->getId(),
$stat->getName(),
$stat->getDateCreated() ? $stat->getDateCreated()->format('Y-m-d H:i:s') : 'N/A'
));
}
}
$io->warning($totalToDelete . ' objet(s) Stats seraient supprimés.');
if ($dryRun) {
$io->note('Mode dry-run : aucune suppression effectuée.');
return Command::SUCCESS;
}
foreach ($toDelete as $statsList) {
foreach ($statsList as $stat) {
$this->entityManager->remove($stat);
}
}
$this->entityManager->flush();
$io->success($totalToDelete . ' doublon(s) supprimé(s) !');
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,194 @@
<?php
namespace App\Command;
use App\Entity\CityFollowUp;
use App\Entity\Stats;
use App\Repository\StatsRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:create-missing-communes-stats',
description: 'Create Stats objects for missing communes using data from cities_insee.csv',
)]
class CreateMissingCommunesStatsCommand extends Command
{
private const CSV_PATH = '/home/poule/encrypted/stockage-syncable/www/development/html/osm-commerce-sf/counting_osm_objects/cities_insee.csv';
// List of themes to create CityFollowUp measurements for
private const THEMES = [
'charging_station',
'defibrillator',
'shop',
'amenity',
// Add more themes as needed
];
private EntityManagerInterface $entityManager;
private StatsRepository $statsRepository;
public function __construct(
EntityManagerInterface $entityManager,
StatsRepository $statsRepository
) {
parent::__construct();
$this->entityManager = $entityManager;
$this->statsRepository = $statsRepository;
}
protected function configure(): void
{
$this
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit the number of communes to process', 0)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simulate without modifying the database');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Creating Stats objects for missing communes');
$limit = (int) $input->getOption('limit');
$dryRun = $input->getOption('dry-run');
// Check if CSV file exists
if (!file_exists(self::CSV_PATH)) {
$io->error('CSV file not found: ' . self::CSV_PATH);
return Command::FAILURE;
}
// Read CSV file
$io->info('Reading CSV file: ' . self::CSV_PATH);
$communes = $this->readCsvFile(self::CSV_PATH);
$io->info(sprintf('Found %d communes in CSV file', count($communes)));
// Get existing Stats objects
$existingStats = $this->statsRepository->findAll();
$existingZones = [];
foreach ($existingStats as $stats) {
$existingZones[$stats->getZone()] = true;
}
$io->info(sprintf('Found %d existing Stats objects', count($existingZones)));
// Find missing communes
$missingCommunes = [];
foreach ($communes as $commune) {
if (!isset($existingZones[$commune['code_insee']])) {
$missingCommunes[] = $commune;
}
}
$io->info(sprintf('Found %d missing communes', count($missingCommunes)));
// Apply limit if specified
if ($limit > 0 && count($missingCommunes) > $limit) {
$io->info(sprintf('Limiting to %d communes', $limit));
$missingCommunes = array_slice($missingCommunes, 0, $limit);
}
// Create Stats objects for missing communes
$created = 0;
$now = new \DateTime();
foreach ($missingCommunes as $commune) {
$io->text(sprintf('Processing commune: %s (%s)', $commune['nom_standard'], $commune['code_insee']));
if (!$dryRun) {
// Create new Stats object
$stats = new Stats();
$stats->setZone($commune['code_insee']);
$stats->setName($commune['nom_standard']);
// Handle population - convert empty string to null or cast to int
$population = $commune['population'] !== '' ? (int) $commune['population'] : null;
$stats->setPopulation($population);
$stats->setDateCreated($now);
$stats->setKind('command'); // Set the kind to 'command'
// Set coordinates if available and not empty
if (isset($commune['latitude_centre']) && isset($commune['longitude_centre'])) {
// Convert empty strings to null for numeric fields
$lat = $commune['latitude_centre'] !== '' ? $commune['latitude_centre'] : null;
$lon = $commune['longitude_centre'] !== '' ? $commune['longitude_centre'] : null;
$stats->setLat($lat);
$stats->setLon($lon);
}
// Create CityFollowUp measurements for each theme
foreach (self::THEMES as $theme) {
// Create a basic measurement with a default value
// In a real scenario, you would fetch actual data for each theme
$followUp = new CityFollowUp();
$followUp->setName($theme . '_count');
$followUp->setMeasure(0); // Default value, should be replaced with actual data
$followUp->setDate($now);
$followUp->setStats($stats);
$this->entityManager->persist($followUp);
}
$this->entityManager->persist($stats);
$created++;
// Flush every 20 entities to avoid memory issues
if ($created % 20 === 0) {
$this->entityManager->flush();
$this->entityManager->clear(Stats::class);
$this->entityManager->clear(CityFollowUp::class);
$io->text(sprintf('Flushed after creating %d Stats objects', $created));
}
} else {
$created++;
}
}
// Final flush
if (!$dryRun && $created > 0) {
$this->entityManager->flush();
}
if ($dryRun) {
$io->success(sprintf('Dry run completed. Would have created %d Stats objects for missing communes.', $created));
} else {
$io->success(sprintf('Created %d Stats objects for missing communes.', $created));
}
return Command::SUCCESS;
}
/**
* Read CSV file and return an array of communes
*/
private function readCsvFile(string $path): array
{
$communes = [];
if (($handle = fopen($path, 'r')) !== false) {
// Read header
$header = fgetcsv($handle, 0, ',');
if ($header === false) {
return [];
}
// Read data
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$commune = [];
foreach ($header as $i => $key) {
$commune[$key] = $row[$i] ?? '';
}
$communes[] = $commune;
}
fclose($handle);
}
return $communes;
}
}

View file

@ -0,0 +1,262 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use App\Repository\StatsRepository;
use App\Service\ActionLogger;
use App\Service\BudgetService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:create-missing-stats-from-csv',
description: 'Crée des objets Stats manquants à partir du fichier communes_france.csv',
)]
class CreateMissingStatsFromCsvCommand extends Command
{
private EntityManagerInterface $entityManager;
private StatsRepository $statsRepository;
private ActionLogger $actionLogger;
private ?BudgetService $budgetService;
public function __construct(
EntityManagerInterface $entityManager,
StatsRepository $statsRepository,
ActionLogger $actionLogger,
?BudgetService $budgetService = null
) {
parent::__construct();
$this->entityManager = $entityManager;
$this->statsRepository = $statsRepository;
$this->actionLogger = $actionLogger;
$this->budgetService = $budgetService;
}
protected function configure(): void
{
$this
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limite le nombre de communes à traiter', 0)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simule sans modifier la base de données')
->setHelp('Cette commande examine le fichier CSV des communes et crée des objets Stats pour les communes qui n\'en ont pas encore. Les objets sont créés avec les informations du CSV et complétés avec des données supplémentaires (coordonnées, budget, etc.).');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Création des objets Stats manquants à partir du fichier CSV');
$limit = (int) $input->getOption('limit');
$dryRun = $input->getOption('dry-run');
$this->actionLogger->log('command/create_missing_stats_from_csv', [
'limit' => $limit,
'dry_run' => $dryRun
]);
// Vérifier si le fichier CSV existe
$csvFile = 'communes_france.csv';
if (!file_exists($csvFile)) {
$io->error('Le fichier CSV des communes n\'existe pas. Veuillez exécuter le script fetch_communes.py pour le générer.');
return Command::FAILURE;
}
$createdCount = 0;
$skippedCount = 0;
$errorCount = 0;
// Ouvrir le fichier CSV
$handle = fopen($csvFile, 'r');
if (!$handle) {
$io->error('Impossible d\'ouvrir le fichier CSV des communes.');
return Command::FAILURE;
}
// Lire l'en-tête pour déterminer les indices des colonnes
$header = fgetcsv($handle);
$indices = array_flip($header);
// Vérifier que les colonnes nécessaires existent
$requiredColumns = ['code', 'nom'];
foreach ($requiredColumns as $column) {
if (!isset($indices[$column])) {
$io->error("La colonne '$column' est manquante dans le fichier CSV.");
fclose($handle);
return Command::FAILURE;
}
}
$io->info(sprintf('Lecture du fichier CSV: %s', $csvFile));
$io->info(sprintf('Colonnes trouvées: %s', implode(', ', $header)));
// Compter le nombre total de lignes pour la barre de progression
$totalLines = 0;
$tempHandle = fopen($csvFile, 'r');
if ($tempHandle) {
// Skip header
fgetcsv($tempHandle);
while (fgetcsv($tempHandle) !== false) {
$totalLines++;
}
fclose($tempHandle);
}
$io->info(sprintf('Nombre total de communes dans le CSV: %d', $totalLines));
// Créer une barre de progression
$progressBar = $io->createProgressBar($totalLines);
$progressBar->start();
// Traiter chaque ligne du CSV
while (($data = fgetcsv($handle)) !== false) {
try {
$inseeCode = $data[$indices['code']];
// Vérifier si une Stats existe déjà pour ce code INSEE
$existingStat = $this->statsRepository->findOneBy(['zone' => $inseeCode]);
if ($existingStat) {
$skippedCount++;
$progressBar->advance();
continue;
}
// Créer un nouvel objet Stats
$stat = new Stats();
$stat->setZone($inseeCode)
->setDateCreated(new \DateTime())
->setDateModified(new \DateTime())
->setKind('command'); // Utiliser 'command' comme source
// Ajouter le nom si disponible
if (isset($indices['nom']) && !empty($data[$indices['nom']])) {
$stat->setName($data[$indices['nom']]);
}
// Ajouter la population si disponible
if (isset($indices['population']) && !empty($data[$indices['population']])) {
$stat->setPopulation((int)$data[$indices['population']]);
}
// Ajouter les codes postaux si disponibles
if (isset($indices['codesPostaux']) && !empty($data[$indices['codesPostaux']])) {
$stat->setCodesPostaux($data[$indices['codesPostaux']]);
}
// Ajouter le SIREN si disponible
if (isset($indices['siren']) && !empty($data[$indices['siren']])) {
$stat->setSiren((int)$data[$indices['siren']]);
}
// Ajouter le code EPCI si disponible
if (isset($indices['codeEpci']) && !empty($data[$indices['codeEpci']])) {
$stat->setCodeEpci((int)$data[$indices['codeEpci']]);
}
// Compléter les données manquantes (coordonnées, budget, etc.)
if (!$dryRun) {
$this->completeStatsData($stat);
}
// Persister l'objet Stats
if (!$dryRun) {
$this->entityManager->persist($stat);
}
$createdCount++;
// Appliquer la limite si spécifiée
if ($limit > 0 && $createdCount >= $limit) {
$io->info(sprintf('Limite de %d communes atteinte.', $limit));
break;
}
// Flush tous les 100 objets pour éviter de surcharger la mémoire
if (!$dryRun && $createdCount % 100 === 0) {
$this->entityManager->flush();
$this->entityManager->clear(Stats::class);
$io->info(sprintf('Flush après création de %d objets Stats', $createdCount));
}
} catch (\Exception $e) {
$errorCount++;
$this->actionLogger->log('error_command_create_missing_stats_from_csv', [
'insee_code' => $inseeCode ?? 'unknown',
'error' => $e->getMessage()
]);
if ($output->isVerbose()) {
$io->warning(sprintf('Erreur pour la commune %s: %s', $inseeCode ?? 'unknown', $e->getMessage()));
}
}
$progressBar->advance();
}
$progressBar->finish();
$io->newLine(2);
// Flush les derniers objets
if (!$dryRun && $createdCount > 0) {
$this->entityManager->flush();
}
fclose($handle);
if ($dryRun) {
$io->success(sprintf('Simulation terminée. %d communes auraient été ajoutées, %d déjà existantes, %d erreurs.', $createdCount, $skippedCount, $errorCount));
} else {
$io->success(sprintf('Création des Stats manquantes terminée : %d communes ajoutées, %d déjà existantes, %d erreurs.', $createdCount, $skippedCount, $errorCount));
}
return Command::SUCCESS;
}
/**
* Complète les données manquantes d'un objet Stats (coordonnées, budget, etc.)
*/
private function completeStatsData(Stats $stat): void
{
$insee_code = $stat->getZone();
// Compléter les coordonnées si manquantes
if (!$stat->getLat() || !$stat->getLon()) {
try {
$apiUrl = 'https://geo.api.gouv.fr/communes/' . $insee_code . '?fields=centre';
$response = @file_get_contents($apiUrl);
if ($response !== false) {
$data = json_decode($response, true);
if (isset($data['centre']['coordinates']) && count($data['centre']['coordinates']) === 2) {
$stat->setLon((string)$data['centre']['coordinates'][0]);
$stat->setLat((string)$data['centre']['coordinates'][1]);
}
}
} catch (\Exception $e) {
$this->actionLogger->log('error_complete_stats_data', [
'insee_code' => $insee_code,
'error' => 'Failed to fetch coordinates: ' . $e->getMessage()
]);
}
}
// Compléter le budget si manquant
if (!$stat->getBudgetAnnuel() && $this->budgetService !== null) {
try {
$budget = $this->budgetService->getBudgetAnnuel($insee_code);
if ($budget !== null) {
$stat->setBudgetAnnuel((string)$budget);
}
} catch (\Exception $e) {
$this->actionLogger->log('error_complete_stats_data', [
'insee_code' => $insee_code,
'error' => 'Failed to fetch budget: ' . $e->getMessage()
]);
}
}
// Calculer le pourcentage de complétion
$stat->computeCompletionPercent();
}
}

View file

@ -0,0 +1,116 @@
<?php
namespace App\Command;
use App\Entity\Demande;
use App\Entity\Stats;
use App\Repository\DemandeRepository;
use App\Repository\StatsRepository;
use App\Service\Motocultrice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:create-stats-from-demandes',
description: 'Create Stats objects for cities with Demandes but no Stats',
)]
class CreateStatsFromDemandesCommand extends Command
{
private EntityManagerInterface $entityManager;
private DemandeRepository $demandeRepository;
private StatsRepository $statsRepository;
private Motocultrice $motocultrice;
public function __construct(
EntityManagerInterface $entityManager,
DemandeRepository $demandeRepository,
StatsRepository $statsRepository,
Motocultrice $motocultrice
) {
parent::__construct();
$this->entityManager = $entityManager;
$this->demandeRepository = $demandeRepository;
$this->statsRepository = $statsRepository;
$this->motocultrice = $motocultrice;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Creating Stats objects for cities with Demandes but no Stats');
// Find all Demandes with INSEE codes
$demandesWithInsee = $this->demandeRepository->createQueryBuilder('d')
->where('d.insee IS NOT NULL')
->getQuery()
->getResult();
if (empty($demandesWithInsee)) {
$io->warning('No Demandes with INSEE codes found.');
return Command::SUCCESS;
}
$io->info(sprintf('Found %d Demandes with INSEE codes.', count($demandesWithInsee)));
// Group Demandes by INSEE code
$demandesByInsee = [];
/** @var Demande $demande */
foreach ($demandesWithInsee as $demande) {
$insee = $demande->getInsee();
if (!isset($demandesByInsee[$insee])) {
$demandesByInsee[$insee] = [];
}
$demandesByInsee[$insee][] = $demande;
}
$io->info(sprintf('Found %d unique INSEE codes.', count($demandesByInsee)));
// Check which INSEE codes don't have Stats objects
$newStatsCount = 0;
foreach ($demandesByInsee as $insee => $demandes) {
$stats = $this->statsRepository->findOneBy(['zone' => $insee]);
if ($stats === null) {
// Create a new Stats object for this INSEE code
$stats = new Stats();
$stats->setZone((string) $insee);
// Try to get the city name from the API based on INSEE code
$apiCityName = $this->motocultrice->get_city_osm_from_zip_code($insee);
if ($apiCityName) {
// Use the API-provided city name
$stats->setName($apiCityName);
$io->text(sprintf('Using API city name: %s', $apiCityName));
} else {
// Fallback to the query from the first Demande if API doesn't return a name
$firstDemande = $demandes[0];
if ($firstDemande->getQuery()) {
$stats->setName($firstDemande->getQuery());
$io->text(sprintf('Using query as fallback name: %s', $firstDemande->getQuery()));
}
}
$stats->setDateCreated(new \DateTime());
$stats->setDateLabourageRequested(new \DateTime());
$stats->setKind('request'); // Set the kind to 'request' as it's created from Demandes
$this->entityManager->persist($stats);
$newStatsCount++;
$io->text(sprintf('Created Stats for INSEE code %s', $insee));
}
}
if ($newStatsCount > 0) {
$this->entityManager->flush();
$io->success(sprintf('Created %d new Stats objects.', $newStatsCount));
} else {
$io->info('No new Stats objects needed to be created.');
}
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Command;
use App\Entity\CityFollowUp;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:delete-zero-cityfollowup',
description: 'Supprime tous les CityFollowUp dont la mesure vaut 0.'
)]
class DeleteZeroCityFollowUpCommand extends Command
{
public function __construct(private EntityManagerInterface $em)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$repo = $this->em->getRepository(CityFollowUp::class);
$toDelete = $repo->createQueryBuilder('c')
->where('c.measure = 0')
->getQuery()
->getResult();
$count = count($toDelete);
foreach ($toDelete as $entity) {
$this->em->remove($entity);
}
$this->em->flush();
$output->writeln("$count CityFollowUp supprimés (mesure = 0)");
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,167 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:export-stats',
description: 'Exporte les objets Stats au format JSON avec leurs propriétés de nom et de décomptes'
)]
class ExportStatsCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'output',
'o',
InputOption::VALUE_REQUIRED,
'Fichier de sortie (par défaut: stats_export.json)',
'stats_export.json'
)
->addOption(
'zone',
'z',
InputOption::VALUE_REQUIRED,
'Code INSEE spécifique à exporter (optionnel)'
)
->addOption(
'pretty',
'p',
InputOption::VALUE_NONE,
'Formater le JSON avec indentation'
)
->setHelp('Cette commande exporte les objets Stats au format JSON avec leurs propriétés de nom et de décomptes.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$outputFile = $input->getOption('output');
$zone = $input->getOption('zone');
$pretty = $input->getOption('pretty');
$io->title('Export des objets Stats');
try {
// Construire la requête
$qb = $this->entityManager->getRepository(Stats::class)->createQueryBuilder('s');
if ($zone) {
$qb->where('s.zone = :zone')
->setParameter('zone', $zone);
$io->note("Export pour la zone INSEE: $zone");
}
$stats = $qb->getQuery()->getResult();
if (empty($stats)) {
$io->warning('Aucun objet Stats trouvé.');
return Command::SUCCESS;
}
$io->info(sprintf('Export de %d objet(s) Stats...', count($stats)));
// Préparer les données pour l'export
$exportData = [];
foreach ($stats as $stat) {
$statData = [
'id' => $stat->getId(),
'zone' => $stat->getZone(),
'name' => $stat->getName(),
'dateCreated' => $stat->getDateCreated() ? $stat->getDateCreated()->format('Y-m-d H:i:s') : null,
'dateModified' => $stat->getDateModified() ? $stat->getDateModified()->format('Y-m-d H:i:s') : null,
'population' => $stat->getPopulation(),
'budgetAnnuel' => $stat->getBudgetAnnuel(),
'siren' => $stat->getSiren(),
'codeEpci' => $stat->getCodeEpci(),
'codesPostaux' => $stat->getCodesPostaux(),
'decomptes' => [
'placesCount' => $stat->getPlacesCount(),
'avecHoraires' => $stat->getAvecHoraires(),
'avecAdresse' => $stat->getAvecAdresse(),
'avecSite' => $stat->getAvecSite(),
'avecAccessibilite' => $stat->getAvecAccessibilite(),
'avecNote' => $stat->getAvecNote(),
'completionPercent' => $stat->getCompletionPercent(),
// 'placesCountReal' => $stat->getPlaces()->count(), // SUPPRIMÉ
],
'followups' => []
];
// Ajouter les followups si disponibles
foreach ($stat->getCityFollowUps() as $followup) {
$statData['followups'][] = [
'name' => $followup->getName(),
'measure' => $followup->getMeasure(),
'date' => $followup->getDate()->format('Y-m-d H:i:s')
];
}
$exportData[] = $statData;
}
// Préparer le JSON
$jsonOptions = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
if ($pretty) {
$jsonOptions |= JSON_PRETTY_PRINT;
}
$jsonContent = json_encode($exportData, $jsonOptions);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Erreur lors de l\'encodage JSON: ' . json_last_error_msg());
}
// Écrire dans le fichier
$bytesWritten = file_put_contents($outputFile, $jsonContent);
if ($bytesWritten === false) {
throw new \Exception("Impossible d'écrire dans le fichier: $outputFile");
}
$io->success(sprintf(
'Export terminé avec succès ! %d objet(s) exporté(s) vers %s (%s octets)',
count($stats),
$outputFile,
number_format($bytesWritten, 0, ',', ' ')
));
// Afficher un aperçu des données
if ($io->isVerbose()) {
$io->section('Aperçu des données exportées');
foreach ($exportData as $index => $data) {
$io->text(sprintf(
'%d. %s (%s) - %d lieux, %d%% complété',
$index + 1,
$data['name'],
$data['zone'],
$data['decomptes']['placesCountReal'],
$data['decomptes']['completionPercent']
));
}
}
return Command::SUCCESS;
} catch (\Exception $e) {
$io->error('Erreur lors de l\'export: ' . $e->getMessage());
return Command::FAILURE;
}
}
}

View file

@ -0,0 +1,230 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:extract-insee-zones',
description: 'Extrait les données OSM pour chaque zone INSEE à partir du fichier france-latest.osm.pbf',
)]
class ExtractInseeZonesCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function configure(): void
{
$this
->addArgument('insee-code', InputArgument::OPTIONAL, 'Code INSEE spécifique à traiter')
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limite le nombre de villes à traiter', null)
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force l\'extraction même si le fichier JSON existe déjà')
->addOption('keep-pbf', 'k', InputOption::VALUE_NONE, 'Conserve les fichiers PBF intermédiaires')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$inseeCode = $input->getArgument('insee-code');
$limit = $input->getOption('limit');
$force = $input->getOption('force');
$keepPbf = $input->getOption('keep-pbf');
// Créer le dossier oss_data s'il n'existe pas
$ossDataDir = __DIR__ . '/../../oss_data';
if (!is_dir($ossDataDir)) {
$io->note('Création du dossier oss_data');
mkdir($ossDataDir, 0755, true);
}
// Vérifier que le fichier france-latest.osm.pbf existe
$francePbfFile = $ossDataDir . '/france-latest.osm.pbf';
if (!file_exists($francePbfFile)) {
$io->note('Le fichier france-latest.osm.pbf n\'existe pas. Téléchargement en cours depuis Geofabrik...');
// URL de téléchargement
$downloadUrl = 'https://download.geofabrik.de/europe/france-latest.osm.pbf';
// Télécharger le fichier
try {
$context = stream_context_create([
'http' => [
'header' => "User-Agent: OSM-Commerces/1.0\r\n"
]
]);
// Utiliser file_get_contents pour télécharger le fichier
$io->section('Téléchargement du fichier france-latest.osm.pbf');
$io->progressStart(100);
// Téléchargement par morceaux pour pouvoir afficher une progression
$fileHandle = fopen($francePbfFile, 'w');
$curlHandle = curl_init($downloadUrl);
curl_setopt($curlHandle, CURLOPT_FILE, $fileHandle);
curl_setopt($curlHandle, CURLOPT_HEADER, 0);
curl_setopt($curlHandle, CURLOPT_USERAGENT, 'OSM-Commerces/1.0');
curl_setopt($curlHandle, CURLOPT_NOPROGRESS, false);
// Use a simpler progress reporting approach
curl_setopt($curlHandle, CURLOPT_PROGRESSFUNCTION, function($resource, $downloadSize, $downloaded) use ($io) {
static $lastProgress = 0;
if ($downloadSize > 0) {
$progress = round(($downloaded / $downloadSize) * 100);
if ($progress > $lastProgress) {
$io->progressAdvance($progress - $lastProgress);
$lastProgress = $progress;
}
}
});
$success = curl_exec($curlHandle);
curl_close($curlHandle);
fclose($fileHandle);
$io->progressFinish();
if (!$success) {
throw new \Exception('Échec du téléchargement');
}
$io->success('Le fichier france-latest.osm.pbf a été téléchargé avec succès.');
} catch (\Exception $e) {
$io->error('Erreur lors du téléchargement du fichier france-latest.osm.pbf: ' . $e->getMessage());
return Command::FAILURE;
}
}
// Vérifier que le dossier polygons existe
$polygonsDir = __DIR__ . '/../../counting_osm_objects/polygons';
if (!is_dir($polygonsDir)) {
$io->error('Le dossier des polygones n\'existe pas. Veuillez d\'abord exécuter la commande app:retrieve-city-polygons.');
return Command::FAILURE;
}
// Créer le dossier pour les extractions JSON si nécessaire
$extractsDir = __DIR__ . '/../../insee_extracts';
if (!is_dir($extractsDir)) {
$io->note('Création du dossier insee_extracts');
mkdir($extractsDir, 0755, true);
}
// Récupérer les Stats à traiter
$statsRepo = $this->entityManager->getRepository(Stats::class);
if ($inseeCode) {
$io->note(sprintf('Traitement du code INSEE spécifique: %s', $inseeCode));
$allStats = $statsRepo->findBy(['zone' => $inseeCode]);
if (empty($allStats)) {
$io->error(sprintf('Aucune ville trouvée avec le code INSEE %s', $inseeCode));
return Command::FAILURE;
}
} else {
$io->note('Traitement de toutes les villes');
$criteria = [];
$orderBy = ['id' => 'ASC'];
$limitValue = $limit ? (int)$limit : null;
$allStats = $statsRepo->findBy($criteria, $orderBy, $limitValue);
}
$totalCount = count($allStats);
$existingCount = 0;
$createdCount = 0;
$errorCount = 0;
$io->progressStart($totalCount);
// Pour chaque Stats, extraire les données si nécessaire
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode) {
$io->progressAdvance();
continue;
}
$polygonFile = $polygonsDir . '/commune_' . $inseeCode . '.poly';
$extractPbfFile = $extractsDir . '/commune_' . $inseeCode . '.osm.pbf';
$extractJsonFile = $extractsDir . '/commune_' . $inseeCode . '.json';
// Vérifier si le polygone existe
if (!file_exists($polygonFile)) {
$io->debug(sprintf('Polygone manquant pour %s', $inseeCode));
$errorCount++;
$io->progressAdvance();
continue;
}
// Vérifier si l'extraction JSON existe déjà
if (file_exists($extractJsonFile) && !$force) {
$existingCount++;
$io->progressAdvance();
continue;
}
try {
// Étape 1: Extraire les données de france-latest.osm.pbf vers un fichier PBF pour la zone
$extractCommand = 'osmium extract -p ' . $polygonFile . ' ' . $francePbfFile . ' -o ' . $extractPbfFile;
$outputLines = [];
$returnVar = 0;
exec($extractCommand, $outputLines, $returnVar);
if ($returnVar !== 0 || !file_exists($extractPbfFile)) {
$io->debug(sprintf('Erreur lors de l\'extraction PBF pour %s: %s', $inseeCode, implode("\n", $outputLines)));
$errorCount++;
$io->progressAdvance();
continue;
}
// Étape 2: Convertir le fichier PBF en JSON
$exportCommand = 'osmium export ' . $extractPbfFile . ' -f json -o ' . $extractJsonFile;
$outputLines = [];
$returnVar = 0;
exec($exportCommand, $outputLines, $returnVar);
if ($returnVar === 0 && file_exists($extractJsonFile)) {
$createdCount++;
} else {
$io->debug(sprintf('Erreur lors de l\'export JSON pour %s: %s', $inseeCode, implode("\n", $outputLines)));
$errorCount++;
}
// Supprimer le fichier PBF intermédiaire pour économiser de l'espace
if (!$keepPbf && file_exists($extractPbfFile)) {
unlink($extractPbfFile);
}
} catch (\Exception $e) {
$errorCount++;
$io->debug(sprintf('Exception pour %s: %s', $inseeCode, $e->getMessage()));
}
$io->progressAdvance();
}
$io->progressFinish();
$io->success(sprintf(
"Extraction des zones INSEE terminée : %d extractions créées, %d déjà existantes, %d erreurs sur un total de %d communes.",
$createdCount,
$existingCount,
$errorCount,
$totalCount
));
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use App\Entity\CityFollowUp;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:import-cityfollowup-ctc',
description: 'Importe les CityFollowUp à partir du JSON DailyStats d\'une ville (Complète tes commerces)'
)]
class ImportCityFollowupFromCTCCommand extends Command
{
public function __construct(private EntityManagerInterface $entityManager)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('insee_code', InputArgument::REQUIRED, 'Code INSEE de la ville')
->addOption('url', null, InputOption::VALUE_OPTIONAL, 'URL CTC de la ville (sinon auto-déduit)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simule l\'import sans rien écrire');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$insee = $input->getArgument('insee_code');
$url = $input->getOption('url');
$dryRun = $input->getOption('dry-run');
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee]);
if (!$stats) {
$io->error("Aucune stats trouvée pour le code INSEE $insee");
return Command::FAILURE;
}
if (!$url) {
$url = $stats->getCTCurlBase() . '_dailystats.json';
}
$io->title("Import des CityFollowUp depuis $url");
// --- Gestion explicite des erreurs HTTP (404, etc.) ---
$context = stream_context_create([
'http' => [
'ignore_errors' => true,
'timeout' => 10,
]
]);
$json = @file_get_contents($url, false, $context);
$http_response_header = $http_response_header ?? [];
$httpCode = null;
foreach ($http_response_header as $header) {
if (preg_match('#^HTTP/\\d+\\.\\d+ (\\d{3})#', $header, $m)) {
$httpCode = (int)$m[1];
break;
}
}
if ($json === false || $httpCode === 404) {
$io->error("Impossible de télécharger le JSON DailyStats depuis $url (erreur HTTP $httpCode)");
return Command::FAILURE;
}
$data = json_decode($json, true);
if (!is_array($data)) {
$io->error("Le JSON n'est pas un tableau valide");
return Command::FAILURE;
}
$types = [
'name' => ['field' => 'no_name', 'label' => 'name'],
'hours' => ['field' => 'no_hours', 'label' => 'hours'],
'website' => ['field' => 'no_website', 'label' => 'website'],
'address' => ['field' => 'no_address', 'label' => 'address'],
'siret' => ['field' => 'no_siret', 'label' => 'siret'],
];
$created = 0;
$skipped = 0;
$createdEntities = [];
foreach ($data as $row) {
$date = isset($row['date']) ? new \DateTime($row['date']) : null;
if (!$date) continue;
$total = $row['total'] ?? null;
if (!$total) continue;
foreach ($types as $type => $info) {
$field = $info['field'];
if (!isset($row[$field])) continue;
$measure = $total - $row[$field]; // nombre d'objets complets pour ce champ
$name = $type . '_count';
// Vérifier doublon (même stats, même nom, même date)
$existing = $this->entityManager->getRepository(CityFollowUp::class)->findOneBy([
'stats' => $stats,
'name' => $name,
'date' => $date,
]);
if ($existing) {
$skipped++;
continue;
}
$cfu = new CityFollowUp();
$cfu->setStats($stats)
->setName($name)
->setDate($date)
->setMeasure($measure);
if (!$dryRun) {
$this->entityManager->persist($cfu);
$createdEntities[] = $cfu;
}
$created++;
}
}
if (!$dryRun) {
$this->entityManager->flush();
// Vérification explicite du lien
foreach ($createdEntities as $cfu) {
if (!$cfu->getStats() || $cfu->getStats()->getId() !== $stats->getId()) {
$io->warning('CityFollowUp non lié correctement à Stats ID ' . $stats->getId() . ' (ID CityFollowUp: ' . $cfu->getId() . ')');
}
}
}
$io->success("$created CityFollowUp créés, $skipped doublons ignorés.");
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,235 @@
<?php
namespace App\Command;
use App\Entity\CityFollowUp;
use App\Entity\Stats;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Finder\Finder;
#[AsCommand(
name: 'app:import-cityfollowup-csv',
description: 'Importe les mesures thématiques depuis un fichier CSV pour une ville donnée'
)]
class ImportCityFollowupFromCsvCommand extends Command
{
private const CSV_DIR = '/home/poule/encrypted/stockage-syncable/www/development/html/osm-commerce-sf/counting_osm_objects/test_results';
public function __construct(
private EntityManagerInterface $entityManager
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('insee', InputArgument::REQUIRED, 'Code INSEE de la ville')
->addArgument('theme', InputArgument::REQUIRED, 'Thématique à importer (ex: borne-de-recharge, defibrillator)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simuler l\'import sans modifier la base de données');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$insee = $input->getArgument('insee');
$theme = $input->getArgument('theme');
$dryRun = $input->getOption('dry-run');
// Vérifier que la ville existe
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee]);
if (!$stats) {
$io->error("Aucune ville trouvée avec le code INSEE $insee.");
return Command::FAILURE;
}
$io->title("Import des mesures thématiques pour {$stats->getName()} (INSEE: $insee) - Thème: $theme");
// Trouver le fichier CSV correspondant
$csvPath = $this->findCsvFile($insee, $theme);
if (!$csvPath) {
$io->error("Aucun fichier CSV trouvé pour la ville $insee et la thématique $theme.");
return Command::FAILURE;
}
$io->info("Fichier CSV trouvé: $csvPath");
// Lire le fichier CSV
$csvData = $this->readCsvFile($csvPath);
if (empty($csvData)) {
$io->error("Le fichier CSV est vide ou mal formaté.");
return Command::FAILURE;
}
$io->info(sprintf("Nombre de lignes dans le CSV: %d", count($csvData)));
// Récupérer les mesures existantes pour cette ville et cette thématique
$existingMeasures = $this->getExistingMeasures($stats, $theme);
$io->info(sprintf("Nombre de mesures existantes en base: %d", count($existingMeasures)));
// Comparer et importer les nouvelles mesures
$imported = 0;
$skipped = 0;
$zeroSkipped = 0;
foreach ($csvData as $row) {
// Ignorer les lignes avec des valeurs manquantes
if (empty($row['date']) || empty($row['nombre_total']) || $row['nombre_total'] === '') {
$skipped++;
continue;
}
// Convertir la date
try {
$date = new \DateTime($row['date']);
} catch (\Exception $e) {
$io->warning("Date invalide: {$row['date']}");
$skipped++;
continue;
}
// Ignorer les mesures qui valent 0
if ((float)$row['nombre_total'] === 0.0) {
$zeroSkipped++;
continue;
}
// Vérifier si la mesure existe déjà
$dateStr = $date->format('Y-m-d');
if (isset($existingMeasures[$dateStr])) {
$skipped++;
continue;
}
// Créer une nouvelle mesure pour le nombre total
if (!$dryRun) {
$followUp = new CityFollowUp();
$followUp->setName($this->convertThemeToCityFollowUpName($theme) . '_count')
->setMeasure((float)$row['nombre_total'])
->setDate($date)
->setStats($stats);
$this->entityManager->persist($followUp);
// Créer une mesure pour la complétion si disponible
if (isset($row['pourcentage_completion']) && $row['pourcentage_completion'] !== '') {
$followUpCompletion = new CityFollowUp();
$followUpCompletion->setName($this->convertThemeToCityFollowUpName($theme) . '_completion')
->setMeasure((float)$row['pourcentage_completion'])
->setDate($date)
->setStats($stats);
$this->entityManager->persist($followUpCompletion);
}
$imported++;
// Flush toutes les 50 entités pour éviter de surcharger la mémoire
if ($imported % 50 === 0) {
$this->entityManager->flush();
$this->entityManager->clear(CityFollowUp::class);
}
} else {
$imported++;
}
}
// Flush final
if (!$dryRun && $imported > 0) {
$this->entityManager->flush();
}
if ($dryRun) {
$io->success("Simulation terminée: $imported mesures seraient importées, $skipped mesures ignorées (déjà existantes), $zeroSkipped mesures ignorées (valeur zéro).");
} else {
$io->success("Import terminé: $imported mesures importées, $skipped mesures ignorées (déjà existantes), $zeroSkipped mesures ignorées (valeur zéro).");
}
return Command::SUCCESS;
}
/**
* Trouve le fichier CSV correspondant à la ville et à la thématique
*/
private function findCsvFile(string $insee, string $theme): ?string
{
$finder = new Finder();
$finder->files()
->in(self::CSV_DIR)
->name("commune_{$insee}_{$theme}.csv");
if (!$finder->hasResults()) {
return null;
}
foreach ($finder as $file) {
return $file->getRealPath();
}
return null;
}
/**
* Lit le fichier CSV et retourne un tableau de données
*/
private function readCsvFile(string $path): array
{
$data = [];
if (($handle = fopen($path, "r")) !== false) {
// Lire l'en-tête
$header = fgetcsv($handle, 1000, ",");
if ($header === false) {
return [];
}
// Lire les données
while (($row = fgetcsv($handle, 1000, ",")) !== false) {
$rowData = [];
foreach ($header as $i => $key) {
$rowData[$key] = $row[$i] ?? '';
}
$data[] = $rowData;
}
fclose($handle);
}
return $data;
}
/**
* Récupère les mesures existantes pour cette ville et cette thématique
*/
private function getExistingMeasures(Stats $stats, string $theme): array
{
$themeName = $this->convertThemeToCityFollowUpName($theme);
$existingMeasures = [];
foreach ($stats->getCityFollowUps() as $followUp) {
if ($followUp->getName() === $themeName . '_count') {
$date = $followUp->getDate()->format('Y-m-d');
$existingMeasures[$date] = $followUp;
}
}
return $existingMeasures;
}
/**
* Convertit le nom de thématique du CSV en nom utilisé dans CityFollowUp
*/
private function convertThemeToCityFollowUpName(string $theme): string
{
$themeMapping = [
'borne-de-recharge' => 'charging_station',
'defibrillator' => 'defibrillator',
// Ajouter d'autres mappings si nécessaire
];
return $themeMapping[$theme] ?? $theme;
}
}

View file

@ -0,0 +1,203 @@
<?php
namespace App\Command;
use App\Entity\Demande;
use App\Entity\Place;
use App\Repository\DemandeRepository;
use App\Repository\PlaceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:link-demandes-places',
description: 'Link Demandes to Places based on name similarity',
)]
class LinkDemandesPlacesCommand extends Command
{
private EntityManagerInterface $entityManager;
private DemandeRepository $demandeRepository;
private PlaceRepository $placeRepository;
public function __construct(
EntityManagerInterface $entityManager,
DemandeRepository $demandeRepository,
PlaceRepository $placeRepository
) {
parent::__construct();
$this->entityManager = $entityManager;
$this->demandeRepository = $demandeRepository;
$this->placeRepository = $placeRepository;
}
protected function configure(): void
{
$this
->addOption('threshold', null, InputOption::VALUE_REQUIRED, 'Similarity threshold (0-100)', 70)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show matches without linking');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Linking Demandes to Places based on name similarity');
$threshold = (int) $input->getOption('threshold');
$dryRun = $input->getOption('dry-run');
if ($threshold < 0 || $threshold > 100) {
$io->error('Threshold must be between 0 and 100');
return Command::FAILURE;
}
// Find all Demandes without linked Places
$demandesWithoutPlace = $this->demandeRepository->createQueryBuilder('d')
->where('d.place IS NULL')
->getQuery()
->getResult();
if (empty($demandesWithoutPlace)) {
$io->warning('No Demandes without linked Places found.');
return Command::SUCCESS;
}
$io->info(sprintf('Found %d Demandes without linked Places.', count($demandesWithoutPlace)));
// Process each Demande
$linkedCount = 0;
/** @var Demande $demande */
foreach ($demandesWithoutPlace as $demande) {
$query = $demande->getQuery();
$insee = $demande->getInsee();
if (empty($query)) {
continue;
}
// Find Places with similar names
$places = $this->findSimilarPlaces($query, $insee);
if (empty($places)) {
continue;
}
// Find the best match
$bestMatch = null;
$bestSimilarity = 0;
foreach ($places as $place) {
$similarity = $this->calculateSimilarity($query, $place->getName());
if ($similarity > $bestSimilarity) {
$bestSimilarity = $similarity;
$bestMatch = $place;
}
}
// If similarity is above threshold, link the Demande to the Place
if ($bestMatch && $bestSimilarity >= $threshold) {
$io->text(sprintf(
'Match found: "%s" (Demande) -> "%s" (Place) with similarity %d%%',
$query,
$bestMatch->getName(),
$bestSimilarity
));
if (!$dryRun) {
$demande->setPlace($bestMatch);
$demande->setPlaceUuid($bestMatch->getUuidForUrl());
$demande->setStatus('linked_to_place');
$this->entityManager->persist($demande);
$linkedCount++;
}
}
}
if (!$dryRun && $linkedCount > 0) {
$this->entityManager->flush();
$io->success(sprintf('Linked %d Demandes to Places.', $linkedCount));
} elseif ($dryRun) {
$io->info('Dry run completed. No changes were made.');
} else {
$io->info('No Demandes were linked to Places.');
}
return Command::SUCCESS;
}
/**
* Find Places with names similar to the query
*/
private function findSimilarPlaces(string $query, ?int $insee): array
{
$queryBuilder = $this->placeRepository->createQueryBuilder('p')
->where('p.name IS NOT NULL');
// If INSEE code is available, filter by Stats zone
if ($insee !== null) {
$queryBuilder
->join('p.stats', 's')
->andWhere('s.zone = :insee')
->setParameter('insee', (string) $insee);
}
// Use LIKE for initial filtering to reduce the number of results
$queryBuilder
->andWhere('p.name LIKE :query')
->setParameter('query', '%' . $this->sanitizeForLike($query) . '%');
return $queryBuilder->getQuery()->getResult();
}
/**
* Calculate similarity between two strings (0-100)
*/
private function calculateSimilarity(string $str1, string $str2): int
{
// Normalize strings for comparison
$str1 = $this->normalizeString($str1);
$str2 = $this->normalizeString($str2);
// If either string is empty after normalization, return 0
if (empty($str1) || empty($str2)) {
return 0;
}
// Calculate Levenshtein distance
$levenshtein = levenshtein($str1, $str2);
$maxLength = max(strlen($str1), strlen($str2));
// Convert to similarity percentage (0-100)
$similarity = (1 - $levenshtein / $maxLength) * 100;
return (int) $similarity;
}
/**
* Normalize a string for comparison
*/
private function normalizeString(string $str): string
{
// Convert to lowercase
$str = mb_strtolower($str);
// Remove accents
$str = transliterator_transliterate('Any-Latin; Latin-ASCII', $str);
// Remove special characters and extra spaces
$str = preg_replace('/[^a-z0-9\s]/', '', $str);
$str = preg_replace('/\s+/', ' ', $str);
return trim($str);
}
/**
* Sanitize a string for use in LIKE queries
*/
private function sanitizeForLike(string $str): string
{
return str_replace(['%', '_'], ['\%', '\_'], $str);
}
}

View file

@ -0,0 +1,116 @@
<?php
namespace App\Command;
use App\Entity\Demande;
use App\Entity\Place;
use App\Repository\DemandeRepository;
use App\Repository\PlaceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:link-demandes-places-osm',
description: 'Link Demandes to Places based on matching OSM type and ID',
)]
class LinkDemandesPlacesOsmCommand extends Command
{
private EntityManagerInterface $entityManager;
private DemandeRepository $demandeRepository;
private PlaceRepository $placeRepository;
public function __construct(
EntityManagerInterface $entityManager,
DemandeRepository $demandeRepository,
PlaceRepository $placeRepository
) {
parent::__construct();
$this->entityManager = $entityManager;
$this->demandeRepository = $demandeRepository;
$this->placeRepository = $placeRepository;
}
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show matches without linking');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Linking Demandes to Places based on matching OSM type and ID');
$dryRun = $input->getOption('dry-run');
// Find all Demandes without a UUID but with OSM type and ID
$demandesWithoutUuid = $this->demandeRepository->createQueryBuilder('d')
->where('d.placeUuid IS NULL')
->andWhere('d.osmObjectType IS NOT NULL')
->andWhere('d.osmId IS NOT NULL')
->getQuery()
->getResult();
if (empty($demandesWithoutUuid)) {
$io->warning('No Demandes without UUID but with OSM type and ID found.');
return Command::SUCCESS;
}
$io->info(sprintf('Found %d Demandes without UUID but with OSM type and ID.', count($demandesWithoutUuid)));
// Process each Demande
$linkedCount = 0;
/** @var Demande $demande */
foreach ($demandesWithoutUuid as $demande) {
$osmType = $demande->getOsmObjectType();
$osmId = $demande->getOsmId();
// Find Place with matching OSM type and ID
$place = $this->placeRepository->findOneBy([
'osm_kind' => $osmType,
'osmId' => $osmId
]);
if ($place) {
$io->text(sprintf(
'Match found: Demande #%d -> Place #%d (OSM %s/%d)',
$demande->getId(),
$place->getId(),
$osmType,
$osmId
));
if (!$dryRun) {
$demande->setPlace($place);
$demande->setPlaceUuid($place->getUuidForUrl());
$demande->setStatus('linked_to_place');
$this->entityManager->persist($demande);
$linkedCount++;
}
} else {
$io->text(sprintf(
'No matching Place found for Demande #%d (OSM %s/%d)',
$demande->getId(),
$osmType,
$osmId
));
}
}
if (!$dryRun && $linkedCount > 0) {
$this->entityManager->flush();
$io->success(sprintf('Linked %d Demandes to Places based on OSM type and ID.', $linkedCount));
} elseif ($dryRun) {
$io->info('Dry run completed. No changes were made.');
} else {
$io->info('No Demandes were linked to Places.');
}
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,149 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use App\Service\Motocultrice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:process-insee-extracts',
description: 'Traite les extraits JSON des zones INSEE pour calculer les mesures de thèmes',
)]
class ProcessInseeExtractsCommand extends Command
{
private EntityManagerInterface $entityManager;
private Motocultrice $motocultrice;
public function __construct(EntityManagerInterface $entityManager, Motocultrice $motocultrice)
{
parent::__construct();
$this->entityManager = $entityManager;
$this->motocultrice = $motocultrice;
}
protected function configure(): void
{
$this
->addArgument('insee-code', InputArgument::OPTIONAL, 'Code INSEE spécifique à traiter')
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limite le nombre de villes à traiter', null)
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force le traitement même si déjà effectué')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$inseeCode = $input->getArgument('insee-code');
$limit = $input->getOption('limit');
$force = $input->getOption('force');
// Vérifier que le dossier des extractions existe
$extractsDir = __DIR__ . '/../../insee_extracts';
if (!is_dir($extractsDir)) {
$io->error('Le dossier des extractions n\'existe pas. Veuillez d\'abord exécuter la commande app:extract-insee-zones.');
return Command::FAILURE;
}
// Récupérer les Stats à traiter
$statsRepo = $this->entityManager->getRepository(Stats::class);
if ($inseeCode) {
$io->note(sprintf('Traitement du code INSEE spécifique: %s', $inseeCode));
$allStats = $statsRepo->findBy(['zone' => $inseeCode]);
if (empty($allStats)) {
$io->error(sprintf('Aucune ville trouvée avec le code INSEE %s', $inseeCode));
return Command::FAILURE;
}
} else {
$io->note('Traitement de toutes les villes');
$criteria = [];
$orderBy = ['id' => 'ASC'];
$limitValue = $limit ? (int)$limit : null;
$allStats = $statsRepo->findBy($criteria, $orderBy, $limitValue);
}
$totalCount = count($allStats);
$processedCount = 0;
$skippedCount = 0;
$errorCount = 0;
$io->progressStart($totalCount);
// Pour chaque Stats, traiter les données si nécessaire
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode) {
$io->progressAdvance();
continue;
}
$extractJsonFile = $extractsDir . '/commune_' . $inseeCode . '.json';
// Vérifier si l'extraction JSON existe
if (!file_exists($extractJsonFile)) {
$io->debug(sprintf('Fichier JSON manquant pour %s', $inseeCode));
$errorCount++;
$io->progressAdvance();
continue;
}
// Vérifier si le traitement a déjà été effectué
if (!$force && $stat->getDateLabourageDone() !== null) {
$skippedCount++;
$io->progressAdvance();
continue;
}
try {
// Utiliser la Motocultrice pour traiter les données
$result = $this->motocultrice->labourer($inseeCode);
if ($result) {
// Mettre à jour la date de labourage
$stat->setDateLabourageDone(new \DateTime());
$this->entityManager->persist($stat);
$processedCount++;
// Flush tous les 10 objets pour éviter de surcharger la mémoire
if ($processedCount % 10 === 0) {
$this->entityManager->flush();
$io->debug(sprintf('Flush après %d traitements', $processedCount));
}
} else {
$io->debug(sprintf('Échec du traitement pour %s', $inseeCode));
$errorCount++;
}
} catch (\Exception $e) {
$errorCount++;
$io->debug(sprintf('Exception pour %s: %s', $inseeCode, $e->getMessage()));
}
$io->progressAdvance();
}
// Flush les derniers objets
$this->entityManager->flush();
$io->progressFinish();
$io->success(sprintf(
"Traitement des extractions INSEE terminé : %d communes traitées, %d ignorées, %d erreurs sur un total de %d communes.",
$processedCount,
$skippedCount,
$errorCount,
$totalCount
));
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,179 @@
<?php
namespace App\Command;
use App\Entity\Place;
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\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:process-labourage-queue',
description: 'Traite la file d\'attente de labourage différé des villes (cron)'
)]
class ProcessLabourageQueueCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice,
private FollowUpService $followUpService
)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Sélectionner la Stats à traiter (date_labourage_requested la plus ancienne, non traitée ou à refaire)
$stats = $this->entityManager->getRepository(Stats::class)
->createQueryBuilder('s')
->where('s.date_labourage_requested IS NOT NULL')
->andWhere('s.date_labourage_done IS NULL OR s.date_labourage_done < s.date_labourage_requested')
->andWhere('s.zone != :global_zone')
->setParameter('global_zone', '00000')
->orderBy('s.date_labourage_requested', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
if (!$stats) {
// 1. Villes jamais labourées (date_labourage_done NULL, hors 00000)
$stats = $this->entityManager->getRepository(Stats::class)
->createQueryBuilder('s')
->where('s.zone != :global_zone')
->andWhere('s.date_labourage_done IS NULL')
->setParameter('global_zone', '00000')
->orderBy('s.date_modified', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
if ($stats) {
$io->note('Aucune ville en attente, on traite en priorité une ville jamais labourée : ' . $stats->getName() . ' (' . $stats->getZone() . ')');
$stats->setDateLabourageRequested(new \DateTime());
$this->entityManager->persist($stats);
$this->entityManager->flush();
} else {
// 2. Ville la plus anciennement modifiée (hors 00000)
$stats = $this->entityManager->getRepository(Stats::class)
->createQueryBuilder('s')
->where('s.zone != :global_zone')
->setParameter('global_zone', '00000')
->orderBy('s.date_modified', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
if (!$stats) {
$io->success('Aucune ville à traiter.');
return Command::SUCCESS;
}
$io->note('Aucune ville en attente, on demande le labourage de la ville la plus anciennement modifiée : ' . $stats->getName() . ' (' . $stats->getZone() . ')');
$stats->setDateLabourageRequested(new \DateTime());
$this->entityManager->persist($stats);
$this->entityManager->flush();
}
}
$io->section('Traitement de la ville : ' . $stats->getName() . ' (' . $stats->getZone() . ')');
// Vérifier la RAM disponible (>= 1 Go)
$meminfo = @file_get_contents('/proc/meminfo');
$ram_ok = false;
if ($meminfo !== false && preg_match('/^MemAvailable:\s+(\d+)/m', $meminfo, $matches)) {
$mem_kb = (int)$matches[1];
$ram_ok = ($mem_kb >= 1024 * 1024); // 1 Go
}
if (!$ram_ok) {
$io->warning('RAM insuffisante, on attend le prochain cron.');
return Command::SUCCESS;
}
// Effectuer le labourage complet (reprendre la logique de création/màj des objets Place)
$io->info('RAM suffisante, lancement du labourage...');
$places_overpass = $this->motocultrice->labourer($stats->getZone());
$processedCount = 0;
$updatedCount = 0;
$existingPlacesQuery = $this->entityManager->getRepository(Place::class)
->createQueryBuilder('p')
->select('p.osmId, p.osm_kind, p.id')
->where('p.zip_code = :zip_code')
->setParameter('zip_code', $stats->getZone())
->getQuery();
$existingPlacesResult = $existingPlacesQuery->getResult();
$placesByOsmKey = [];
foreach ($existingPlacesResult as $placeData) {
$osmKey = $placeData['osm_kind'] . '_' . $placeData['osmId'];
$placesByOsmKey[$osmKey] = $placeData['id'];
}
foreach ($places_overpass as $placeData) {
$osmKey = $placeData['type'] . '_' . $placeData['id'];
$existingPlaceId = $placesByOsmKey[$osmKey] ?? null;
if (!$existingPlaceId) {
$place = new Place();
$place->setOsmId($placeData['id'])
->setOsmKind($placeData['type'])
->setZipCode($stats->getZone())
->setUuidForUrl($this->motocultrice->uuid_create())
->setModifiedDate(new \DateTime())
->setStats($stats)
->setDead(false)
->setOptedOut(false)
->setMainTag($this->motocultrice->find_main_tag($placeData['tags']) ?? '')
->setStreet($this->motocultrice->find_street($placeData['tags']) ?? '')
->setHousenumber($this->motocultrice->find_housenumber($placeData['tags']) ?? '')
->setSiret($this->motocultrice->find_siret($placeData['tags']) ?? '')
->setAskedHumainsSupport(false)
->setLastContactAttemptDate(null)
->setPlaceCount(0);
$place->update_place_from_overpass_data($placeData);
$this->entityManager->persist($place);
$stats->addPlace($place);
$processedCount++;
} else {
$existingPlace = $this->entityManager->getRepository(Place::class)->find($existingPlaceId);
if ($existingPlace) {
$existingPlace->setDead(false);
$existingPlace->update_place_from_overpass_data($placeData);
$stats->addPlace($existingPlace);
$this->entityManager->persist($existingPlace);
$updatedCount++;
}
}
}
$stats->setDateLabourageDone(new \DateTime());
// Update city name from API if available
$apiCityName = $this->motocultrice->get_city_osm_from_zip_code($stats->getZone());
if ($apiCityName && $apiCityName !== $stats->getName()) {
$io->info(sprintf('Updating city name from "%s" to "%s" based on API data', $stats->getName(), $apiCityName));
$stats->setName($apiCityName);
}
$io->info('Récupération des followups de cette ville...');
// $this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager);
// update completion
$stats->computeCompletionPercent();
$followups = $stats->getCityFollowUps();
if ($followups) {
$lastFollowUp = $followups[count($followups) - 1];
if ($lastFollowUp) {
$name = $lastFollowUp->getName();
$io->success("Followup le plus récent : $name : " . $lastFollowUp->getDate()->format('d/m/Y') . ' : ' . $lastFollowUp->getMeasure());
}
}
$io->info('Pourcentage de complétion: ' . $stats->getCompletionPercent());
$this->entityManager->persist($stats);
$this->entityManager->flush();
$io->success("Labourage terminé : $processedCount nouveaux lieux, $updatedCount lieux mis à jour.");
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,109 @@
<?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\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:regenerate-followups',
description: 'Régénère les followups pour une ville spécifique avec les nouveaux critères',
)]
class RegenerateFollowupsCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private FollowUpService $followUpService,
private Motocultrice $motocultrice
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('insee_code', InputArgument::REQUIRED, 'Code INSEE de la ville')
->addOption('delete-existing', 'd', InputOption::VALUE_NONE, 'Supprimer les followups existants avant de régénérer')
->addOption('disable-cleanup', 'c', InputOption::VALUE_NONE, 'Désactiver le nettoyage des followups redondants')
->setHelp('Cette commande régénère les followups pour une ville avec les nouveaux critères de completion.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$inseeCode = $input->getArgument('insee_code');
$deleteExisting = $input->getOption('delete-existing');
$disableCleanup = $input->getOption('disable-cleanup');
$io->title('Régénération des followups pour ' . $inseeCode);
// Vérifier que la ville existe
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $inseeCode]);
if (!$stats) {
$io->error('Aucune stats trouvée pour le code INSEE ' . $inseeCode);
return Command::FAILURE;
}
$io->info('Ville trouvée : ' . $stats->getName());
// Supprimer les followups existants si demandé
if ($deleteExisting) {
$io->section('Suppression des followups existants');
$followups = $stats->getCityFollowUps();
$count = count($followups);
foreach ($followups as $followup) {
$this->entityManager->remove($followup);
}
$this->entityManager->flush();
$io->success($count . ' followups supprimés');
}
// Régénérer les followups
$io->section('Régénération des followups');
$io->note('Utilisation des nouveaux critères de completion plus réalistes');
try {
$this->followUpService->generateCityFollowUps(
$stats,
$this->motocultrice,
$this->entityManager,
$disableCleanup
);
$io->success('Followups régénérés avec succès');
// Afficher les résultats
$newFollowups = $stats->getCityFollowUps();
$io->section('Résultats');
$table = [];
foreach ($newFollowups as $followup) {
$table[] = [
$followup->getName(),
$followup->getMeasure(),
$followup->getDate()->format('Y-m-d H:i:s')
];
}
$io->table(['Nom', 'Valeur', 'Date'], $table);
} catch (\Exception $e) {
$io->error('Erreur lors de la régénération : ' . $e->getMessage());
return Command::FAILURE;
}
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace App\Command;
use App\Entity\Place;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:remove-duplicate-places',
description: 'Remove duplicate Places based on OSM type and ID',
)]
class RemoveDuplicatePlacesCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(
EntityManagerInterface $entityManager
) {
parent::__construct();
$this->entityManager = $entityManager;
}
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show duplicates without removing them')
->addOption('zip-code', null, InputOption::VALUE_REQUIRED, 'Process only places with this ZIP code');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Removing duplicate Places based on OSM type and ID');
$dryRun = $input->getOption('dry-run');
$zipCode = $input->getOption('zip-code');
// Build the query to get all places sorted by OSM type and ID
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('p')
->from(Place::class, 'p')
->orderBy('p.osm_kind', 'ASC')
->addOrderBy('p.osmId', 'ASC');
// Add ZIP code filter if provided
if ($zipCode) {
$queryBuilder->andWhere('p.zip_code = :zip_code')
->setParameter('zip_code', $zipCode);
$io->info(sprintf('Processing only places with ZIP code: %s', $zipCode));
}
$places = $queryBuilder->getQuery()->getResult();
if (empty($places)) {
$io->warning('No places found.');
return Command::SUCCESS;
}
$io->info(sprintf('Found %d places.', count($places)));
// Find duplicates by comparing consecutive places
$duplicates = [];
$previousPlace = null;
$duplicateCount = 0;
foreach ($places as $place) {
if ($previousPlace !== null &&
$previousPlace->getOsmKind() === $place->getOsmKind() &&
$previousPlace->getOsmId() === $place->getOsmId()) {
// This is a duplicate
$duplicates[] = [
'keep' => $previousPlace,
'remove' => $place
];
$duplicateCount++;
}
$previousPlace = $place;
}
if (empty($duplicates)) {
$io->success('No duplicate places found.');
return Command::SUCCESS;
}
$io->info(sprintf('Found %d duplicate places.', $duplicateCount));
// Process duplicates
$removedCount = 0;
foreach ($duplicates as $duplicate) {
$keep = $duplicate['keep'];
$remove = $duplicate['remove'];
$io->text(sprintf(
'Duplicate found: Keep #%d, Remove #%d (OSM %s/%s)',
$keep->getId(),
$remove->getId(),
$keep->getOsmKind(),
$keep->getOsmId()
));
if (!$dryRun) {
// Remove the duplicate
$this->entityManager->remove($remove);
$removedCount++;
}
}
if (!$dryRun && $removedCount > 0) {
$this->entityManager->flush();
$io->success(sprintf('Removed %d duplicate places.', $removedCount));
} elseif ($dryRun) {
$io->info('Dry run completed. No places were removed.');
}
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:retrieve-city-polygons',
description: 'Récupère les polygones des villes selon leur zone donnée par le code INSEE',
)]
class RetrieveCityPolygonsCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function configure(): void
{
$this
->addArgument('insee-code', InputArgument::OPTIONAL, 'Code INSEE spécifique à traiter')
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limite le nombre de villes à traiter', null)
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force la récupération même si le polygone existe déjà')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$inseeCode = $input->getArgument('insee-code');
$limit = $input->getOption('limit');
$force = $input->getOption('force');
// Vérifier que le dossier polygons existe, sinon le créer
$polygonsDir = __DIR__ . '/../../counting_osm_objects/polygons';
if (!is_dir($polygonsDir)) {
$io->note('Création du dossier polygons');
mkdir($polygonsDir, 0755, true);
}
// Récupérer les Stats à traiter
$statsRepo = $this->entityManager->getRepository(Stats::class);
if ($inseeCode) {
$io->note(sprintf('Traitement du code INSEE spécifique: %s', $inseeCode));
$allStats = $statsRepo->findBy(['zone' => $inseeCode]);
if (empty($allStats)) {
$io->error(sprintf('Aucune ville trouvée avec le code INSEE %s', $inseeCode));
return Command::FAILURE;
}
} else {
$io->note('Traitement de toutes les villes');
$criteria = [];
$orderBy = ['id' => 'ASC'];
$limitValue = $limit ? (int)$limit : null;
$allStats = $statsRepo->findBy($criteria, $orderBy, $limitValue);
}
$totalCount = count($allStats);
$existingCount = 0;
$createdCount = 0;
$errorCount = 0;
$io->progressStart($totalCount);
// Pour chaque Stats, récupérer le polygone si nécessaire
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode) {
$io->progressAdvance();
continue;
}
$polygonFile = $polygonsDir . '/commune_' . $inseeCode . '.poly';
// Vérifier si le polygone existe déjà
if (file_exists($polygonFile) && !$force) {
$existingCount++;
$io->progressAdvance();
continue;
}
try {
// Utiliser le script Python existant pour récupérer le polygone
$command = 'cd ' . __DIR__ . '/../../counting_osm_objects && python3 get_poly.py ' . $inseeCode;
$output = [];
$returnVar = 0;
exec($command, $output, $returnVar);
if ($returnVar === 0 && file_exists($polygonFile)) {
$createdCount++;
} else {
$errorCount++;
if ($output) {
$io->debug(sprintf('Erreur pour %s: %s', $inseeCode, implode("\n", $output)));
}
}
} catch (\Exception $e) {
$errorCount++;
$io->debug(sprintf('Exception pour %s: %s', $inseeCode, $e->getMessage()));
}
$io->progressAdvance();
}
$io->progressFinish();
$io->success(sprintf(
"Récupération des polygones terminée : %d polygones créés, %d déjà existants, %d erreurs sur un total de %d communes.",
$createdCount,
$existingCount,
$errorCount,
$totalCount
));
return Command::SUCCESS;
}
}

View file

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

Some files were not shown because too many files have changed in this diff Show more