From e3f8680472830203a7103b5287cbdcc4aafde33b Mon Sep 17 00:00:00 2001 From: Tykayn Date: Sun, 29 Jun 2025 18:32:24 +0200 Subject: [PATCH] suivi global --- src/Controller/FollowUpController.php | 214 ++++++++++++++++- src/Service/Motocultrice.php | 11 +- .../admin/followup_global_graph.html.twig | 105 ++++++++ templates/admin/followup_graph.html.twig | 16 +- templates/admin/stats.html.twig | 227 +++++++++++++++--- templates/public/nav.html.twig | 6 + 6 files changed, 531 insertions(+), 48 deletions(-) create mode 100644 templates/admin/followup_global_graph.html.twig diff --git a/src/Controller/FollowUpController.php b/src/Controller/FollowUpController.php index 5105e1b..44dd7b6 100644 --- a/src/Controller/FollowUpController.php +++ b/src/Controller/FollowUpController.php @@ -13,7 +13,7 @@ use Symfony\Component\Routing\Annotation\Route; class FollowUpController extends AbstractController { - #[Route('/admin/followup/{insee_code}/delete', name: 'admin_followup_delete')] + #[Route('/admin/followup/{insee_code}/delete', name: 'admin_followup_delete', requirements: ['insee_code' => '\\d+'])] public function deleteFollowups(string $insee_code, EntityManagerInterface $em): Response { $stats = $em->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); if (!$stats) { @@ -29,7 +29,7 @@ class FollowUpController extends AbstractController return $this->redirectToRoute('admin_followup_graph', ['insee_code' => $insee_code]); } - #[Route('/admin/followup/{insee_code}', name: 'admin_followup')] + #[Route('/admin/followup/{insee_code}', name: 'admin_followup', requirements: ['insee_code' => '\\d+'])] public function followup( string $insee_code, Motocultrice $motocultrice, @@ -93,6 +93,21 @@ class FollowUpController extends AbstractController 'label' => 'Écoles', 'objects' => array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'school') ], + 'police' => [ + 'label' => 'Commissariats', + 'objects' => array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'police') + ], + 'healthcare' => [ + 'label' => 'Lieux de santé', + 'objects' => array_filter($elements, function($el) { + return isset($el['tags']['healthcare']) + || ($el['tags']['amenity'] ?? null) === 'doctors' + || ($el['tags']['amenity'] ?? null) === 'pharmacy' + || ($el['tags']['amenity'] ?? null) === 'hospital' + || ($el['tags']['amenity'] ?? null) === 'clinic' + || ($el['tags']['amenity'] ?? null) === 'social_facility'; + }) + ], ]; $now = new \DateTime(); foreach ($types as $type => $data) { @@ -146,6 +161,19 @@ class FollowUpController extends AbstractController $completed = array_filter($data['objects'], function($el) { return !empty($el['tags']['ref:UAI'] ?? null) && !empty($el['tags']['isced:level'] ?? null) && !empty($el['tags']['school:FR'] ?? null); }); + } elseif ($type === 'police') { + $completed = array_filter($data['objects'], function($el) { + return !empty($el['tags']['phone'] ?? null) || !empty($el['tags']['website'] ?? null); + }); + } elseif ($type === 'healthcare') { + $completed = array_filter($data['objects'], function($el) { + $tags = $el['tags'] ?? []; + return !empty($tags['name'] ?? null) + || !empty($tags['contact:phone'] ?? null) + || !empty($tags['phone'] ?? null) + || !empty($tags['email'] ?? null) + || !empty($tags['contact:email'] ?? null); + }); } $completion = count($data['objects']) > 0 ? round(count($completed) / count($data['objects']) * 100) : 0; $followupCompletion = new CityFollowUp(); @@ -161,7 +189,7 @@ class FollowUpController extends AbstractController return $this->redirectToRoute('admin_followup_graph', ['insee_code' => $insee_code]); } - #[Route('/admin/followup/{insee_code}/graph', name: 'admin_followup_graph')] + #[Route('/admin/followup/{insee_code}/graph', name: 'admin_followup_graph', requirements: ['insee_code' => '\\d+'])] public function followupGraph( string $insee_code, EntityManagerInterface $em, @@ -219,6 +247,21 @@ class FollowUpController extends AbstractController 'label' => 'Écoles', 'objects' => array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'school') ], + 'police' => [ + 'label' => 'Commissariats', + 'objects' => array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'police') + ], + 'healthcare' => [ + 'label' => 'Lieux de santé', + 'objects' => array_filter($elements, function($el) { + return isset($el['tags']['healthcare']) + || ($el['tags']['amenity'] ?? null) === 'doctors' + || ($el['tags']['amenity'] ?? null) === 'pharmacy' + || ($el['tags']['amenity'] ?? null) === 'hospital' + || ($el['tags']['amenity'] ?? null) === 'clinic' + || ($el['tags']['amenity'] ?? null) === 'social_facility'; + }) + ], ]; $now = new \DateTime(); foreach ($types as $type => $data) { @@ -270,6 +313,19 @@ class FollowUpController extends AbstractController $completed = array_filter($data['objects'], function($el) { return !empty($el['tags']['ref:UAI'] ?? null) && !empty($el['tags']['isced:level'] ?? null) && !empty($el['tags']['school:FR'] ?? null); }); + } elseif ($type === 'police') { + $completed = array_filter($data['objects'], function($el) { + return !empty($el['tags']['phone'] ?? null) || !empty($el['tags']['website'] ?? null); + }); + } elseif ($type === 'healthcare') { + $completed = array_filter($data['objects'], function($el) { + $tags = $el['tags'] ?? []; + return !empty($tags['name'] ?? null) + || !empty($tags['contact:phone'] ?? null) + || !empty($tags['phone'] ?? null) + || !empty($tags['email'] ?? null) + || !empty($tags['contact:email'] ?? null); + }); } $completion = count($data['objects']) > 0 ? round(count($completed) / count($data['objects']) * 100) : 0; $followupCompletion = new CityFollowUp(); @@ -356,6 +412,21 @@ class FollowUpController extends AbstractController 'label' => 'Écoles', 'objects' => array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'school') ], + 'police' => [ + 'label' => 'Commissariats', + 'objects' => array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'police') + ], + 'healthcare' => [ + 'label' => 'Lieux de santé', + 'objects' => array_filter($elements, function($el) { + return isset($el['tags']['healthcare']) + || ($el['tags']['amenity'] ?? null) === 'doctors' + || ($el['tags']['amenity'] ?? null) === 'pharmacy' + || ($el['tags']['amenity'] ?? null) === 'hospital' + || ($el['tags']['amenity'] ?? null) === 'clinic' + || ($el['tags']['amenity'] ?? null) === 'social_facility'; + }) + ], ]; foreach ($types as $type => $data) { // Suivi du nombre @@ -406,6 +477,19 @@ class FollowUpController extends AbstractController $completed = array_filter($data['objects'], function($el) { return !empty($el['tags']['ref:UAI'] ?? null) && !empty($el['tags']['isced:level'] ?? null) && !empty($el['tags']['school:FR'] ?? null); }); + } elseif ($type === 'police') { + $completed = array_filter($data['objects'], function($el) { + return !empty($el['tags']['phone'] ?? null) || !empty($el['tags']['website'] ?? null); + }); + } elseif ($type === 'healthcare') { + $completed = array_filter($data['objects'], function($el) { + $tags = $el['tags'] ?? []; + return !empty($tags['name'] ?? null) + || !empty($tags['contact:phone'] ?? null) + || !empty($tags['phone'] ?? null) + || !empty($tags['email'] ?? null) + || !empty($tags['contact:email'] ?? null); + }); } $completion = count($data['objects']) > 0 ? round(count($completed) / count($data['objects']) * 100) : 0; $followupCompletion = new CityFollowUp(); @@ -434,4 +518,128 @@ class FollowUpController extends AbstractController $this->addFlash('success', 'Suivi généré pour toutes les villes.'); return $this->redirectToRoute('app_admin'); } + + #[Route('/admin/followup/global', name: 'admin_followup_global')] + public function followupGlobal(EntityManagerInterface $em) { + // Récupérer ou créer l'objet Stats global + $statsGlobal = $em->getRepository(Stats::class)->findOneBy(['zone' => '00000']); + if (!$statsGlobal) { + $statsGlobal = new Stats(); + $statsGlobal->setZone('00000'); + $statsGlobal->setName('toutes les villes'); + $em->persist($statsGlobal); + $em->flush(); + } + $now = new \DateTime(); + $themes = [ + 'fire_hydrant', 'charging_station', 'toilets', 'bus_stop', 'defibrillator', 'camera', 'recycling', 'substation', 'laboratory', 'school', 'police', 'healthcare', 'places' + ]; + $allStats = $em->getRepository(Stats::class)->findAll(); + // Exclure l'objet global + $allStats = array_filter($allStats, fn($s) => $s->getZone() !== '00000'); + $cityCount = count($allStats); + $globalCompletionSum = 0; + $globalCompletionCount = 0; + // Pour chaque thème, somme des counts et moyenne des completions + foreach ($themes as $theme) { + $sumCount = 0; + $sumCompletion = 0; + $nbCompletion = 0; + foreach ($allStats as $stats) { + $latestCount = null; + $latestCompletion = null; + foreach ($stats->getCityFollowUps() as $fu) { + if ($fu->getName() === $theme . '_count') { + if ($latestCount === null || $fu->getDate() > $latestCount->getDate()) { + $latestCount = $fu; + } + } + if ($fu->getName() === $theme . '_completion') { + if ($latestCompletion === null || $fu->getDate() > $latestCompletion->getDate()) { + $latestCompletion = $fu; + } + } + } + if ($latestCount) $sumCount += $latestCount->getMeasure(); + if ($latestCompletion) { + $sumCompletion += $latestCompletion->getMeasure(); + $nbCompletion++; + } + } + // Ajout du CityFollowUp global pour le count + $fuCount = new CityFollowUp(); + $fuCount->setName($theme . '_count') + ->setMeasure($sumCount) + ->setDate($now) + ->setStats($statsGlobal); + $em->persist($fuCount); + // Ajout du CityFollowUp global pour la complétion + $completionAvg = $nbCompletion > 0 ? round($sumCompletion / $nbCompletion, 1) : 0; + $fuCompletion = new CityFollowUp(); + $fuCompletion->setName($theme . '_completion') + ->setMeasure($completionAvg) + ->setDate($now) + ->setStats($statsGlobal); + $em->persist($fuCompletion); + // Pour la complétion globale moyenne + if ($theme !== 'places' && $nbCompletion > 0) { + $globalCompletionSum += $completionAvg; + $globalCompletionCount++; + } + } + // Suivi du nombre total de lieux (places_count) + $sumPlaces = 0; + foreach ($allStats as $stats) { + $sumPlaces += $stats->getPlacesCount() ?? 0; + } + $fuPlaces = new CityFollowUp(); + $fuPlaces->setName('places_count') + ->setMeasure($sumPlaces) + ->setDate($now) + ->setStats($statsGlobal); + $em->persist($fuPlaces); + // Suivi de la complétion globale moyenne + $globalCompletionAvg = $globalCompletionCount > 0 ? round($globalCompletionSum / $globalCompletionCount, 1) : 0; + $fuGlobalCompletion = new CityFollowUp(); + $fuGlobalCompletion->setName('global_completion_average') + ->setMeasure($globalCompletionAvg) + ->setDate($now) + ->setStats($statsGlobal); + $em->persist($fuGlobalCompletion); + // Suivi du nombre de villes + $fuCityCount = new CityFollowUp(); + $fuCityCount->setName('city_count') + ->setMeasure($cityCount) + ->setDate($now) + ->setStats($statsGlobal); + $em->persist($fuCityCount); + $em->flush(); + $this->addFlash('success', 'Suivi global généré pour toutes les villes.'); + return $this->redirectToRoute('admin_followup_global_graph'); + } + + #[Route('/admin/followup/global/graph', name: 'admin_followup_global_graph')] + public function followupGlobalGraph(EntityManagerInterface $em) { + $stats = $em->getRepository(Stats::class)->findOneBy(['zone' => '00000']); + if (!$stats) { + $this->addFlash('error', 'Aucun suivi global trouvé.'); + return $this->redirectToRoute('app_admin'); + } + $followups = $stats->getCityFollowUps(); + $followups = $followups->toArray(); + usort($followups, fn($a, $b) => $a->getDate() <=> $b->getDate()); + // Grouper par type + $series = []; + foreach ($followups as $fu) { + $series[$fu->getName()][] = [ + 'date' => $fu->getDate()->format('c'), + 'value' => $fu->getMeasure(), + 'name' => $fu->getName(), + ]; + } + return $this->render('admin/followup_global_graph.html.twig', [ + 'stats' => $stats, + 'series' => $series + ]); + } } \ No newline at end of file diff --git a/src/Service/Motocultrice.php b/src/Service/Motocultrice.php index 7313add..1561628 100644 --- a/src/Service/Motocultrice.php +++ b/src/Service/Motocultrice.php @@ -573,12 +573,19 @@ area["ref:INSEE"="$zone"]->.searchArea; nwr["man_made"="surveillance"](area.searchArea); nwr["amenity"="recycling"](area.searchArea); nwr["power"="substation"](area.searchArea); + nwr["healthcare"](area.searchArea); + nwr["amenity"="doctors"](area.searchArea); + nwr["amenity"="pharmacy"](area.searchArea); + nwr["amenity"="hospital"](area.searchArea); + nwr["amenity"="clinic"](area.searchArea); + nwr["amenity"="social_facility"](area.searchArea); nwr["healthcare"="laboratory"](area.searchArea); nwr["amenity"="school"](area.searchArea); + nwr["amenity"="police"](area.searchArea); ); -out body; +(._;>;); +out meta; >; -out skel qt; QUERY; } diff --git a/templates/admin/followup_global_graph.html.twig b/templates/admin/followup_global_graph.html.twig new file mode 100644 index 0000000..eebf0a4 --- /dev/null +++ b/templates/admin/followup_global_graph.html.twig @@ -0,0 +1,105 @@ +{% extends 'base.html.twig' %} +{% block title %}Suivi global de toutes les villes{% endblock %} +{% block body %} +
+

Suivi global de toutes les villes

+
+

Nombre de villes et complétion moyenne

+ +
+
+

Suivi par thématique

+
+ {% set themes = [ + 'fire_hydrant', 'charging_station', 'toilets', 'bus_stop', 'defibrillator', 'camera', 'recycling', 'substation', 'laboratory', 'school', 'police', 'healthcare', 'places' + ] %} + {% for theme in themes %} +
+
+
+
{{ theme|replace({'_': ' '})|title }}
+ + +
+
+
+ {% endfor %} +
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/followup_graph.html.twig b/templates/admin/followup_graph.html.twig index c1feb97..1d89910 100644 --- a/templates/admin/followup_graph.html.twig +++ b/templates/admin/followup_graph.html.twig @@ -5,10 +5,16 @@ {% block body %}

Suivi des objets OSM pour {{ stats.name }} ({{ stats.zone }})

-
+

Historique des objets suivis (nombre et complétion).

{% set type_labels = { @@ -21,7 +27,9 @@ 'recycling': 'Points de recyclage', 'substation': 'Sous-stations électriques', 'laboratory': "Laboratoires d'analyse", - 'school': 'Écoles' + 'school': 'Écoles', + 'police': 'Commissariats', + 'healthcare': 'Lieux de santé' } %} {% for type in type_labels|keys %}

{{ type_labels[type] }}

@@ -91,7 +99,9 @@ recycling: 'Points de recyclage', substation: 'Sous-stations électriques', laboratory: "Laboratoires d'analyse", - school: 'Écoles' + school: 'Écoles', + police: 'Commissariats', + healthcare: 'Lieux de santé' }; function formatDelta(val) { if (val === null) return '-'; diff --git a/templates/admin/stats.html.twig b/templates/admin/stats.html.twig index d9fbd14..d6cbe00 100644 --- a/templates/admin/stats.html.twig +++ b/templates/admin/stats.html.twig @@ -41,6 +41,27 @@ border-left: 4px solid #0dcaf0; background-color: #f8f9fa; } + .completion-badge { + display: inline-block; + width: 18px; + height: 18px; + border-radius: 50%; + margin-bottom: 4px; + border: 2px solid #fff; + box-shadow: 0 0 2px #888; + } + .completion-low { + background: #b2dfdb; + border-color: #009688; + } + .completion-medium { + background: #81c784; + border-color: #388e3c; + } + .completion-high { + background: #388e3c; + border-color: #1b5e20; + } {% endblock %} @@ -107,6 +128,98 @@
+
+ + +{% set followup_icons = { + 'fire_hydrant': 'bi-droplet', + 'charging_station': 'bi-lightning-charge', + 'toilets': 'bi-toilet', + 'bus_stop': 'bi-bus-front', + 'defibrillator': 'bi-heart-pulse', + 'camera': 'bi-camera-video', + 'recycling': 'bi-recycle', + 'substation': 'bi-plug', + 'laboratory': 'bi-beaker', + 'school': 'bi-mortarboard', + 'police': 'bi-shield-lock', + 'healthcare': 'bi-hospital', + 'places': 'bi-geo-alt' +} %} +{% set type_labels = { + 'fire_hydrant': 'Bornes incendie', + 'charging_station': 'Bornes de recharge', + 'toilets': 'Toilettes publiques', + 'bus_stop': 'Arrêts de bus', + 'defibrillator': 'Défibrillateurs', + 'camera': 'Caméras de surveillance', + 'recycling': 'Points de recyclage', + 'substation': 'Sous-stations électriques', + 'laboratory': "Laboratoires d'analyse", + 'school': 'Écoles', + 'police': 'Commissariats', + 'healthcare': 'Lieux de santé', + 'places': 'Lieux' +} %} + +{% set overpass_type_queries = { + 'fire_hydrant': 'nwr["emergency"="fire_hydrant"](area.searchArea);', + 'charging_station': 'nwr["amenity"="charging_station"](area.searchArea);', + 'toilets': 'nwr["amenity"="toilets"](area.searchArea);', + 'bus_stop': 'nwr["highway"="bus_stop"](area.searchArea);', + 'defibrillator': 'nwr["emergency"="defibrillator"](area.searchArea);', + 'camera': 'nwr["man_made"="surveillance"](area.searchArea);', + 'recycling': 'nwr["amenity"="recycling"](area.searchArea);', + 'substation': 'nwr["power"="substation"](area.searchArea);', + 'laboratory': 'nwr["healthcare"="laboratory"](area.searchArea);', + 'school': 'nwr["amenity"="school"](area.searchArea);', + 'police': 'nwr["amenity"="police"](area.searchArea);', + 'healthcare': 'nwr["healthcare"](area.searchArea);nwr["amenity"="doctors"](area.searchArea);nwr["amenity"="pharmacy"](area.searchArea);nwr["amenity"="hospital"](area.searchArea);nwr["amenity"="clinic"](area.searchArea);nwr["amenity"="social_facility"](area.searchArea);' +} %} + +
+ {% for type, data in latestFollowups %} + {% set overpass_query = '[out:json][timeout:60];\narea["ref:INSEE"="' ~ stats.zone ~ '"]->.searchArea;\n(' ~ overpass_type_queries[type]|default('') ~ ');\n(._;>;);\nout meta;\n>;' %} + {% set completion = data.completion is defined ? data.completion.getMeasure() : null %} + {% set completion_class = '' %} + {% if completion is not null %} + {% if completion < 40 %} + {% set completion_class = 'completion-low' %} + {% elseif completion < 80 %} + {% set completion_class = 'completion-medium' %} + {% else %} + {% set completion_class = 'completion-high' %} + {% endif %} + {% endif %} + {% if data is defined and (data.count is defined or data.completion is defined) %} +
+
+
+
+
+ {{ type_labels[type]|default(type|capitalize) }}
+ {{ data.count is defined ? data.count.getMeasure() : '?' }}
+ {{ completion is not null ? completion : '?' }}% +
+
+
+ {% else %} +
+
+
+
+
+ {{ type_labels[type]|default(type|capitalize) }}
+ N = ?
+ ?% +
+
+
+ {% endif %} + {% endfor %} +
+ +
{{ stats.getCompletionPercent() }} % @@ -168,6 +281,9 @@ Gouttes
+
@@ -226,7 +342,7 @@
-
+

Podium des contributeurs OSM de cette ville

@@ -249,7 +365,7 @@ {{ loop.index }} - + {{ row.osm_user }} @@ -314,6 +430,7 @@
  • Site web
  • Numéro de téléphone
  • Accessibilité PMR
  • +
  • SIRET
  • Chaque critère rempli augmente le score de complétion d'une part égale. Un commerce parfaitement renseigné aura un score de 100%.

    @@ -326,39 +443,7 @@

    - - -{% set followup_icons = { - 'fire_hydrant': 'bi-droplet', - 'charging_station': 'bi-lightning-charge', - 'toilets': 'bi-toilet', - 'bus_stop': 'bi-bus-front', - 'defibrillator': 'bi-heart-pulse', - 'camera': 'bi-camera-video', - 'recycling': 'bi-recycle', - 'substation': 'bi-plug', - 'laboratory': 'bi-beaker', - 'school': 'bi-mortarboard', - 'places': 'bi-geo-alt' -} %} - -
    - {% for type, data in latestFollowups %} - {% if data.count or data.completion %} -
    -
    -
    -
    - {{ type_labels[type]|default(type|capitalize) }}
    - N = {{ data.count ? data.count.getMeasure() : '?' }}
    - Compl. = {{ data.completion ? data.completion.getMeasure() : '?' }}% -
    -
    -
    - {% endif %} - {% endfor %} -
    {% endblock %} @@ -509,7 +594,7 @@ if (properties.address) popupContent += `${properties.address}
    `; if (properties.main_tag) popupContent += `${properties.main_tag}
    `; if (properties.note) popupContent += `Note: ${properties.note}
    `; - popupContent += `Voir sur OSM`; + popupContent += `Voir sur OSM`; while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; @@ -539,11 +624,26 @@ const openInJOSMButton = document.getElementById('openInJOSM'); if (openInJOSMButton) { openInJOSMButton.addEventListener('click', () => { - const osmElements = geojsonData.features.map(f => ({ - osm_id: f.properties.id.split('/')[1], - osm_type: f.properties.id.split('/')[0] - })); - openInJOSM(map, map_is_loaded, osmElements); + const place_nodes = []; + const place_ways = []; + const place_relations = []; + const places = {{ geojson|raw }}.features; + places.forEach(place => { + if (place.properties.getOsmKind() === 'node') { + place_nodes.push(place.properties.id.split('/')[1]); + } elseif (place.properties.getOsmKind() === 'way') { + place_ways.push(place.properties.id.split('/')[1]); + } elseif (place.properties.getOsmKind() === 'relation') { + place_relations.push(place.properties.id.split('/')[1]); + } + }); + const overpass_josm_query = '[out:xml][timeout:60];\n' + + (place_nodes.length > 0 ? 'node(id:' + place_nodes.join(',') + ');\n' : '') + + (place_ways.length > 0 ? 'way(id:' + place_ways.join(',') + ');\n' : '') + + (place_relations.length > 0 ? 'relation(id:' + place_relations.join(',') + ');\n' : '') + + '(._;>;);\nout meta;'; + const url = 'http://127.0.0.1:8111/import?url=https://overpass-api.de/api/interpreter?data=' + encodeURIComponent(overpass_josm_query); + openInJOSM(map, map_is_loaded, [{osm_id: place_nodes.join(','), osm_type: 'node'}, {osm_id: place_ways.join(','), osm_type: 'way'}, {osm_id: place_relations.join(','), osm_type: 'relation'}], url); }); } @@ -675,8 +775,10 @@ if(dc ){ } new Chart(completionCtx, { type: 'line', + data: { labels: completionLabels, + tension: 0.3, datasets: [{ label: 'Distribution du Taux de Complétion', data: completionValues, @@ -734,4 +836,49 @@ if(dc ){ } }); + {% endblock %} diff --git a/templates/public/nav.html.twig b/templates/public/nav.html.twig index 6b49d13..a39272d 100644 --- a/templates/public/nav.html.twig +++ b/templates/public/nav.html.twig @@ -47,6 +47,12 @@ Podium des contributeurs OSM +