suivi global

This commit is contained in:
Tykayn 2025-06-29 18:32:24 +02:00 committed by tykayn
parent afc120ef2a
commit e3f8680472
6 changed files with 531 additions and 48 deletions

View file

@ -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
]);
}
}

View file

@ -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;
}

View file

@ -0,0 +1,105 @@
{% extends 'base.html.twig' %}
{% block title %}Suivi global de toutes les villes{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Suivi global de toutes les villes</h1>
<div class="mb-4">
<h3>Nombre de villes et complétion moyenne</h3>
<canvas id="global-summary-chart" height="80"></canvas>
</div>
<hr>
<h3>Suivi par thématique</h3>
<div class="row">
{% set themes = [
'fire_hydrant', 'charging_station', 'toilets', 'bus_stop', 'defibrillator', 'camera', 'recycling', 'substation', 'laboratory', 'school', 'police', 'healthcare', 'places'
] %}
{% for theme in themes %}
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">{{ theme|replace({'_': ' '})|title }}</h5>
<canvas id="chart-{{ theme }}-count" height="60"></canvas>
<canvas id="chart-{{ theme }}-completion" height="60"></canvas>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0"></script>
<script>
const series = {{ series|json_encode|raw }};
// Graphe global summary
const ctxSummary = document.getElementById('global-summary-chart').getContext('2d');
const cityCountData = (series['city_count'] || []).map(e => ({x: e.date, y: e.value}));
const completionAvgData = (series['global_completion_average'] || []).map(e => ({x: e.date, y: e.value}));
new Chart(ctxSummary, {
type: 'line',
data: {
datasets: [
{
label: 'Nombre de villes',
data: cityCountData,
borderColor: 'blue',
yAxisID: 'y1',
},
{
label: 'Complétion moyenne (%)',
data: completionAvgData,
borderColor: 'green',
yAxisID: 'y2',
}
]
},
options: {
responsive: true,
interaction: {mode: 'index', intersect: false},
stacked: false,
scales: {
x: {type: 'time', time: {unit: 'day'}},
y1: {type: 'linear', position: 'left', title: {display: true, text: 'Villes'}},
y2: {type: 'linear', position: 'right', title: {display: true, text: '%'}},
}
}
});
// Graphes par thème
const themes = {{ themes|json_encode|raw }};
themes.forEach(theme => {
// Count
const countData = (series[theme + '_count'] || []).map(e => ({x: e.date, y: e.value}));
const ctxCount = document.getElementById('chart-' + theme + '-count').getContext('2d');
new Chart(ctxCount, {
type: 'line',
data: {
datasets: [{
label: 'Nombre',
data: countData,
borderColor: 'orange',
}]
},
options: {
responsive: true,
scales: {x: {type: 'time', time: {unit: 'day'}}, y: {beginAtZero: true}}
}
});
// Completion
const completionData = (series[theme + '_completion'] || []).map(e => ({x: e.date, y: e.value}));
const ctxCompletion = document.getElementById('chart-' + theme + '-completion').getContext('2d');
new Chart(ctxCompletion, {
type: 'line',
data: {
datasets: [{
label: 'Complétion (%)',
data: completionData,
borderColor: 'green',
}]
},
options: {
responsive: true,
scales: {x: {type: 'time', time: {unit: 'day'}}, y: {beginAtZero: true, max: 100}}
}
});
});
</script>
{% endblock %}

View file

@ -5,10 +5,16 @@
{% block body %}
<div class="container mt-4">
<h1>Suivi des objets OSM pour {{ stats.name }} ({{ stats.zone }})</h1>
<div class="mb-3">
<div class="mb-3 d-flex flex-wrap gap-2">
<a href="{{ path('admin_followup', {'insee_code': stats.zone}) }}" class="btn btn-warning">
<i class="bi bi-arrow-repeat"></i> Mettre à jour les suivis (followup)
</a>
<a href="{{ path('app_admin_labourer', {'insee_code': stats.zone, 'deleteMissing': 1}) }}" class="btn btn-primary">
<i class="bi bi-shovel"></i> Labourer la zone
</a>
<a href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}" class="btn btn-info">
<i class="bi bi-bar-chart"></i> Voir les stats
</a>
</div>
<p>Historique des objets suivis (nombre et complétion).</p>
{% 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 %}
<h2 id="title-{{ type }}">{{ type_labels[type] }}</h2>
@ -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 '-';

View file

