diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php
index 943bac7e..13f15391 100644
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -381,7 +381,7 @@ final class AdminController extends AbstractController
}
}
unset($row);
-
+
// Normalisation des scores pondérés entre 0 et 100
foreach ($podium_local as &$row) {
if ($maxPondere > 0 && $row['completion_pondere'] !== null) {
@@ -391,7 +391,7 @@ final class AdminController extends AbstractController
}
}
unset($row);
-
+
// Tri décroissant sur le score normalisé
usort($podium_local, function ($a, $b) {
return ($b['completion_pondere_normalisee'] ?? 0) <=> ($a['completion_pondere_normalisee'] ?? 0);
@@ -452,6 +452,8 @@ final class AdminController extends AbstractController
$progression7Days[$type] = \App\Service\FollowUpService::calculate7DayProgression($stats, $type);
}
$progression7Days['places'] = \App\Service\FollowUpService::calculate7DayProgression($stats, 'places');
+
+
return $this->render('admin/stats.html.twig', [
'stats' => $stats,
@@ -467,7 +469,7 @@ final class AdminController extends AbstractController
'latestFollowups' => $latestFollowups,
'followup_labels' => \App\Service\FollowUpService::getFollowUpThemes(),
'followup_icons' => \App\Service\FollowUpService::getFollowUpIcons(),
- 'progression7Days' => $progression7Days,
+ 'progression7Days' => $progression7Days,
'all_types' => \App\Service\FollowUpService::getFollowUpThemes(),
]);
}
@@ -491,7 +493,7 @@ final class AdminController extends AbstractController
$followups = $stats->getCityFollowUps();
$countData = [];
$completionData = [];
-
+
foreach ($followups as $fu) {
if ($fu->getName() === $theme . '_count') {
$countData[] = [
@@ -525,7 +527,7 @@ final class AdminController extends AbstractController
$josm_url = null;
}
// 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)) {
return [$matches[1], $matches[2]];
}
@@ -569,7 +571,7 @@ final class AdminController extends AbstractController
'lat' => $place->getLat(),
'lon' => $place->getLon(),
'name' => $place->getName(),
- 'tags' => [ 'main_tag' => $place->getMainTag() ],
+ 'tags' => ['main_tag' => $place->getMainTag()],
'is_complete' => !empty($place->getName()),
'osm_url' => 'https://www.openstreetmap.org/' . $place->getOsmKind() . '/' . $place->getOsmId(),
];
@@ -577,7 +579,7 @@ final class AdminController extends AbstractController
}
$geojson = [
'type' => 'FeatureCollection',
- 'features' => array_map(function($obj) {
+ 'features' => array_map(function ($obj) {
return [
'type' => 'Feature',
'geometry' => [
@@ -608,6 +610,7 @@ final class AdminController extends AbstractController
'completion_data' => json_encode($completionData),
'icons' => \App\Service\FollowUpService::getFollowUpIcons(),
'geojson' => json_encode($geojson),
+ 'overpass_query' => $overpass_query,
'josm_url' => $josm_url,
'center' => $center,
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null,
@@ -617,14 +620,16 @@ final class AdminController extends AbstractController
#[Route('/admin/placeType/{osm_kind}/{osm_id}', name: 'app_admin_by_osm_id')]
public function placeType(string $osm_kind, string $osm_id): Response
{
-
+
$place = $this->entityManager->getRepository(Place::class)->findOneBy(['osm_kind' => $osm_kind, 'osmId' => $osm_id]);
if ($place) {
- $this->actionLogger->log('admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id,
- 'name' => $place->getName(),
- 'code_insee' => $place->getZipCode(),
- 'uuid' => $place->getUuidForUrl()
- ]);
+ $this->actionLogger->log('admin/placeType', [
+ 'osm_kind' => $osm_kind,
+ 'osm_id' => $osm_id,
+ 'name' => $place->getName(),
+ 'code_insee' => $place->getZipCode(),
+ 'uuid' => $place->getUuidForUrl()
+ ]);
return $this->redirectToRoute('app_admin_commerce', ['id' => $place->getId()]);
} else {
$this->actionLogger->log('ERROR_admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id]);
@@ -1520,11 +1525,11 @@ final class AdminController extends AbstractController
public function podiumContributeursOsm(): Response
{
$this->actionLogger->log('admin/podium_contributeurs_osm', []);
-
+
// Récupérer tous les lieux avec un utilisateur OSM
$places = $this->entityManager->getRepository(Place::class)->findBy(['osm_user' => null], ['osm_user' => 'ASC']);
$places = array_filter($places, fn($place) => $place->getOsmUser() !== null);
-
+
// Compter les contributions par utilisateur
$contributions = [];
foreach ($places as $place) {
@@ -1536,13 +1541,13 @@ final class AdminController extends AbstractController
$contributions[$user]++;
}
}
-
+
// Trier par nombre de contributions décroissant
arsort($contributions);
-
+
// Prendre les 10 premiers
$topContributors = array_slice($contributions, 0, 10, true);
-
+
return $this->render('admin/podium_contributeurs_osm.html.twig', [
'contributors' => $topContributors
]);
@@ -1555,15 +1560,17 @@ final class AdminController extends AbstractController
if ($request->isMethod('POST')) {
$uploadedFile = $request->files->get('json_file');
-
+
if (!$uploadedFile) {
$this->addFlash('error', 'Aucun fichier JSON n\'a été fourni.');
return $this->redirectToRoute('app_admin_import_stats');
}
// Vérifier le type de fichier
- if ($uploadedFile->getClientMimeType() !== 'application/json' &&
- $uploadedFile->getClientOriginalExtension() !== 'json') {
+ if (
+ $uploadedFile->getClientMimeType() !== 'application/json' &&
+ $uploadedFile->getClientOriginalExtension() !== 'json'
+ ) {
$this->addFlash('error', 'Le fichier doit être au format JSON.');
return $this->redirectToRoute('app_admin_import_stats');
}
@@ -1598,7 +1605,7 @@ final class AdminController extends AbstractController
// Vérifier si l'objet Stats existe déjà
$existingStats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zone]);
-
+
if ($existingStats) {
$skippedCount++;
continue; // Ignorer les objets existants
@@ -1607,9 +1614,9 @@ final class AdminController extends AbstractController
// Créer un nouvel objet Stats
$stats = new Stats();
$stats->setZone($zone)
- ->setName($name)
- ->setDateCreated(new \DateTime())
- ->setDateModified(new \DateTime());
+ ->setName($name)
+ ->setDateCreated(new \DateTime())
+ ->setDateModified(new \DateTime());
// Remplir les champs optionnels
if (isset($statData['population'])) {
@@ -1656,7 +1663,6 @@ final class AdminController extends AbstractController
$this->entityManager->persist($stats);
$createdCount++;
-
} catch (\Exception $e) {
$errors[] = "Ligne " . ($index + 1) . ": " . $e->getMessage();
}
@@ -1680,7 +1686,6 @@ final class AdminController extends AbstractController
'skipped' => $skippedCount,
'errors' => count($errors)
]);
-
} catch (\Exception $e) {
$this->addFlash('error', 'Erreur lors de l\'import : ' . $e->getMessage());
$this->actionLogger->log('admin/import_stats_error', ['error' => $e->getMessage()]);
@@ -1696,7 +1701,7 @@ final class AdminController extends AbstractController
public function exportOverpassCsv($insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
-
+
if (!$stats) {
throw $this->createNotFoundException('Stats non trouvées pour ce code INSEE');
}
@@ -1747,7 +1752,7 @@ final class AdminController extends AbstractController
public function exportTableCsv($insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
-
+
if (!$stats) {
throw $this->createNotFoundException('Stats non trouvées pour ce code INSEE');
}
@@ -1784,7 +1789,7 @@ final class AdminController extends AbstractController
$osmKind = $place->getOsmKind();
$osmId = $place->getOsmId();
$osmLink = ($osmKind && $osmId) ? 'https://www.openstreetmap.org/' . $osmKind . '/' . $osmId : '';
-
+
// Construire l'adresse complète
$address = '';
if ($place->getHousenumber() && $place->getStreet()) {
@@ -1792,7 +1797,7 @@ final class AdminController extends AbstractController
} elseif ($place->getStreet()) {
$address = $place->getStreet();
}
-
+
fputcsv($output, [
$place->getName() ?: '(sans nom)',
$place->getEmail() ?: '',
diff --git a/src/Service/FollowUpService.php b/src/Service/FollowUpService.php
index 6a472228..4b0b3b91 100644
--- a/src/Service/FollowUpService.php
+++ b/src/Service/FollowUpService.php
@@ -480,7 +480,7 @@ class FollowUpService
public static function getFollowUpOverpassQueries(): array
{
- return [
+ return [
'fire_hydrant' => 'nwr["emergency"="fire_hydrant"](area.searchArea);',
'charging_station' => 'nwr["amenity"="charging_station"](area.searchArea);',
'toilets' => 'nwr["amenity"="toilets"](area.searchArea);',
@@ -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);',
'bicycle_parking' => 'nwr["amenity"="bicycle_parking"](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);',
'bench' => 'nwr["amenity"="bench"](area.searchArea);',
'waste_basket' => 'nwr["amenity"="waste_basket"](area.searchArea);',
diff --git a/src/Service/Motocultrice.php b/src/Service/Motocultrice.php
index 68e3bc58..742486ee 100644
--- a/src/Service/Motocultrice.php
+++ b/src/Service/Motocultrice.php
@@ -619,4 +619,59 @@ QUERY;
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;
+ }
}
\ No newline at end of file
diff --git a/templates/admin/followup_embed_graph.html.twig b/templates/admin/followup_embed_graph.html.twig
index cea0bfd2..2f431948 100644
--- a/templates/admin/followup_embed_graph.html.twig
+++ b/templates/admin/followup_embed_graph.html.twig
@@ -13,7 +13,7 @@
Graphe détaillé
-
+
OSM Mon Commerce
diff --git a/templates/admin/followup_theme_graph.html.twig b/templates/admin/followup_theme_graph.html.twig
index 442f5b17..653cc707 100644
--- a/templates/admin/followup_theme_graph.html.twig
+++ b/templates/admin/followup_theme_graph.html.twig
@@ -172,6 +172,14 @@
{% endif %}
+ {% if overpass_query is defined %}
+
+ {% endif %}
+
-
-
-
-
-
-
-
-
+
+
+ {#
+
Progression
+
+ Période | Nombre | Complétion (%) |
+
+ 7 jours | {{ progressions.count['7j'] is not null ? '%+d'|format(progressions.count['7j']) : '–' }} | {{ progressions.completion['7j'] is not null ? '%+.1f'|format(progressions.completion['7j']) : '–' }} |
+ 30 jours | {{ progressions.count['30j'] is not null ? '%+d'|format(progressions.count['30j']) : '–' }} | {{ progressions.completion['30j'] is not null ? '%+.1f'|format(progressions.completion['30j']) : '–' }} |
+ 6 mois | {{ progressions.count['6m'] is not null ? '%+d'|format(progressions.count['6m']) : '–' }} | {{ progressions.completion['6m'] is not null ? '%+.1f'|format(progressions.completion['6m']) : '–' }} |
+ 1 an | {{ progressions.count['1a'] is not null ? '%+d'|format(progressions.count['1a']) : '–' }} | {{ progressions.completion['1a'] is not null ? '%+.1f'|format(progressions.completion['1a']) : '–' }} |
+
+
+
#}
+
+
+
+
+
+
-
{% endblock %}
@@ -224,34 +245,78 @@
console.log('[DEBUG] mapCenter:', mapCenter);
if (mapToken && geojson && geojson.features && geojson.features.length > 0) {
console.log('[DEBUG] Initialisation de la carte Maplibre...');
- const map = new maplibregl.Map({
- container: 'themeMap',
- style: `https://api.maptiler.com/maps/streets/style.json?key=${mapToken}`,
- center: mapCenter || geojson.features[0].geometry.coordinates,
- zoom: 13
- });
- map.addControl(new maplibregl.NavigationControl());
- geojson.features.forEach(f => {
- let color = f.properties.is_complete ? '#198754' : '#adb5bd';
- if (!f.properties.is_complete && (f.properties.tags && (f.properties.tags.name || f.properties.tags.operator))) {
- color = '#ffc107'; // partiel
+ 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
+ }
+ ]
+ };
}
- const marker = new maplibregl.Marker({ color: color })
- .setLngLat(f.geometry.coordinates)
- .setPopup(new maplibregl.Popup({ offset: 18 })
- .setHTML(`
-
-
${f.properties.name || '(sans nom)'}
-
${f.properties.osm_kind} ${f.properties.id}
-
- ${Object.entries(f.properties.tags).map(([k,v]) => `${k}: ${v}`).join('
')}
-
-
Voir sur OSM
-
- `)
- )
- .addTo(map);
- });
+ return null;
+ }
+ function initMap(style) {
+ if (mapInstance) { mapInstance.remove(); }
+ const styleUrl = getStyleUrl(style);
+ mapInstance = new maplibregl.Map({
+ container: 'themeMap',
+ style: styleUrl,
+ center: mapCenter || (geojson.features && geojson.features[0] ? geojson.features[0].geometry.coordinates : [2,48]),
+ zoom: 13
+ });
+ mapInstance.addControl(new maplibregl.NavigationControl());
+ geojson.features.forEach(f => {
+ let color = f.properties.is_complete ? '#198754' : '#adb5bd';
+ if (!f.properties.is_complete && (f.properties.tags && (f.properties.tags.name || f.properties.tags.operator))) {
+ color = '#ffc107';
+ }
+ const marker = new maplibregl.Marker({ color: color })
+ .setLngLat(f.geometry.coordinates)
+ .setPopup(new maplibregl.Popup({ offset: 18 })
+ .setHTML(`
+
+
${f.properties.name || '(sans nom)'}
+
${f.properties.osm_kind} ${f.properties.id}
+
+ ${Object.entries(f.properties.tags).map(([k,v]) => `${k}: ${v}`).join('
')}
+
+
Voir sur OSM
+
+ `)
+ )
+ .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 {
console.warn('[DEBUG] Carte non initialisée : conditions non remplies.');
if (!mapToken) {
@@ -335,88 +400,74 @@
}
};
- // Graphique du nombre d'objets
- const countCtx = document.getElementById('countChart').getContext('2d');
- const countChart = new Chart(countCtx, {
+ // 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
- }]
- },
- options: {
- ...commonOptions,
- scales: {
- ...commonOptions.scales,
- y: {
- ...commonOptions.scales.y,
- title: {
- display: true,
- text: 'Nombre d\'objets'
- }
+ 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',
}
- }
- }
- });
-
- // 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',
- 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
- }]
+ ]
},
options: {
- ...commonOptions,
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: true },
+ tooltip: { mode: 'index', intersect: false }
+ },
+ interaction: { mode: 'nearest', axis: 'x', intersect: false },
scales: {
- ...commonOptions.scales,
- y: {
- ...commonOptions.scales.y,
+ 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,
- title: {
- display: true,
- text: 'Pourcentage de complétion (%)'
- }
+ grid: { drawOnChartArea: false }
}
}
}
});
- // 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
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 %}
{% endblock %}
\ No newline at end of file