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

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