osm-commerces/templates/admin/stats.html.twig
2025-07-14 19:27:07 +02:00

1205 lines
55 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.html.twig' %}
{% block title %}{{ 'display.stats'|trans }}- {{ stats.zone }}
{{ stats.name }} {% endblock %}
{% block stylesheets %}
{{ parent() }}
<link href='{{ asset('js/maplibre/maplibre-gl.css') }}' rel='stylesheet' />
<style>
.completion-circle {
fill-opacity: 0.6;
stroke: #fff;
stroke-width: 3;
}
#distribution_completion {
height: 300px;
margin: 20px 0;
}
.completion-info {
margin-bottom: 2rem;
}
.osm-modification-info {
font-size: 0.85rem;
line-height: 1.3;
}
.osm-modification-info .text-muted {
font-size: 0.75rem;
}
.osm-modification-info a {
text-decoration: none;
color: #0d6efd;
}
.osm-modification-info a:hover {
text-decoration: underline;
}
.osm-freshness-info {
font-size: 0.95rem;
line-height: 1.4;
}
.osm-freshness-info .alert {
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;
}
.compact-theme-card {
transition: transform 0.2s ease-in-out;
}
.compact-theme-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1) !important;
}
.theme-title a {
font-size: 0.85rem;
line-height: 1.2;
}
.theme-stats {
font-size: 0.75rem;
color: #6c757d;
}
.theme-actions .btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.theme-row-scroll {
overflow-x: auto;
white-space: nowrap;
padding-bottom: 0.5rem;
}
.theme-row-scroll .col-auto {
display: inline-block;
float: none;
}
.theme-row-scroll .compact-theme-card {
display: inline-block;
vertical-align: top;
margin-right: 8px;
}
.tab-content {
margin-top: 1.5rem;
}
.table-theme th, .table-theme td {
vertical-align: middle;
font-size: 0.95em;
}
.table-theme th {
background: #f8f9fa;
}
</style>
{% endblock %}
{% block body %}
<div class="container">
<div class="mt-4 p-4">
<div class="row">
<div class="col-md-6 col-12">
<h1 class="title">{{ 'display.stats'|trans }} - {{ stats.zone }}
{{ stats.name }} - {{ stats.completionPercent }}% complété</h1>
</div>
<div class="col-md-6 col-12">
<a href="{{ path('app_admin_labourer', {'insee_code': stats.zone, 'deleteMissing': 1}) }}" class="btn btn-primary" id="labourer">Labourer les mises à jour</a>
<a href="{{ path('app_admin_labourer', {'insee_code': stats.zone, 'deleteMissing': 1, 'disableFollowUpCleanup': 1}) }}" class="btn btn-warning ms-2" id="labourer-no-cleanup" title="Labourer sans nettoyer les suivis OSM">
<i class="bi bi-shield-check"></i> Labourer (sans nettoyage)
</a>
<a href="{{ path('admin_followup_graph', {'insee_code': stats.zone}) }}" class="btn btn-info ms-2" id="followup-graph-link">
<i class="bi bi-graph-up"></i> Suivi OSM (graphes)
</a>
<button id="openInJOSM" class="btn btn-secondary ms-2">
<i class="bi bi-map"></i> Ouvrir dans JOSM
</button>
<a href="{{ path('app_public_stats_evolutions', {'insee_code': stats.zone}) }}" class="btn btn-outline-info ms-2">
<i class="bi bi-activity"></i> Évolutions des objets
</a>
<a href="{{ path('admin_street_completion', {'insee_code': stats.zone}) }}" class="btn btn-outline-success ms-2">
<i class="bi bi-signpost"></i> Complétion des rues
</a>
<a href="{{ path('admin_speed_limit', {'insee_code': stats.zone}) }}" class="btn btn-outline-danger ms-2">
<i class="bi bi-speedometer2"></i> Limites de vitesse
</a>
</div>
</div>
{% if stats.population %}
<div class="row mb-3">
<div class="col-md-4 col-12">
<span class="badge bg-info">
<i class="bi bi-people"></i> Population&nbsp;: {{ stats.population|number_format(0, '.', ' ') }}
</span>
</div>
<div class="col-md-4 col-12">
<span class="badge bg-secondary">
<i class="bi bi-shop"></i> 1 lieu pour
{% set ratio = (stats.population and stats.places|length > 0) ? (stats.population / stats.places|length)|round(0, 'ceil') : '?' %}
{{ ratio|number_format(0, '.', ' ') }} habitants
</span>
</div>
<div class="col-md-4 col-12">
<span class="badge bg-success">
<i class="bi bi-pencil-square"></i> {{ stats.getAvecNote() }} / {{ stats.places|length }} lieux avec note
</span>
</div>
</div>
{% if stats.budgetAnnuel %}
<div class="row mb-3">
<div class="col-md-4 col-12">
<span class="badge bg-warning text-dark">
<i class="bi bi-cash-coin"></i> Budget annuel&nbsp;: {{ stats.budgetAnnuel|number_format(0, '.', ' ') }}
</span>
</div>
<div class="col-md-4 col-12">
<span class="badge bg-warning text-dark">
<i class="bi bi-cash-stack"></i> Budget par habitant&nbsp;:
{% if stats.population > 0 %}
{{ (stats.budgetAnnuel / stats.population)|number_format(0, '.', ' ') }}
{% else %}
?
{% endif %}
</span>
</div>
</div>
{% endif %}
{% endif %}
<div class="row">
<div id="followups">
{% set overpass_type_queries = {
'fire_hydrant': 'nwr["emergency"="fire_hydrant"](area.searchArea);',
'charging_station': 'nwr["amenity"="charging_station"](area.searchArea);',
'bicycle_parking' : 'nwr["amenity"="bicycle_parking"](area.searchArea);',
'toilets': 'nwr["amenity"="toilets"](area.searchArea);',
'bus_stop': 'nwr["highway"="bus_stop"](area.searchArea);nwr["public_transport"="platform"](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);',
'drinking_water' : 'nwr["amenity"="drinking_water"](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);'
} %}
{% set theme_groups = {
'emergency': ['fire_hydrant', 'defibrillator'],
'transport': ['bus_stop', 'charging_station', 'bicycle_parking'],
'healthcare': ['healthcare', 'laboratory', 'drinking_water'],
'education': ['school'],
'security': ['police', 'camera'],
'infrastructure': ['toilets', 'recycling', 'substation']
} %}
<div class="row mb-4">
<div class="col-12">
<ul class="nav nav-tabs" id="themeTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-table" data-bs-toggle="tab" data-bs-target="#tabTableContent" type="button" role="tab" aria-controls="tabTableContent" aria-selected="true">Tableau</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-cards" data-bs-toggle="tab" data-bs-target="#tabCardsContent" type="button" role="tab" aria-controls="tabCardsContent" aria-selected="false">Cartes</button>
</li>
</ul>
<div class="tab-content" id="themeTabsContent">
<div class="tab-pane fade show active" id="tabTableContent" role="tabpanel" aria-labelledby="tab-table">
<table class="table table-theme">
<thead>
<tr>
<th>Catégorie</th>
<th>Thème</th>
<th>Nombre</th>
<th>Complétion</th>
<th>Évolution</th>
<th>Graphique</th>
</tr>
</thead>
<tbody>
{% for group_name, group_types in theme_groups %}
{% for type in group_types %}
{% set data = latestFollowups[type]|default(null) %}
{% set completion = data and data.completion is defined ? data.completion.getMeasure() : null %}
{% set count = data and data.count is defined ? data.count.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 %}
<tr>
<td class="text-muted">
{% if loop.first %}
{% if group_name == 'emergency' %}🚨 Urgence
{% elseif group_name == 'transport' %}🚌 Transport
{% elseif group_name == 'healthcare' %}🏥 Santé
{% elseif group_name == 'education' %}🎓 Éducation
{% elseif group_name == 'security' %}🛡️ Sécurité
{% elseif group_name == 'infrastructure' %}🏗️ Infrastructure
{% else %}{{ group_name|capitalize }}
{% endif %}
{% endif %}
</td>
<td>
<i class="bi {{ followup_icons[type]|default('bi-question-circle') }}"></i>
{{ followup_labels[type]|default(type|capitalize) }}
</td>
<td>{{ count is not null ? count : '?' }}</td>
<td><span class="completion-badge {{ completion_class }}"></span> {{ completion is not null ? completion ~ '%' : '?' }}</td>
<td>
{% if progression7Days[type] is defined %}
{% set countDelta = progression7Days[type].count %}
{% set completionDelta = progression7Days[type].completion %}
<span class="text-muted small">
{% if countDelta is not null %}
<span title="Progression sur 7 jours - Nombre d'objets">
{{ countDelta > 0 ? '+' ~ countDelta : countDelta == 0 ? '0' : countDelta }}
</span>
{% endif %}
{% if completionDelta is not null %}
<span title="Progression sur 7 jours - Complétion">
{{ completionDelta > 0 ? '+' ~ completionDelta|round(1) : completionDelta == 0 ? '0' : completionDelta|round(1) }}%
</span>
{% endif %}
</span>
{% endif %}
</td>
<td>
<a href="{{ path('admin_followup_theme_graph', {'insee_code': stats.zone, 'theme': type}) }}" target="_blank" class="btn btn-sm btn-outline-primary" title="Voir le graphique">
<i class="bi bi-graph-up"></i>
</a>
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-pane fade" id="tabCardsContent" role="tabpanel" aria-labelledby="tab-cards">
{% set all_types = followup_labels|keys %}
<div class="row">
{% for type in all_types %}
{% set data = latestFollowups[type]|default(null) %}
{% 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 and 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 %}
<div class="col-auto">
<div class="card shadow-sm text-center compact-theme-card" style="min-width: 120px; max-width: 140px;">
<div class="card-body p-2">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="completion-badge {{ completion_class }}"></span>
<i class="bi {{ followup_icons[type]|default('bi-question-circle') }} fs-4"></i>
</div>
<div class="theme-title mb-1">
<a href="https://overpass-api.de/api/interpreter?data={{ overpass_query|url_encode }}" target="_blank" class="fw-bold text-decoration-none text-dark small" title="Voir le JSON Overpass">
{{ followup_labels[type]|default(type|capitalize) }}
</a>
</div>
<div class="theme-stats small">
<span title="Nombre">{{ data and data.count is defined ? data.count.getMeasure() : '?' }}</span> |
<span title="Complétion">{{ completion is not null ? completion : '?' }}%</span>
</div>
<div class="theme-actions mt-1">
<a href="{{ path('admin_followup_theme_graph', {'insee_code': stats.zone, 'theme': type}) }}" target="_blank" class="btn btn-sm btn-outline-primary btn-sm" title="Voir le graphique">
<i class="bi bi-graph-up"></i>
</a>
<a href="http://127.0.0.1:8111/import?url=https://overpass-api.de/api/interpreter?data={{ overpass_query|url_encode }}" target="_blank" class="btn btn-sm btn-outline-dark btn-sm ms-1" title="Ouvrir dans JOSM">
<i class="bi bi-box-arrow-up-right"></i> JOSM
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row mb-4">
{% for type in all_types %}
<div class="col-md-4 col-12 mb-2">
<span class="fw-bold">{{ followup_labels[type]|default(type|capitalize) }}</span>
<button class="btn btn-link p-0 ms-1" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-tags-{{ type }}" aria-expanded="false" aria-controls="collapse-tags-{{ type }}" title="Voir les critères de complétion">
<i class="bi bi-question-circle"></i>
</button>
<div class="collapse mt-2" id="collapse-tags-{{ type }}">
<div class="card card-body p-2 small">
<span class="fw-bold">Critères de complétion attendus :</span>
<ul class="mb-0">
{% for tag in completion_tags[type] ?? [] %}
<li><code>{{ tag }}</code></li>
{% else %}
<li><span class="text-muted">Aucun critère défini</span></li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-12">
<span class="badge {% if stats.getCompletionPercent() > 85 %}bg-success{% else %}bg-warning{% endif %}">
{{ stats.getCompletionPercent() }} %
</span>
complété sur les critères donnés.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-building"></i> {{ stats.places | length}}
</span>lieux dans la zone.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-clock"></i> {{ stats.getAvecHoraires() }}
</span>
lieux avec horaires.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-map"></i> {{ stats.getAvecAdresse() }}
</span>
lieux avec adresse.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-globe"></i> {{ stats.getAvecSite() }}
</span>
lieux avec site web renseigné.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-arrow-up-right"></i>
{{ stats.getAvecAccessibilite() }}
</span>
lieux avec accessibilité PMR renseignée.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-chat-dots"></i> {{ stats.getAvecNote() }}
</span>
lieux avec note renseignée.
</div>
</div>
<div id="maploader">
<div class="spinner-border" role="status">
<i class="bi bi-load bi-spin"></i>
<span class="visually-hidden">Chargement de la carte...</span>
</div>
</div>
<div class="d-flex justify-content-end mb-2">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary" id="circleMarkersBtn">
<i class="bi bi-circle"></i> Cercles
</button>
<button type="button" class="btn btn-outline-primary active" id="dropMarkersBtn">
<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>
<div class="row ">
<div class="col-md-6 col-12 ">
<canvas id="repartition_tags" width="600" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
</div>
<div class="col-md-6 col-12 ">
<div class="card">
<div class="card-header">
<i class="bi bi-calendar-event"></i> Fréquence des mises à jour par trimestre pour {{stats.name}}
</div>
<div class="card-body">
<canvas id="modificationsByQuarterChart" style="min-height: 250px; width: 100%;"></canvas>
</div>
</div>
</div>
</div>
<div id="attribution">
<a href="https://www.openstreetmap.org/copyright">Données OpenStreetMap</a>
</div>
</div>
<div class="card mt-4">
{% include 'admin/stats_history.html.twig' with {stat: stats} %}
<canvas id="distribution_completion" class="mt-4 mb-4" height="400"></canvas>
<div class="row">
<div class="col-md-6 col-12">
<h1 class="card-title p-4">Tableau des {{ stats.places |length }} lieux</h1>
</div>
<div class="col-md-6 col-12">
<div class="btn-group mt-4" role="group">
<a href="{{ path('app_admin_export_overpass_csv', {'insee_code': stats.zone}) }}" class="btn btn-primary">
<i class="bi bi-filetype-csv"></i>
Export Overpass CSV
</a>
<a href="{{ path('app_admin_export_table_csv', {'insee_code': stats.zone}) }}" class="btn btn-success">
<i class="bi bi-table"></i>
Export Tableau CSV
</a>
</div>
</div>
</div>
<div id="table_container" class="table-container" >
<table id="stats-table" class="table table-bordered table-striped table-hover table-responsive table-sort">
{% include 'admin/stats/table-head.html.twig' %}
<tbody>
{% for commerce in stats.places %}
{% include 'admin/stats/row.html.twig' %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card mt-4" id="podium">
<div class="card-header">
<h2>Podium des contributeurs OSM de cette ville</h2>
</div>
<div class="card-body">
<div class="table-container" >
<table class="table table-striped table-bordered mt-4" style="max-width:800px">
<thead class="table-dark">
<tr>
<th scope="col">#</th>
<th scope="col">Utilisateur OSM</th>
<th scope="col">Nombre de lieux</th>
<th scope="col">Score de complétion moyen</th>
<th scope="col">Score de complétion pondéré</th>
<th scope="col">Score pondéré normalisé (0-100)</th>
</tr>
</thead>
<tbody>
{% for row in podium_local %}
<tr>
<th scope="row">{{ loop.index }}</th>
<td>
<a href="https://www.openstreetmap.org/user/{{ row.osm_user|e('url') }}" >
{{ row.osm_user }}
</a>
</td>
<td>{{ row.nb }}</td>
<td>
{% if row.completion_moyen is not null %}
{{ row.completion_moyen }}&nbsp;%
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{% if row.completion_pondere is not null %}
{{ row.completion_pondere }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{% if row.completion_pondere_normalisee is not null %}
{{ row.completion_pondere_normalisee }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6">Aucun contributeur trouvé pour cette ville.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# <div class="card mt-4">
<div class="card-header">
<h2>Requête Overpass</h2>
<div id=overPassRequest >
<pre>
{{overpass}}
</pre>
</div>
</div>
</div> #}
<div class="completion-info mt-4">
<div class="alert alert-info">
<div class="d-flex align-items-center completion-hover-trigger" style="cursor: pointer;">
<i class="bi bi-info-circle me-2"></i>
<p class="mb-0">Comment est calculé le score de complétion ?</p>
<i class="bi bi-chevron-down ms-auto" id="completionInfoIcon"></i>
</div>
<div id="completionInfoContent" style="display: none;" class="mt-3">
<p>Le score de complétion est calculé en fonction de plusieurs critères :</p>
<ul>
<li>Nom du commerce</li>
<li>Adresse complète (numéro, rue, code postal)</li>
<li>Horaires d'ouverture</li>
<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>
</div>
</div>
</div>
<div class="accordion mb-3" id="accordionStats">
<div class="accordion-item">
<h2 class="accordion-header" id="headingOne">
</div>
</div>
<!-- Espace de dump JSON -->
<div id="ctc-json-dump-container" class="mt-4" style="display:none;">
<div class="card">
<div class="card-header p-2">
<i class="bi bi-file-earmark-code"></i> Dump du JSON récupéré
</div>
<div class="card-body p-2">
<pre id="ctc-json-dump" style="max-height:400px;overflow:auto;"></pre>
</div>
</div>
</div>
<div class="mt-3 ctc-tests">
<div class="card ctc-tests">
<div class="card-header p-2">
<i class="bi bi-link-45deg"></i> Tester les JSON Complète tes commerces
</div>
<div class="card-body p-2">
<ul class="mb-0" style="font-size:0.95em;">
{% set ctc_jsons = stats.getAllCTCUrlsMap() %}
{% for key, url in ctc_jsons %}
<li><a href="{{ url }}" target="_blank" rel="noopener">{{ key }}</a></li>
{% endfor %}
</ul>
<div class="mt-3">
<div class="input-group">
<select id="ctc-json-select" class="form-select">
<option value="">Choisir un JSON à tester…</option>
{% for key, url in ctc_jsons %}
<option value="{{ url }}">{{ key }}</option>
{% endfor %}
</select>
<button id="ctc-json-test-btn" class="btn btn-outline-primary" type="button">
<i class="bi bi-bug"></i> Tester l'accès JSON CTC
</button>
</div>
</div>
</div>
</div>
</div>
<div id="ctc-json-error" class="alert alert-danger mt-4" style="display:none;"></div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const geojsonData = {{ geojson|raw }};
const map_token = "{{ maptiler_token }}";
// Liste des tags attendus pour la complétion des lieux
const completionTags = {{ completion_tags['places']|json_encode|raw }};
// Calcul de la complétion et des tags manquants pour chaque lieu
geojsonData.features.forEach(f => {
let filled = 0;
let missing = [];
if (completionTags && completionTags.length > 0) {
completionTags.forEach(tag => {
if (f.properties && typeof f.properties[tag] !== 'undefined' && f.properties[tag] !== null && f.properties[tag] !== '') {
filled++;
} else {
missing.push(tag);
}
});
}
f.properties.completion = completionTags && completionTags.length > 0 ? Math.round(100 * filled / completionTags.length) : null;
// Correction : toujours un tableau
f.properties.missing_tags = Array.isArray(f.properties.missing_tags) ? f.properties.missing_tags : (f.properties.missing_tags ? [f.properties.missing_tags] : []);
});
// Fonction de couleur dégradée
function lerpColor(a, b, t) {
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 map;
let map_is_loaded = false;
let currentMode = 'drop';
// Fonction pour mettre à jour le style des marqueurs
window.updateMarkers = function(mode) {
currentMode = mode;
// Mettre à jour l'état visuel des boutons
document.getElementById('circleMarkersBtn').classList.toggle('active', mode === 'circle');
document.getElementById('dropMarkersBtn').classList.toggle('active', mode === 'drop');
if (!map) return;
// Supprimer la couche si elle existe déjà
if (map.getLayer('unclustered-point')) {
map.removeLayer('unclustered-point');
}
// Ajouter la couche selon le mode
if (mode === 'circle') {
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'places',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': [
'case',
['has', 'completion'],
[
'interpolate', ['linear'], ['get', 'completion'],
0, '#cccccc',
100, '#008000'
],
'#cccccc'
],
'circle-radius': 10,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff'
}
});
} else {
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'places',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': [
'case',
['has', 'completion'],
[
'interpolate', ['linear'], ['get', 'completion'],
0, '#cccccc',
100, '#008000'
],
'#cccccc'
],
'circle-radius': 8,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff'
}
});
}
}
if (map_token && geojsonData && geojsonData.features.length > 0) {
map = new maplibregl.Map({
container: 'map',
style: `https://api.maptiler.com/maps/streets/style.json?key=${map_token}`,
center: geojsonData.features[0].geometry.coordinates,
zoom: 14
});
window.mapInstance = map;
map.on('load', function() {
map_is_loaded = true;
document.getElementById('maploader').style.display = 'none';
map.addSource('places', {
type: 'geojson',
data: geojsonData,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
});
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'places',
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step', ['get', 'point_count'],
'#51bbd6', 100,
'#f1f075', 750,
'#f28cb1'
],
'circle-radius': [
'step', ['get', 'point_count'],
20, 100,
30, 750,
40
]
}
});
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'places',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12
}
});
// Par défaut, gouttes bleues
window.updateMarkers('drop');
// Listeners boutons
document.getElementById('circleMarkersBtn').addEventListener('click', function() {
window.updateMarkers('circle');
});
document.getElementById('dropMarkersBtn').addEventListener('click', function() {
window.updateMarkers('drop');
});
// Popups et clusters (inchangé)
map.on('click', 'clusters', function(e) {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] });
const clusterId = features[0].properties.cluster_id;
map.getSource('places').getClusterExpansionZoom(clusterId, function(err, zoom) {
if (err) return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
});
});
map.on('click', 'unclustered-point', function(e) {
const coordinates = e.features[0].geometry.coordinates.slice();
const properties = e.features[0].properties;
let popupContent = `<strong>${properties.name || 'Sans nom'}</strong><br>`;
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 += `<b>Complétion :</b> ${properties.completion !== null ? properties.completion + '%' : ''}`;
const missingTags = Array.isArray(properties.missing_tags) ? properties.missing_tags : [];
if (missingTags.length > 0) {
popupContent += `<div style='color:#b30000;font-size:0.95em;margin-top:4px;'><b>Manque :</b> ${missingTags.map(t => `<code>${t}</code>`).join(', ')}</div>`;
}
popupContent += `<br><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;
}
new maplibregl.Popup()
.setLngLat(coordinates)
.setHTML(popupContent)
.addTo(map);
});
map.on('mouseenter', 'clusters', function() {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'clusters', function() {
map.getCanvas().style.cursor = '';
});
});
} else {
const mapDiv = document.getElementById('map');
if (mapDiv) {
mapDiv.innerHTML = '<div class="alert alert-warning">Aucune donnée géographique à afficher pour cette zone.</div>';
}
document.getElementById('maploader').style.display = 'none';
}
const openInJOSMButton = document.getElementById('openInJOSM');
if (openInJOSMButton) {
openInJOSMButton.addEventListener('click', () => {
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]);
} else if (
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);
});
}
// === GRAPHIQUE FRÉQUENCE DES MISES À JOUR PAR TRIMESTRE ===
const modifData = {{ modificationsByQuarter|raw }};
const modifLabels = Object.keys(modifData);
const modifCounts = Object.values(modifData);
const modifCanvas = document.getElementById('modificationsByQuarterChart');
if (modifCanvas && modifLabels.length > 0) {
new 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>';
}
// Créer un graphique de la répartition des tags
const tagsCount = {};
const places = {{ geojson|raw }}.features;
places.forEach(place => {
const mainTag = place.properties.main_tag;
if (mainTag) {
tagsCount[mainTag] = (tagsCount[mainTag] || 0) + 1;
}
});
const sortedTags = Object.entries(tagsCount).sort(([, a], [, b]) => b - a);
const labels = sortedTags.map(item => item[0]);
const data = sortedTags.map(item => item[1]);
const container_tags= document.getElementById('repartition_tags');
console.log('répartition', tagsCount, container_tags)
if(!container_tags){
console.log('pas de container_tags', container_tags)
return;
}
const ctx = container_tags.getContext ? container_tags.getContext('2d') : null;
if(ctx){
new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
label: 'Répartition des tags',
data: data,
backgroundColor: [
'rgba(54, 162, 235, 0.7)',
'rgba(255, 99, 132, 0.7)',
'rgba(255, 206, 86, 0.7)',
'rgba(75, 192, 192, 0.7)',
'rgba(153, 102, 255, 0.7)',
'rgba(255, 159, 64, 0.7)',
'rgba(201, 203, 207, 0.7)'
],
borderColor: [
'rgba(54, 162, 235, 1)',
'rgba(255, 99, 132, 1)',
'rgba(255, 206, 86, 1)',
'rgba(75, 192, 192, 1)',
'rgba(153, 102, 255, 1)',
'rgba(255, 159, 64, 1)',
'rgba(201, 203, 207, 1)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'right',
},
title: {
display: true,
text: 'Répartition des tags principaux'
}
}
}
});
}
// Graphique de distribution du taux de complétion
const completionData = [];
{% for commerce in stats.places %}
completionData.push({{ commerce.getCompletionPercentage() }});
{% endfor %}
const completionDistribution = {};
completionData.forEach(percent => {
const range = Math.floor(percent / 10) * 10;
const key = `${range}-${range + 10}%`;
completionDistribution[key] = (completionDistribution[key] || 0) + 1;
});
const completionLabels = Object.keys(completionDistribution).sort((a, b) => {
return parseInt(a.split('-')[0]) - parseInt(b.split('-')[0]);
});
const completionValues = completionLabels.map(label => completionDistribution[label]);
const dc = document.getElementById('distribution_completion');
if(dc ){
const completionCtx = dc.getContext ? dc.getContext('2d') : null;
if(!completionCtx){
console.log('pas de completionCtx' )
return ;
}
new Chart(completionCtx, {
type: 'line',
data: {
labels: completionLabels,
tension: 0.3,
datasets: [{
label: 'Distribution du Taux de Complétion',
data: completionValues,
backgroundColor: 'rgba(75, 192, 192, 0.5)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
}]
},
options: {
scales: {
y: {
beginAtZero: true
}
},
responsive: true,
}
});
}else{
console.log('pas de distribution_completion')
}
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const trigger = document.querySelector('.completion-hover-trigger');
const content = document.getElementById('completionInfoContent');
const icon = document.getElementById('completionInfoIcon');
if (trigger && content) {
trigger.addEventListener('mouseenter', function() {
content.style.display = 'block';
if (icon) {
icon.classList.remove('bi-chevron-down');
icon.classList.add('bi-chevron-up');
}
});
trigger.addEventListener('mouseleave', function() {
content.style.display = 'none';
if (icon) {
icon.classList.remove('bi-chevron-up');
icon.classList.add('bi-chevron-down');
}
});
// Pour garder la popup ouverte si la souris va sur la popup
content.addEventListener('mouseenter', function() {
content.style.display = 'block';
});
content.addEventListener('mouseleave', function() {
content.style.display = 'none';
if (icon) {
icon.classList.remove('bi-chevron-up');
icon.classList.add('bi-chevron-down');
}
});
}
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('btn-geolocate');
btn && btn.addEventListener('click', function() {
console.log('[GEOLOC] Bouton cliqué');
if (!navigator.geolocation) {
alert('La géolocalisation n\'est pas supportée par ce navigateur.');
console.error('[GEOLOC] navigator.geolocation non supporté');
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;
console.log('[GEOLOC] Position obtenue', lat, lon);
// MapLibre
console.log('[GEOLOC] mapInstance', window.mapInstance);
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 {
console.error('[GEOLOC] mapInstance non défini ou flyTo non disponible', window.mapInstance);
}
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-geo-alt"></i> Me localiser';
}, function(err) {
alert('Impossible de vous localiser : ' + err.message);
console.error('[GEOLOC] Erreur de géolocalisation', err);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-geo-alt"></i> Me localiser';
});
});
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('ctc-json-test-btn');
const select = document.getElementById('ctc-json-select');
const dumpContainer = document.getElementById('ctc-json-dump-container');
const dump = document.getElementById('ctc-json-dump');
const error = document.getElementById('ctc-json-error');
if(btn && select) {
btn.addEventListener('click', function() {
const url = select.value;
dumpContainer.style.display = 'none';
error.style.display = 'none';
dump.textContent = '';
if(!url) return;
fetch(url)
.then(r => {
if(!r.ok) throw new Error('Erreur HTTP ' + r.status);
return r.json();
})
.then(data => {
dump.textContent = JSON.stringify(data, null, 2);
dumpContainer.style.display = '';
})
.catch(e => {
error.textContent = 'Erreur lors de la récupération du JSON : ' + e.message;
error.style.display = '';
});
});
}
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const ctcCompletionSeries = {{ ctc_completion_series|json_encode|raw }};
console.log('ctcCompletionSeries',ctcCompletionSeries)
// Exemple d'intégration dans un graphique Chart.js :
// Pour chaque type, ajouter une série CTC si elle existe
Object.keys(ctcCompletionSeries).forEach(function(type) {
const data = ctcCompletionSeries[type].map(pt => ({ x: pt.date, y: pt.value }));
// Ajoute la série au graphique correspondant (ex: name_count, hours_count...)
// À adapter selon l'ID du canvas et la structure du graphique
const canvasId = type.replace('_count','') + 'Chart';
const canvas = document.getElementById(canvasId);
if (!canvas) return;
// On suppose que le graphique existe déjà, on ajoute la série CTC
if (canvas.chart) {
canvas.chart.data.datasets.push({
label: 'CTC (Complète tes commerces)',
data: data,
borderColor: 'orange',
backgroundColor: 'rgba(255,165,0,0.1)',
fill: false,
yAxisID: 'y',
borderDash: [5,3],
datalabels: {
display: false
}
});
canvas.chart.update();
}
});
});
</script>
{% endblock %}