osm-labo/templates/admin/followup_theme_graph.html.twig
2025-07-12 14:42:16 +02:00

606 lines
No EOL
26 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends 'base_embed.html.twig' %}
{% block title %}Graphique {{ theme_label }} - {{ stats.name }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link href='{{ asset('js/maplibre/maplibre-gl.css') }}' rel='stylesheet'/>
<style>
.chart-container {
width: 100%;
height: 400px;
margin: 20px 0;
}
.stats-header {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.action-bar {
background: white;
padding: 15px;
border-radius: 8px;
border: 1px solid #dee2e6;
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.action-bar .btn {
white-space: nowrap;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: white;
padding: 15px;
border-radius: 8px;
border: 1px solid #dee2e6;
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #0d6efd;
}
.stat-label {
font-size: 14px;
color: #6c757d;
margin-top: 5px;
}
.chart-tabs {
display: flex;
margin-bottom: 20px;
border-bottom: 1px solid #dee2e6;
}
.chart-tab {
padding: 10px 20px;
background: none;
border: none;
cursor: pointer;
border-bottom: 3px solid transparent;
}
.chart-tab.active {
border-bottom-color: #0d6efd;
color: #0d6efd;
}
.chart-content {
display: none;
}
.chart-content.active {
display: block;
}
#themeMap {
height: 400px;
width: 100%;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.maplibregl-popup-content {
font-size: 0.95em;
}
.btn-josm {
margin-bottom: 1rem;
}
/* Bouton flottant suggestion desktop */
.suggestion-float-btn {
position: fixed;
bottom: 32px;
right: 32px;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
display: none;
}
@media (min-width: 768px) {
.suggestion-float-btn { display: block; }
.suggestion-footer-btn { display: none !important; }
}
@media (max-width: 767.98px) {
.suggestion-float-btn { display: none !important; }
.suggestion-footer-btn { display: block; width: 100%; margin: 0; border-radius: 0; }
}
</style>
{% endblock %}
{% block body %}
<div class="container-fluid">
{# DEBUG : Affichage des objets Place trouvés pour cette ville #}
{% if places is defined %}
<div class="alert alert-warning" style="font-size:0.95em;">
<b>DEBUG : Objets Place trouvés pour cette ville (avant filtrage)</b><br>
<table class="table table-sm table-bordered mt-2 mb-0">
<thead><tr>
<th>#</th><th>id</th><th>main_tag</th><th>osm_kind</th><th>nom</th><th>lat</th><th>lon</th>
</tr></thead>
<tbody>
{% for p in places %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ p.getOsmId() }}</td>
<td>{{ p.getMainTag() }}</td>
<td>{{ p.getOsmKind() }}</td>
<td>{{ p.getName() }}</td>
<td>{{ p.getLat() }}</td>
<td>{{ p.getLon() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="stats-header">
<div class="d-flex justify-content-between align-items-start">
<div>
<h2>
<i class="bi {{ icons[theme]|default('bi-question-circle') }}"></i>
{{ theme_label }} - {{ stats.name }}
</h2>
<p class="mb-0">Code INSEE: {{ stats.zone }}</p>
</div>
<a href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Retour aux stats
</a>
</div>
</div>
<div class="action-bar">
<a href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}" class="btn btn-primary">
<i class="bi bi-bar-chart"></i> Stats de la ville
</a>
<a href="{{ path('app_admin_labourer', {'insee_code': stats.zone}) }}" class="btn btn-warning">
<i class="bi bi-arrow-clockwise"></i> Labourer la ville
</a>
<a href="{{ path('admin_followup_graph', {'insee_code': stats.zone}) }}" class="btn btn-info">
<i class="bi bi-graph-up"></i> Suivi OSM (graphes)
</a>
<a href="{{ path('admin_followup_embed_graph', {'insee_code': stats.zone, 'theme': theme}) }}" class="btn btn-success" target="_blank">
<i class="bi bi-box-arrow-up-right"></i> Graphe embedded
</a>
<a href="{{ path('app_admin') }}" class="btn btn-secondary">
<i class="bi bi-house"></i> Accueil admin
</a>
<a href="{{ path('app_public_stats_evolutions', {'insee_code': stats.zone}) }}" class="btn btn-outline-primary">
<i class="bi bi-clock-history"></i> Évolutions temporelles
</a>
<a href="{{ path('admin_street_completion', {'insee_code': stats.zone}) }}" class="btn btn-outline-success">
<i class="bi bi-signpost"></i> Complétion des rues
</a>
</div>
{% if josm_url %}
<a href="{{ josm_url }}" class="btn btn-outline-dark btn-josm" target="_blank">
<i class="bi bi-box-arrow-up-right"></i> Ouvrir tous les objets dans JOSM
</a>
{% else %}
<div class="alert alert-info mb-3">Aucun objet sélectionné pour ce thème, rien à charger dans JOSM.</div>
{% endif %}
<div id="themeMap"></div>
{% if geojson is defined %}
{# On n'utilise plus geojson côté PHP, la carte sera alimentée dynamiquement via Overpass en JS #}
{% endif %}
{% if overpass_query is defined %}
<div class="mb-3">
<a href="https://overpass-turbo.eu/?Q={{ overpass_query|url_encode }}" target="_blank" class="btn btn-outline-primary mt-2">
<i class="bi bi-geo"></i> Vérifier sur Overpass Turbo
</a>
</div>
{% endif %}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="currentCount">-</div>
<div class="stat-label">Nombre actuel</div>
</div>
<div class="stat-card">
<div class="stat-value" id="currentCompletion">-</div>
<div class="stat-label">Complétion actuelle</div>
</div>
<div class="stat-card">
<div class="stat-value" id="dataPoints">-</div>
<div class="stat-label">Points de données</div>
</div>
<div class="stat-card">
<div class="stat-value" id="lastUpdate">-</div>
<div class="stat-label">Dernière mise à jour</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
{# <div class="card p-3">
<h5>Progression</h5>
<table class="table table-sm mb-0">
<thead><tr><th>Période</th><th>Nombre</th><th>Complétion (%)</th></tr></thead>
<tbody>
<tr><td>7 jours</td><td>{{ progressions.count['7j'] is not null ? '%+d'|format(progressions.count['7j']) : '' }}</td><td>{{ progressions.completion['7j'] is not null ? '%+.1f'|format(progressions.completion['7j']) : '' }}</td></tr>
<tr><td>30 jours</td><td>{{ progressions.count['30j'] is not null ? '%+d'|format(progressions.count['30j']) : '' }}</td><td>{{ progressions.completion['30j'] is not null ? '%+.1f'|format(progressions.completion['30j']) : '' }}</td></tr>
<tr><td>6 mois</td><td>{{ progressions.count['6m'] is not null ? '%+d'|format(progressions.count['6m']) : '' }}</td><td>{{ progressions.completion['6m'] is not null ? '%+.1f'|format(progressions.completion['6m']) : '' }}</td></tr>
<tr><td>1 an</td><td>{{ progressions.count['1a'] is not null ? '%+d'|format(progressions.count['1a']) : '' }}</td><td>{{ progressions.completion['1a'] is not null ? '%+.1f'|format(progressions.completion['1a']) : '' }}</td></tr>
</tbody>
</table>
</div> #}
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="ms-auto">
<label for="basemapSelect" class="form-label mb-1">Fond de carte :</label>
<select id="basemapSelect" class="form-select">
<option value="streets">MapTiler Streets</option>
<option value="satellite">BD Ortho IGN</option>
</select>
</div>
</div>
</div>
<div class="chart-container">
<canvas id="themeChart"></canvas>
</div>
{% if completion_tags is defined and completion_tags[theme] is defined %}
<div class="card mt-4">
<div class="card-header">
<i class="bi bi-info-circle"></i> Critères de complétion attendus pour ce thème
</div>
<div class="card-body p-2">
<ul class="mb-0">
{% for tag in completion_tags[theme] %}
<li><code>{{ tag }}</code></li>
{% else %}
<li><span class="text-muted">Aucun critère défini</span></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div>
<div class="card mt-5">
<div class="card-header">
<i class="bi bi-tags"></i> Statistiques des tags utilisés dans les objets trouvés
</div>
<div class="card-body p-2">
<div id="tags-stats-block">
<table class="table table-sm table-bordered mb-0" id="tags-stats-table" style="max-width:600px;">
<thead>
<tr>
<th>Tag</th>
<th>Nombre d'occurrences</th>
</tr>
</thead>
<tbody>
<tr><td colspan="2" class="text-muted">Chargement...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<a href="https://forum.openstreetmap.fr/t/osm-mon-commerce/34403/11" class="btn btn-info suggestion-footer-btn mt-4 mb-2" target="_blank" rel="noopener">
<i class="bi bi-chat-dots"></i> Faire une suggestion
</a>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src='{{ asset('js/maplibre/maplibre-gl.js') }}'></script>
<script>
const overpassQuery = `{% if overpass_query is defined %}{{ overpass_query|replace({
'(._;>;);\nout meta;\n>;': 'out center;'
})|e('js') }}{% endif %}`;
const mapToken = '{{ maptiler_token }}';
// Liste des tags attendus pour la complétion de ce thème
const completionTags = {{ completion_tags[theme]|json_encode|raw }};
let mapInstance = null;
const basemaps = {
'streets': 'https://api.maptiler.com/maps/streets/style.json?key=' + mapToken,
// 'satellite': 'https://data.geopf.fr/annexes/ressources/vectorTiles/styles/BDORTHO/standard.json' // supprimé car non compatible
};
const IGN_RASTER_ID = 'ign-ortho';
const IGN_LAYER_ID = 'ign-ortho-layer';
const IGN_RASTER_URL = 'https://wxs.ign.fr/ortho/geoportail/wmts?layer=ORTHOIMAGERY.ORTHOPHOTOS&style=normal&tilematrixset=PM&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/jpeg&TileMatrix={z}&TileCol={x}&TileRow={y}';
document.addEventListener('DOMContentLoaded', function() {
if (!overpassQuery) {
document.getElementById('themeMap').innerHTML = '<div class="alert alert-warning">Aucune requête Overpass disponible pour ce thème.</div>';
return;
}
// Initialiser la carte
mapInstance = new maplibregl.Map({
container: 'themeMap',
style: basemaps['streets'],
center: [2, 48],
zoom: 13
});
mapInstance.addControl(new maplibregl.NavigationControl());
// Gestion du changement de fond de carte
const basemapSelect = document.getElementById('basemapSelect');
if (basemapSelect) {
basemapSelect.addEventListener('change', function() {
const val = basemapSelect.value;
if (val === 'streets') {
mapInstance.setStyle(basemaps['streets']);
} else if (val === 'satellite') {
// Ajout du raster IGN comme dans JOSM
if (!mapInstance.getSource(IGN_RASTER_ID)) {
mapInstance.addSource(IGN_RASTER_ID, {
'type': 'raster',
'tiles': [IGN_RASTER_URL],
'tileSize': 256,
'attribution': 'Données © IGN BD Ortho',
'scheme': 'tms',
'minzoom': 0,
'maxzoom': 19
});
}
if (!mapInstance.getLayer(IGN_LAYER_ID)) {
mapInstance.addLayer({
'id': IGN_LAYER_ID,
'type': 'raster',
'source': IGN_RASTER_ID,
'minzoom': 0,
'maxzoom': 19
});
}
}
});
}
// Requête Overpass
fetch('https://overpass-api.de/api/interpreter', {
method: 'POST',
body: overpassQuery,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
.then(response => response.json())
.then(data => {
if (!data.elements || data.elements.length === 0) {
document.getElementById('themeMap').innerHTML = '<div class="alert alert-warning">Aucun objet trouvé via Overpass pour ce thème.</div>';
// Vider le tableau des tags
document.querySelector('#tags-stats-table tbody').innerHTML = '<tr><td colspan="2" class="text-muted">Aucun objet trouvé</td></tr>';
return;
}
// Centrage carte
let lats = [], lons = [];
data.elements.forEach(e => {
if (e.lat && e.lon) {
lats.push(e.lat); lons.push(e.lon);
} else if (e.type === 'way' && e.center) {
lats.push(e.center.lat); lons.push(e.center.lon);
}
});
if (lats.length && lons.length) {
const avgLat = lats.reduce((a,b)=>a+b,0)/lats.length;
const avgLon = lons.reduce((a,b)=>a+b,0)/lons.length;
mapInstance.setCenter([avgLon, avgLat]);
}
// Marqueurs
data.elements.forEach(e => {
let lat = null, lon = null;
if (e.type === 'node') {
lat = e.lat; lon = e.lon;
} else if (e.center) {
lat = e.center.lat; lon = e.center.lon;
}
if (!lat || !lon) return; // On ignore les ways sans centroïde
// Calcul de la complétion
let filled = 0;
let missingTags = [];
if (completionTags && completionTags.length > 0) {
completionTags.forEach(tag => {
if (e.tags && typeof e.tags[tag] !== 'undefined' && e.tags[tag] !== null && e.tags[tag] !== '') {
filled++;
} else {
missingTags.push(tag);
}
});
}
let completion = completionTags && completionTags.length > 0 ? Math.round(100 * filled / completionTags.length) : null;
// Couleur dégradée du gris au vert intense
function lerpColor(a, b, t) {
// a et b sont des couleurs hex, t entre 0 et 1
const ah = a.replace('#', '');
const bh = b.replace('#', '');
const ar = parseInt(ah.substring(0,2), 16), ag = parseInt(ah.substring(2,4), 16), ab = parseInt(ah.substring(4,6), 16);
const br = parseInt(bh.substring(0,2), 16), bg = parseInt(bh.substring(2,4), 16), bb = parseInt(bh.substring(4,6), 16);
const rr = Math.round(ar + (br-ar)*t);
const rg = Math.round(ag + (bg-ag)*t);
const rb = Math.round(ab + (bb-ab)*t);
return '#' + rr.toString(16).padStart(2,'0') + rg.toString(16).padStart(2,'0') + rb.toString(16).padStart(2,'0');
}
let color = '#cccccc'; // gris par défaut
if (completion !== null) {
color = lerpColor('#cccccc', '#008000', Math.max(0, Math.min(1, completion/100)));
}
// Affichage des tags manquants
let missingHtml = '';
if (missingTags.length > 0) {
missingHtml = `<div style='color:#b30000;font-size:0.95em;margin-top:4px;'><b>Manque :</b> ${missingTags.map(t => `<code>${t}</code>`).join(', ')}</div>`;
}
const popupHtml = `<div style='min-width:180px'>
<strong>${e.tags && e.tags.name ? e.tags.name : '(sans nom)'}</strong><br>
<span class='text-muted'>${e.type} ${e.id}</span><br>
<span style='font-size:0.95em;'>${e.tags ? Object.entries(e.tags).map(([k,v]) => `<span><b>${k}</b>: ${v}</span>`).join('<br>') : ''}</span><br>
<b>Complétion :</b> ${completion !== null ? completion + '%' : ''}
${missingHtml}
<br><a href='https://www.openstreetmap.org/${e.type}/${e.id}' target='_blank'>Voir sur OSM</a>
</div>`;
new maplibregl.Marker({ color: color })
.setLngLat([lon, lat])
.setPopup(new maplibregl.Popup({ offset: 18 }).setHTML(popupHtml))
.addTo(mapInstance);
});
// --- Statistiques des tags ---
const tagCounts = {};
data.elements.forEach(e => {
if (e.tags) {
Object.entries(e.tags).forEach(([k, v]) => {
if (!tagCounts[k]) tagCounts[k] = 0;
tagCounts[k]++;
});
}
});
const tbody = document.querySelector('#tags-stats-table tbody');
if (Object.keys(tagCounts).length === 0) {
tbody.innerHTML = '<tr><td colspan="2" class="text-muted">Aucun tag trouvé</td></tr>';
} else {
tbody.innerHTML = Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `<tr><td><code>${k}</code></td><td>${v}</td></tr>`)
.join('');
}
})
.catch(err => {
document.getElementById('themeMap').innerHTML = '<div class="alert alert-danger">Erreur lors de la requête Overpass : ' + err + '</div>';
});
});
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<script>
const countData = {{ count_data|raw }};
const completionData = {{ completion_data|raw }};
// Mettre à jour les statistiques
function updateStats() {
if (countData.length > 0) {
const latestCount = countData[countData.length - 1];
document.getElementById('currentCount').textContent = latestCount.value;
document.getElementById('lastUpdate').textContent = new Date(latestCount.date).toLocaleDateString('fr-FR');
}
if (completionData.length > 0) {
const latestCompletion = completionData[completionData.length - 1];
document.getElementById('currentCompletion').textContent = latestCompletion.value + '%';
}
document.getElementById('dataPoints').textContent = Math.max(countData.length, completionData.length);
}
// Configuration commune pour les graphiques
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
}
},
scales: {
x: {
type: 'time',
time: {
unit: 'day',
displayFormats: {
day: 'dd/MM/yyyy'
}
},
title: {
display: true,
text: 'Date'
}
},
y: {
beginAtZero: true,
title: {
display: true
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
};
// Graphique fusionné
const ctx = document.getElementById('themeChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
datasets: [
{
label: "Nombre d'objets",
data: countData.map(d => ({ x: new Date(d.date), y: d.value })),
borderColor: '#0d6efd',
backgroundColor: 'rgba(13, 110, 253, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.1,
yAxisID: 'y1',
},
{
label: 'Pourcentage de complétion',
data: completionData.map(d => ({ x: new Date(d.date), y: d.value })),
borderColor: '#198754',
backgroundColor: 'rgba(25, 135, 84, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.1,
yAxisID: 'y2',
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true },
tooltip: { mode: 'index', intersect: false }
},
interaction: { mode: 'nearest', axis: 'x', intersect: false },
scales: {
x: {
type: 'time',
time: { unit: 'day', displayFormats: { day: 'dd/MM/yyyy' } },
title: { display: true, text: 'Date' }
},
y1: {
type: 'linear',
position: 'left',
title: { display: true, text: "Nombre d'objets" },
beginAtZero: true
},
y2: {
type: 'linear',
position: 'right',
title: { display: true, text: 'Complétion (%)' },
min: 0,
max: 100,
grid: { drawOnChartArea: false }
}
}
}
});
// Initialiser les statistiques
updateStats();
// Ajout debug JS requête Overpass
{% if overpass_query is defined %}
console.log('[DEBUG][Overpass] Requête envoyée à Overpass :', `{{ overpass_query|e('js') }}`);
{% else %}
console.log('[DEBUG][Overpass] Aucune requête Overpass transmise à la page.');
{% endif %}
</script>
{% endblock %}