carte thèmatique

This commit is contained in:
Tykayn 2025-07-05 16:15:56 +02:00 committed by tykayn
parent 2fd0d8d933
commit 5fe2804a1a
5 changed files with 253 additions and 142 deletions

View file

@ -453,6 +453,8 @@ final class AdminController extends AbstractController
} }
$progression7Days['places'] = \App\Service\FollowUpService::calculate7DayProgression($stats, 'places'); $progression7Days['places'] = \App\Service\FollowUpService::calculate7DayProgression($stats, 'places');
return $this->render('admin/stats.html.twig', [ return $this->render('admin/stats.html.twig', [
'stats' => $stats, 'stats' => $stats,
'commerces' => $commerces, 'commerces' => $commerces,
@ -525,7 +527,7 @@ final class AdminController extends AbstractController
$josm_url = null; $josm_url = null;
} }
// Fonction utilitaire pour extraire clé/valeur de la requête Overpass // Fonction utilitaire pour extraire clé/valeur de la requête Overpass
$extractTag = function($query) { $extractTag = function ($query) {
if (preg_match('/\\[([a-zA-Z0-9:_-]+)\\]="([^"]+)"/', $query, $matches)) { if (preg_match('/\\[([a-zA-Z0-9:_-]+)\\]="([^"]+)"/', $query, $matches)) {
return [$matches[1], $matches[2]]; return [$matches[1], $matches[2]];
} }
@ -569,7 +571,7 @@ final class AdminController extends AbstractController
'lat' => $place->getLat(), 'lat' => $place->getLat(),
'lon' => $place->getLon(), 'lon' => $place->getLon(),
'name' => $place->getName(), 'name' => $place->getName(),
'tags' => [ 'main_tag' => $place->getMainTag() ], 'tags' => ['main_tag' => $place->getMainTag()],
'is_complete' => !empty($place->getName()), 'is_complete' => !empty($place->getName()),
'osm_url' => 'https://www.openstreetmap.org/' . $place->getOsmKind() . '/' . $place->getOsmId(), 'osm_url' => 'https://www.openstreetmap.org/' . $place->getOsmKind() . '/' . $place->getOsmId(),
]; ];
@ -577,7 +579,7 @@ final class AdminController extends AbstractController
} }
$geojson = [ $geojson = [
'type' => 'FeatureCollection', 'type' => 'FeatureCollection',
'features' => array_map(function($obj) { 'features' => array_map(function ($obj) {
return [ return [
'type' => 'Feature', 'type' => 'Feature',
'geometry' => [ 'geometry' => [
@ -608,6 +610,7 @@ final class AdminController extends AbstractController
'completion_data' => json_encode($completionData), 'completion_data' => json_encode($completionData),
'icons' => \App\Service\FollowUpService::getFollowUpIcons(), 'icons' => \App\Service\FollowUpService::getFollowUpIcons(),
'geojson' => json_encode($geojson), 'geojson' => json_encode($geojson),
'overpass_query' => $overpass_query,
'josm_url' => $josm_url, 'josm_url' => $josm_url,
'center' => $center, 'center' => $center,
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null, 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null,
@ -620,7 +623,9 @@ final class AdminController extends AbstractController
$place = $this->entityManager->getRepository(Place::class)->findOneBy(['osm_kind' => $osm_kind, 'osmId' => $osm_id]); $place = $this->entityManager->getRepository(Place::class)->findOneBy(['osm_kind' => $osm_kind, 'osmId' => $osm_id]);
if ($place) { if ($place) {
$this->actionLogger->log('admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id, $this->actionLogger->log('admin/placeType', [
'osm_kind' => $osm_kind,
'osm_id' => $osm_id,
'name' => $place->getName(), 'name' => $place->getName(),
'code_insee' => $place->getZipCode(), 'code_insee' => $place->getZipCode(),
'uuid' => $place->getUuidForUrl() 'uuid' => $place->getUuidForUrl()
@ -1562,8 +1567,10 @@ final class AdminController extends AbstractController
} }
// Vérifier le type de fichier // Vérifier le type de fichier
if ($uploadedFile->getClientMimeType() !== 'application/json' && if (
$uploadedFile->getClientOriginalExtension() !== 'json') { $uploadedFile->getClientMimeType() !== 'application/json' &&
$uploadedFile->getClientOriginalExtension() !== 'json'
) {
$this->addFlash('error', 'Le fichier doit être au format JSON.'); $this->addFlash('error', 'Le fichier doit être au format JSON.');
return $this->redirectToRoute('app_admin_import_stats'); return $this->redirectToRoute('app_admin_import_stats');
} }
@ -1656,7 +1663,6 @@ final class AdminController extends AbstractController
$this->entityManager->persist($stats); $this->entityManager->persist($stats);
$createdCount++; $createdCount++;
} catch (\Exception $e) { } catch (\Exception $e) {
$errors[] = "Ligne " . ($index + 1) . ": " . $e->getMessage(); $errors[] = "Ligne " . ($index + 1) . ": " . $e->getMessage();
} }
@ -1680,7 +1686,6 @@ final class AdminController extends AbstractController
'skipped' => $skippedCount, 'skipped' => $skippedCount,
'errors' => count($errors) 'errors' => count($errors)
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->addFlash('error', 'Erreur lors de l\'import : ' . $e->getMessage()); $this->addFlash('error', 'Erreur lors de l\'import : ' . $e->getMessage());
$this->actionLogger->log('admin/import_stats_error', ['error' => $e->getMessage()]); $this->actionLogger->log('admin/import_stats_error', ['error' => $e->getMessage()]);

View file

@ -495,7 +495,7 @@ class FollowUpService
'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);', '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);',
'bicycle_parking' => 'nwr["amenity"="bicycle_parking"](area.searchArea);', 'bicycle_parking' => 'nwr["amenity"="bicycle_parking"](area.searchArea);',
'advertising_board' => 'nwr["advertising"="board"]["message"="political"](area.searchArea);', 'advertising_board' => 'nwr["advertising"="board"]["message"="political"](area.searchArea);',
'building' => 'way["building"](area.searchArea);', 'building' => 'nwr["building"](area.searchArea);',
'email' => 'nwr["email"](area.searchArea);nwr["contact:email"](area.searchArea);', 'email' => 'nwr["email"](area.searchArea);nwr["contact:email"](area.searchArea);',
'bench' => 'nwr["amenity"="bench"](area.searchArea);', 'bench' => 'nwr["amenity"="bench"](area.searchArea);',
'waste_basket' => 'nwr["amenity"="waste_basket"](area.searchArea);', 'waste_basket' => 'nwr["amenity"="waste_basket"](area.searchArea);',

View file

@ -619,4 +619,59 @@ QUERY;
return []; return [];
} }
} }
/**
* Calcule la progression sur 7j, 30j, 6 mois, 1 an pour une série de points [{date, value}]
* @param array $data
* @param \DateTime|null $now
* @return array
*/
public function calculateProgressions(array $data, ?\DateTime $now = null): array
{
$periods = [
'7j' => '-7 days',
'30j' => '-30 days',
'6m' => '-6 months',
'1a' => '-1 year',
];
$progressions = [];
if ($now === null) $now = new \DateTime();
$calculateDelta = function(array $data, \DateTime $refDate) {
if (empty($data)) return null;
$last = end($data)['value'];
$exactRef = null;
foreach (array_reverse($data) as $point) {
$pointDate = new \DateTime($point['date']);
if ($pointDate <= $refDate) {
$exactRef = $point['value'];
break;
}
}
if ($exactRef !== null) return $last - $exactRef;
$beforeRef = null; $afterRef = null; $beforeDate = null; $afterDate = null;
foreach (array_reverse($data) as $point) {
$pointDate = new \DateTime($point['date']);
if ($pointDate < $refDate) { $beforeRef = $point['value']; $beforeDate = $pointDate; break; }
}
foreach ($data as $point) {
$pointDate = new \DateTime($point['date']);
if ($pointDate > $refDate) { $afterRef = $point['value']; $afterDate = $pointDate; break; }
}
if ($beforeRef !== null && $afterRef !== null && $beforeDate && $afterDate) {
$timeDiff = $afterDate->getTimestamp() - $beforeDate->getTimestamp();
$refTimeDiff = $refDate->getTimestamp() - $beforeDate->getTimestamp();
$ratio = $refTimeDiff / $timeDiff;
$interpolatedRef = $beforeRef + ($afterRef - $beforeRef) * $ratio;
return $last - $interpolatedRef;
}
if ($beforeRef !== null) return $last - $beforeRef;
if ($afterRef !== null) return $last - $afterRef;
return null;
};
foreach ($periods as $label => $mod) {
$refDate = (clone $now)->modify($mod);
$progressions[$label] = $calculateDelta($data, $refDate);
}
return $progressions;
}
} }

View file

@ -13,7 +13,7 @@
<a href="{{ path('admin_followup_theme_graph', {'insee_code': stats.zone, 'theme': theme}) }}" class="btn btn-primary me-2"> <a href="{{ path('admin_followup_theme_graph', {'insee_code': stats.zone, 'theme': theme}) }}" class="btn btn-primary me-2">
<i class="bi bi-graph-up"></i> Graphe détaillé <i class="bi bi-graph-up"></i> Graphe détaillé
</a> </a>
<a href="https://osm-mon-commerce.fr/?insee={{ stats.zone }}" target="_blank" class="btn btn-success me-2"> <a href="{{ path('app_public_index') }}" target="_blank" class="btn btn-success me-2">
<i class="bi bi-globe"></i> OSM Mon Commerce <i class="bi bi-globe"></i> OSM Mon Commerce
</a> </a>
</div> </div>

View file

@ -172,6 +172,14 @@
{% endif %} {% endif %}
<div id="themeMap"></div> <div id="themeMap"></div>
{% 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">
<i class="bi bi-box-arrow-up-right"></i> Voir la requête sur Overpass Turbo
</a>
</div>
{% endif %}
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-value" id="currentCount">-</div> <div class="stat-value" id="currentCount">-</div>
@ -191,21 +199,34 @@
</div> </div>
</div> </div>
<div class="chart-tabs"> <div class="row mb-3">
<button class="chart-tab active" data-chart="count">Nombre d'objets</button> <div class="col-md-6">
<button class="chart-tab" data-chart="completion">Pourcentage de complétion</button> {# <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 class="chart-content active" id="count-chart">
<div class="chart-container">
<canvas id="countChart"></canvas>
</div> </div>
</div> </div>
<div class="chart-content" id="completion-chart">
<div class="chart-container"> <div class="chart-container">
<canvas id="completionChart"></canvas> <canvas id="themeChart"></canvas>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@ -224,17 +245,51 @@
console.log('[DEBUG] mapCenter:', mapCenter); console.log('[DEBUG] mapCenter:', mapCenter);
if (mapToken && geojson && geojson.features && geojson.features.length > 0) { if (mapToken && geojson && geojson.features && geojson.features.length > 0) {
console.log('[DEBUG] Initialisation de la carte Maplibre...'); console.log('[DEBUG] Initialisation de la carte Maplibre...');
const map = new maplibregl.Map({ let mapInstance = null;
function getStyleUrl(style) {
if (style === 'streets') {
return `https://api.maptiler.com/maps/streets/style.json?key=${mapToken}`;
} else if (style === 'satellite') {
// BD Ortho IGN WMTS (clé publique, usage limité)
return {
version: 8,
sources: {
"bdortho": {
"type": "raster",
"tiles": [
"https://wxs.ign.fr/essentiels/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}"
],
"tileSize": 256,
"attribution": "Données © IGN BD Ortho"
}
},
layers: [
{
"id": "bdortho",
"type": "raster",
"source": "bdortho",
"minzoom": 0,
"maxzoom": 19
}
]
};
}
return null;
}
function initMap(style) {
if (mapInstance) { mapInstance.remove(); }
const styleUrl = getStyleUrl(style);
mapInstance = new maplibregl.Map({
container: 'themeMap', container: 'themeMap',
style: `https://api.maptiler.com/maps/streets/style.json?key=${mapToken}`, style: styleUrl,
center: mapCenter || geojson.features[0].geometry.coordinates, center: mapCenter || (geojson.features && geojson.features[0] ? geojson.features[0].geometry.coordinates : [2,48]),
zoom: 13 zoom: 13
}); });
map.addControl(new maplibregl.NavigationControl()); mapInstance.addControl(new maplibregl.NavigationControl());
geojson.features.forEach(f => { geojson.features.forEach(f => {
let color = f.properties.is_complete ? '#198754' : '#adb5bd'; let color = f.properties.is_complete ? '#198754' : '#adb5bd';
if (!f.properties.is_complete && (f.properties.tags && (f.properties.tags.name || f.properties.tags.operator))) { if (!f.properties.is_complete && (f.properties.tags && (f.properties.tags.name || f.properties.tags.operator))) {
color = '#ffc107'; // partiel color = '#ffc107';
} }
const marker = new maplibregl.Marker({ color: color }) const marker = new maplibregl.Marker({ color: color })
.setLngLat(f.geometry.coordinates) .setLngLat(f.geometry.coordinates)
@ -250,8 +305,18 @@
</div> </div>
`) `)
) )
.addTo(map); .addTo(mapInstance);
}); });
}
// Initialisation par défaut
initMap('streets');
// Sélecteur de fond de carte
const basemapSelect = document.getElementById('basemapSelect');
if (basemapSelect) {
basemapSelect.addEventListener('change', function() {
initMap(this.value);
});
}
} else { } else {
console.warn('[DEBUG] Carte non initialisée : conditions non remplies.'); console.warn('[DEBUG] Carte non initialisée : conditions non remplies.');
if (!mapToken) { if (!mapToken) {
@ -335,88 +400,74 @@
} }
}; };
// Graphique du nombre d'objets // Graphique fusionné
const countCtx = document.getElementById('countChart').getContext('2d'); const ctx = document.getElementById('themeChart').getContext('2d');
const countChart = new Chart(countCtx, { new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
datasets: [{ datasets: [
label: 'Nombre d\'objets', {
data: countData.map(d => ({ label: "Nombre d'objets",
x: new Date(d.date), data: countData.map(d => ({ x: new Date(d.date), y: d.value })),
y: d.value
})),
borderColor: '#0d6efd', borderColor: '#0d6efd',
backgroundColor: 'rgba(13, 110, 253, 0.1)', backgroundColor: 'rgba(13, 110, 253, 0.1)',
borderWidth: 2, borderWidth: 2,
fill: true, fill: true,
tension: 0.1 tension: 0.1,
}] yAxisID: 'y1',
}, },
options: { {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
title: {
display: true,
text: 'Nombre d\'objets'
}
}
}
}
});
// Graphique de la complétion
const completionCtx = document.getElementById('completionChart').getContext('2d');
const completionChart = new Chart(completionCtx, {
type: 'line',
data: {
datasets: [{
label: 'Pourcentage de complétion', label: 'Pourcentage de complétion',
data: completionData.map(d => ({ data: completionData.map(d => ({ x: new Date(d.date), y: d.value })),
x: new Date(d.date),
y: d.value
})),
borderColor: '#198754', borderColor: '#198754',
backgroundColor: 'rgba(25, 135, 84, 0.1)', backgroundColor: 'rgba(25, 135, 84, 0.1)',
borderWidth: 2, borderWidth: 2,
fill: true, fill: true,
tension: 0.1 tension: 0.1,
}] yAxisID: 'y2',
}
]
}, },
options: { options: {
...commonOptions, responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true },
tooltip: { mode: 'index', intersect: false }
},
interaction: { mode: 'nearest', axis: 'x', intersect: false },
scales: { scales: {
...commonOptions.scales, x: {
y: { type: 'time',
...commonOptions.scales.y, 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, max: 100,
title: { grid: { drawOnChartArea: false }
display: true,
text: 'Pourcentage de complétion (%)'
} }
} }
} }
}
});
// Gestion des onglets
document.querySelectorAll('.chart-tab').forEach(tab => {
tab.addEventListener('click', function() {
// Retirer la classe active de tous les onglets
document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.chart-content').forEach(c => c.classList.remove('active'));
// Ajouter la classe active à l'onglet cliqué
this.classList.add('active');
const chartId = this.getAttribute('data-chart') + '-chart';
document.getElementById(chartId).classList.add('active');
});
}); });
// Initialiser les statistiques // Initialiser les statistiques
updateStats(); 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> </script>
{% endblock %} {% endblock %}