@ -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;
}
</style>
{% endblock %}
@ -107,6 +128,98 @@
<div class="row">
<div id="followups">
{% 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);'
} %}
<div class="row mb-4 latestFollowups ">
{% 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) %}
<div class="col-auto mb-2">
<div class="card shadow-sm text-center" style="min-width: 140px;">
<div class="card-body p-2">
<span class="completion-badge {{ completion_class }}"></span><br>
<i class="bi {{ followup_icons[type]|default('bi-question-circle') }} fs-2 mb-1"></i><br>
<a href="http://127.0.0.1:8111/import?url=https://overpass-api.de/api/interpreter?data={{ overpass_query|url_encode }}" target="_blank" class="fw-bold text-decoration-underline text-dark" title="Charger dans JOSM">{{ type_labels[type]|default(type|capitalize) }}</a><br>
<span title="Nombre"> {{ data.count is defined ? data.count.getMeasure() : '?' }}</span><br>
<span title="Complétion"> {{ completion is not null ? completion : '?' }}%</span>
</div>
</div>
</div>
{% else %}
<div class="col-auto mb-2">
<div class="card shadow-sm text-center bg-light text-muted" style="min-width: 140px;">
<div class="card-body p-2">
<span class="completion-badge" style="background:#eee;"></span><br>
<i class="bi bi-question-circle fs-2 mb-1"></i><br>
<span class="fw-bold">{{ type_labels[type]|default(type|capitalize) }}</span><br>
<span title="Nombre">N = ?</span><br>
<span title="Complétion">?%</span>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
<div class="col-md-3 col-12">
<span class="badge {% if stats.getCompletionPercent() > 85 %}bg-success{% else %}bg-warning{% endif %}">
{{ stats.getCompletionPercent() }} %
@ -168,6 +281,9 @@
<i class="bi bi-geo-alt"></i> Gouttes
</button>
</div>
<button id="btn-geolocate" class="btn btn-outline-primary btn-sm">
<i class="bi bi-geo-alt"></i> Me localiser
</button>
</div>
<div id="map" style="height: 400px; width: 100%; margin-bottom: 1rem;"></div>
@ -226,7 +342,7 @@
</div>
<div class="card mt-4">
<div class="card mt-4" id="podium">
<div class="card-header">
<h2>Podium des contributeurs OSM de cette ville</h2>
</div>
@ -249,7 +365,7 @@
<tr>
<th scope="row">{{ loop.index }}</th>
<td>
<a href="https://www.openstreetmap.org/user/{{ row.osm_user|e('url') }}" target="_blank">
<a href="https://www.openstreetmap.org/user/{{ row.osm_user|e('url') }}" >
{{ row.osm_user }}
</a>
</td>
@ -314,6 +430,7 @@
<li>Site web</li>
<li>Numéro de téléphone</li>
<li>Accessibilité PMR</li>
<li>SIRET</li>
</ul>
<p>Chaque critère rempli augmente le score de complétion d'une part égale.
Un commerce parfaitement renseigné aura un score de 100%.</p>
@ -326,39 +443,7 @@
<h2 class="accordion-header" id="headingOne">
</div>
</div>
<!-- Bouton caché pour JOSM -->
<a id="josmButton" style="display: none;"></a>
{% 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'
} %}
<div class="row mb-4">
{% for type, data in latestFollowups %}
{% if data.count or data.completion %}
<div class="col-auto mb-2">
<div class="card shadow-sm text-center" style="min-width: 140px;">
<div class="card-body p-2">
<i class="bi {{ followup_icons[type]|default('bi-question-circle') }} fs-2 mb-1"></i><br>
<span class="fw-bold">{{ type_labels[type]|default(type|capitalize) }}</span><br>
<span title="Nombre">N = {{ data.count ? data.count.getMeasure() : '?' }}</span><br>
<span title="Complétion">Compl. = {{ data.completion ? data.completion.getMeasure() : '?' }}%</span>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endblock %}
@ -509,7 +594,7 @@
if (properties.address) popupContent += `${properties.address}<br>`;
if (properties.main_tag) popupContent += `<em>${properties.main_tag}</em><br>`;
if (properties.note) popupContent += `<small>Note: ${properties.note}</small><br>`;
popupContent += `<a href="${properties.osm_url}" target="_blank">Voir sur OSM</a>`;
popupContent += `<a href="${properties.osm_url}" >Voir sur OSM</a>`;
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 ){
}
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('btn-geolocate');
btn && btn.addEventListener('click', function() {
if (!navigator.geolocation) {
alert('La géolocalisation n\'est pas supportée par ce navigateur.');
return;
}
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Localisation...';
navigator.geolocation.getCurrentPosition(function(pos) {
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
// MapLibre
if (window.mapInstance && typeof window.mapInstance.flyTo === 'function') {
window.mapInstance.flyTo({center: [lon, lat], zoom: 15});
if (window._geoMarker) window.mapInstance.removeLayer('geo-marker');
if (window._geoMarkerSource) window.mapInstance.removeSource('geo-marker');
window.mapInstance.addSource('geo-marker', {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: [lon, lat] } }
});
window.mapInstance.addLayer({
id: 'geo-marker',
type: 'circle',
source: 'geo-marker',
paint: { 'circle-radius': 10, 'circle-color': '#007bff', 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' }
});
window._geoMarker = true;
window._geoMarkerSource = true;
} else if (window.L && window.map) { // Leaflet
window.map.setView([lat, lon], 15);
if (window._geoMarker) window.map.removeLayer(window._geoMarker);
window._geoMarker = window.L.marker([lat, lon], {icon: window.L.icon({iconUrl: 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/icons/geo-alt-fill.svg', iconSize: [32,32]})}).addTo(window.map);
}
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-geo-alt"></i> Me localiser';
}, function(err) {
alert('Impossible de vous localiser : ' + err.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-geo-alt"></i> Me localiser';
});
});
});
</script>
{% endblock %}

View file

@ -47,6 +47,12 @@
Podium des contributeurs OSM
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('admin_followup_global_graph') }}">
<i class="bi bi-globe"></i>
Suivi global OSM
</a>
</li>
</ul>
</div>
</div>