diff --git a/assets/dashboard-charts.js b/assets/dashboard-charts.js index 3b9f26d..1c7734e 100644 --- a/assets/dashboard-charts.js +++ b/assets/dashboard-charts.js @@ -1,9 +1,176 @@ // Bubble chart du dashboard avec option de taille de bulle proportionnelle ou égale let bubbleChart = null; // Déclaré en dehors pour garder la référence +function waitForChartAndDrawBubble() { + if (!window.Chart || !window.ChartDataLabels) { + setTimeout(waitForChartAndDrawBubble, 50); + return; + } + const chartCanvas = document.getElementById('bubbleChart'); + const toggle = document.getElementById('toggleBubbleSize'); + + + + + function drawBubbleChart(proportional) { + // Détruire toute instance Chart.js existante sur ce canvas (Chart.js v3+) + const existing = window.Chart.getChart(chartCanvas); + if (existing) { + try { existing.destroy(); } catch (e) { console.warn('Erreur destroy Chart:', e); } + } + if (bubbleChart) { + try { bubbleChart.destroy(); } catch (e) { console.warn('Erreur destroy Chart:', e); } + bubbleChart = null; + } + + // Forcer le canvas à occuper toute la largeur/hauteur du conteneur en pixels + if (chartCanvas && chartCanvas.parentElement) { + const parentRect = chartCanvas.parentElement.getBoundingClientRect(); + console.log('parentRect', parentRect) + + chartCanvas.width = (parentRect.width); + chartCanvas.height = (parentRect.height); + chartCanvas.style.width = parentRect.width + 'px'; + chartCanvas.style.height = parentRect.height + 'px'; + } + + + if(!getBubbleData){ + console.log('pas de getBubbleData') + return ; + } + const bubbleChartData = getBubbleData(proportional); + + if(!bubbleChartData){ + console.log('pas de bubbleChartData') + return ; + } + + // Calcul de la régression linéaire (moindres carrés) + const validPoints = bubbleChartData.filter(d => d.x > 0 && d.y > 0); + const n = validPoints.length; + let regressionLine = null, slope = 0, intercept = 0; + if (n >= 2) { + let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0; + validPoints.forEach(d => { + sumX += Math.log10(d.x); + sumY += d.y; + sumXY += Math.log10(d.x) * d.y; + sumXX += Math.log10(d.x) * Math.log10(d.x); + }); + const meanX = sumX / n; + const meanY = sumY / n; + slope = (sumXY - n * meanX * meanY) / (sumXX - n * meanX * meanX); + intercept = meanY - slope * meanX; + const xMin = Math.min(...validPoints.map(d => d.x)); + const xMax = Math.max(...validPoints.map(d => d.x)); + regressionLine = [ + { x: xMin, y: slope * Math.log10(xMin) + intercept }, + { x: xMax, y: slope * Math.log10(xMax) + intercept } + ]; + } + window.Chart.register(window.ChartDataLabels); + bubbleChart = new window.Chart(chartCanvas.getContext('2d'), { + type: 'bubble', + data: { + datasets: [ + { + label: 'Villes', + data: bubbleChartData, + backgroundColor: bubbleChartData.map(d => `rgba(94, 255, 121, ${d.completion / 100})`), + borderColor: 'rgb(94, 255, 121)', + datalabels: { + anchor: 'center', + align: 'center', + color: '#000', + display: true, + font: { weight: '400', size : "12px" }, + formatter: (value, context) => { + return context.dataset.data[context.dataIndex].label; + } + } + }, + regressionLine ? { + label: 'Régression linéaire', + type: 'line', + data: regressionLine, + borderColor: 'rgba(95, 168, 0, 0.7)', + borderWidth: 2, + pointRadius: 0, + fill: false, + order: 0, + tension: 0, + datalabels: { display: false } + } : null + ].filter(Boolean) + }, + options: { + // responsive: true, + plugins: { + datalabels: { + // Désactivé au niveau global, activé par dataset + display: false + }, + legend: { display: true }, + tooltip: { + callbacks: { + label: (context) => { + const d = context.raw; + if (context.dataset.type === 'line') { + return `Régression: y = ${slope.toFixed(2)} × log10(x) + ${intercept.toFixed(2)}`; + } + return [ + `${d.label}`, + `Population: ${d.x.toLocaleString()}`, + `Lieux / hab: ${d.y.toFixed(2)}`, + `Total lieux: ${Math.round(Math.pow(d.r / 2, 2))}`, + `Complétion: ${d.completion}%` + ]; + } + } + } + }, + scales: { + x: { + type: 'logarithmic', + title: { display: true, text: 'Population (échelle log)' } + }, + y: { + title: { display: true, text: 'Completion' } + } + } + } + }); + // Ajout du clic sur une bulle + chartCanvas.onclick = function(evt) { + const points = bubbleChart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true); + if (points.length > 0) { + const firstPoint = points[0]; + const dataIndex = firstPoint.index; + const stat = window.statsDataForBubble[dataIndex]; + if (stat && stat.zone) { + window.location.href = '/admin/stats/' + stat.zone; + } + } + }; + } + + // Initial draw + console.log('[bubble chart] Initialisation avec taille proportionnelle ?', toggle?.checked); + if(drawBubbleChart){ + + drawBubbleChart(toggle && toggle.checked); + // Listener + toggle?.addEventListener('change', function() { + console.log('[bubble chart] Toggle changé, taille proportionnelle ?', toggle?.checked); + drawBubbleChart(toggle?.checked); + }); + } +} + function getBubbleData(proportional) { // Générer les données puis trier par rayon décroissant - const data = window.statsDataForBubble.map(stat => { + const data = window.statsDataForBubble?.map(stat => { const population = parseInt(stat.population, 10); const placesCount = parseInt(stat.placesCount, 10); // const ratio = population > 0 ? (placesCount / population) * 1000 : 0; @@ -18,167 +185,13 @@ function getBubbleData(proportional) { }; }); // Trier du plus gros au plus petit rayon - data.sort((a, b) => b.r - a.r); + if(data){ + + data.sort((a, b) => b.r - a.r); + } return data; } - - document.addEventListener('DOMContentLoaded', function() { - function waitForChartAndDrawBubble() { - if (!window.Chart || !window.ChartDataLabels) { - setTimeout(waitForChartAndDrawBubble, 50); - return; - } - const chartCanvas = document.getElementById('bubbleChart'); - const toggle = document.getElementById('toggleBubbleSize'); - - - - - function drawBubbleChart(proportional) { - // Détruire toute instance Chart.js existante sur ce canvas (Chart.js v3+) - const existing = window.Chart.getChart(chartCanvas); - if (existing) { - try { existing.destroy(); } catch (e) { console.warn('Erreur destroy Chart:', e); } - } - if (bubbleChart) { - try { bubbleChart.destroy(); } catch (e) { console.warn('Erreur destroy Chart:', e); } - bubbleChart = null; - } - - // Forcer le canvas à occuper toute la largeur/hauteur du conteneur en pixels - if (chartCanvas && chartCanvas.parentElement) { - const parentRect = chartCanvas.parentElement.getBoundingClientRect(); - console.log('parentRect', parentRect) - - chartCanvas.width = (parentRect.width); - chartCanvas.height = (parentRect.height); - chartCanvas.style.width = parentRect.width + 'px'; - chartCanvas.style.height = parentRect.height + 'px'; - } - - - const bubbleChartData = getBubbleData(proportional); - // Calcul de la régression linéaire (moindres carrés) - const validPoints = bubbleChartData.filter(d => d.x > 0 && d.y > 0); - const n = validPoints.length; - let regressionLine = null, slope = 0, intercept = 0; - if (n >= 2) { - let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0; - validPoints.forEach(d => { - sumX += Math.log10(d.x); - sumY += d.y; - sumXY += Math.log10(d.x) * d.y; - sumXX += Math.log10(d.x) * Math.log10(d.x); - }); - const meanX = sumX / n; - const meanY = sumY / n; - slope = (sumXY - n * meanX * meanY) / (sumXX - n * meanX * meanX); - intercept = meanY - slope * meanX; - const xMin = Math.min(...validPoints.map(d => d.x)); - const xMax = Math.max(...validPoints.map(d => d.x)); - regressionLine = [ - { x: xMin, y: slope * Math.log10(xMin) + intercept }, - { x: xMax, y: slope * Math.log10(xMax) + intercept } - ]; - } - window.Chart.register(window.ChartDataLabels); - bubbleChart = new window.Chart(chartCanvas.getContext('2d'), { - type: 'bubble', - data: { - datasets: [ - { - label: 'Villes', - data: bubbleChartData, - backgroundColor: bubbleChartData.map(d => `rgba(94, 255, 121, ${d.completion / 100})`), - borderColor: 'rgb(94, 255, 121)', - datalabels: { - anchor: 'center', - align: 'center', - color: '#000', - display: true, - font: { weight: '400', size : "1rem" }, - formatter: (value, context) => { - return context.dataset.data[context.dataIndex].label; - } - } - }, - regressionLine ? { - label: 'Régression linéaire', - type: 'line', - data: regressionLine, - borderColor: 'rgba(162, 255, 40, 0.7)', - borderWidth: 2, - pointRadius: 0, - fill: false, - order: 0, - tension: 0, - datalabels: { display: false } - } : null - ].filter(Boolean) - }, - options: { - // responsive: true, - plugins: { - datalabels: { - // Désactivé au niveau global, activé par dataset - display: false - }, - legend: { display: true }, - tooltip: { - callbacks: { - label: (context) => { - const d = context.raw; - if (context.dataset.type === 'line') { - return `Régression: y = ${slope.toFixed(2)} × log10(x) + ${intercept.toFixed(2)}`; - } - return [ - `${d.label}`, - `Population: ${d.x.toLocaleString()}`, - `Lieux / hab: ${d.y.toFixed(2)}`, - `Total lieux: ${Math.round(Math.pow(d.r / 2, 2))}`, - `Complétion: ${d.completion}%` - ]; - } - } - } - }, - scales: { - x: { - type: 'logarithmic', - title: { display: true, text: 'Population (échelle log)' } - }, - y: { - title: { display: true, text: 'Completion' } - } - } - } - }); - // Ajout du clic sur une bulle - chartCanvas.onclick = function(evt) { - const points = bubbleChart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true); - if (points.length > 0) { - const firstPoint = points[0]; - const dataIndex = firstPoint.index; - const stat = window.statsDataForBubble[dataIndex]; - if (stat && stat.zone) { - window.location.href = '/admin/stats/' + stat.zone; - } - } - }; - } - - // Initial draw - console.log('[bubble chart] Initialisation avec taille proportionnelle ?', toggle.checked); - if(drawBubbleChart){ - - drawBubbleChart(toggle && toggle.checked); - // Listener - toggle.addEventListener('change', function() { - console.log('[bubble chart] Toggle changé, taille proportionnelle ?', toggle.checked); - drawBubbleChart(toggle.checked); - }); - } - } + waitForChartAndDrawBubble(); }); \ No newline at end of file diff --git a/src/Controller/ApiController.php b/src/Controller/ApiController.php new file mode 100644 index 0000000..24ed9a9 --- /dev/null +++ b/src/Controller/ApiController.php @@ -0,0 +1,201 @@ +findAll(); + $features = []; + foreach ($statsList as $stats) { + // Calcul du barycentre des commerces de la zone + $lat = null; + $lon = null; + $places = $stats->getPlaces(); + $count = 0; + $sumLat = 0; + $sumLon = 0; + foreach ($places as $place) { + if ($place->getLat() && $place->getLon()) { + $sumLat += $place->getLat(); + $sumLon += $place->getLon(); + $count++; + } + } + if ($count > 0) { + $lat = $sumLat / $count; + $lon = $sumLon / $count; + } + $feature = [ + 'type' => 'Feature', + 'geometry' => $lat && $lon ? [ + 'type' => 'Point', + 'coordinates' => [$lon, $lat], + ] : null, + 'properties' => [ + 'id' => $stats->getId(), + 'name' => $stats->getName(), + 'zone' => $stats->getZone(), + 'completion_percent' => $stats->getCompletionPercent(), + 'places_count' => $stats->getPlacesCount(), + 'avec_horaires' => $stats->getAvecHoraires(), + 'avec_adresse' => $stats->getAvecAdresse(), + 'avec_site' => $stats->getAvecSite(), + 'avec_accessibilite' => $stats->getAvecAccessibilite(), + 'avec_note' => $stats->getAvecNote(), + 'population' => $stats->getPopulation(), + 'siren' => $stats->getSiren(), + 'codeEpci' => $stats->getCodeEpci(), + 'codesPostaux' => $stats->getCodesPostaux(), + 'date_created' => $stats->getDateCreated() ? $stats->getDateCreated()->format('c') : null, + 'date_modified' => $stats->getDateModified() ? $stats->getDateModified()->format('c') : null, + ], + ]; + $features[] = $feature; + } + $geojson = [ + 'type' => 'FeatureCollection', + 'features' => $features, + 'meta' => [ + 'generated_at' => (new \DateTime())->format('c'), + 'source' => 'https://osm-commerces.cipherbliss.com/api/v1/stats_geojson' + ] + ]; + return new JsonResponse($geojson, Response::HTTP_OK, [ + 'Content-Type' => 'application/geo+json' + ]); + } + + #[Route('/api/v1/stats/{insee}', name: 'api_stats_by_insee', methods: ['GET'])] + public function statsByInsee(StatsRepository $statsRepository, string $insee): JsonResponse + { + $stats = $statsRepository->findOneBy(['zone' => $insee]); + if (!$stats) { + return new JsonResponse(['error' => 'Zone non trouvée'], Response::HTTP_NOT_FOUND); + } + $data = [ + 'id' => $stats->getId(), + 'name' => $stats->getName(), + 'zone' => $stats->getZone(), + 'completion_percent' => $stats->getCompletionPercent(), + 'places_count' => $stats->getPlacesCount(), + 'avec_horaires' => $stats->getAvecHoraires(), + 'avec_adresse' => $stats->getAvecAdresse(), + 'avec_site' => $stats->getAvecSite(), + 'avec_accessibilite' => $stats->getAvecAccessibilite(), + 'avec_note' => $stats->getAvecNote(), + 'population' => $stats->getPopulation(), + 'siren' => $stats->getSiren(), + 'codeEpci' => $stats->getCodeEpci(), + 'codesPostaux' => $stats->getCodesPostaux(), + 'date_created' => $stats->getDateCreated() ? $stats->getDateCreated()->format('c') : null, + 'date_modified' => $stats->getDateModified() ? $stats->getDateModified()->format('c') : null, + ]; + return new JsonResponse($data, Response::HTTP_OK); + } + + #[Route('/api/v1/stats/{insee}/places', name: 'api_stats_places_by_insee', methods: ['GET'])] + public function statsPlacesByInsee(StatsRepository $statsRepository, string $insee): JsonResponse + { + $stats = $statsRepository->findOneBy(['zone' => $insee]); + if (!$stats) { + return new JsonResponse(['error' => 'Zone non trouvée'], Response::HTTP_NOT_FOUND); + } + $features = []; + foreach ($stats->getPlaces() as $place) { + $lat = $place->getLat(); + $lon = $place->getLon(); + $feature = [ + 'type' => 'Feature', + 'geometry' => ($lat && $lon) ? [ + 'type' => 'Point', + 'coordinates' => [$lon, $lat], + ] : null, + 'properties' => [ + 'id' => $place->getId(), + 'name' => $place->getName(), + 'main_tag' => $place->getMainTag(), + 'osmId' => $place->getOsmId(), + 'email' => $place->getEmail(), + 'note' => $place->getNote(), + 'zip_code' => $place->getZipCode(), + 'siret' => $place->getSiret(), + 'has_opening_hours' => $place->hasOpeningHours(), + 'has_address' => $place->hasAddress(), + 'has_website' => $place->hasWebsite(), + 'has_wheelchair' => $place->hasWheelchair(), + 'has_note' => $place->hasNote(), + 'completion_percent' => $place->getCompletionPercentage(), + ], + ]; + $features[] = $feature; + } + $geojson = [ + 'type' => 'FeatureCollection', + 'features' => $features, + 'meta' => [ + 'generated_at' => (new \DateTime())->format('c'), + 'source' => 'https://osm-commerces.cipherbliss.com/api/v1/stats/by_insee/' . $insee . '/places' + ] + ]; + return new JsonResponse($geojson, Response::HTTP_OK, [ + 'Content-Type' => 'application/geo+json' + ]); + } + + #[Route('/api/v1/stats/by_insee/{insee}/places.csv', name: 'api_stats_places_csv_by_insee', methods: ['GET'])] + public function statsPlacesCsvByInsee(StatsRepository $statsRepository, string $insee): StreamedResponse + { + $stats = $statsRepository->findOneBy(['zone' => $insee]); + if (!$stats) { + $response = new StreamedResponse(); + $response->setCallback(function() { + echo 'error\nZone non trouvée'; + }); + $response->headers->set('Content-Type', 'text/csv'); + $response->setStatusCode(Response::HTTP_NOT_FOUND); + return $response; + } + $response = new StreamedResponse(function() use ($stats) { + $handle = fopen('php://output', 'w'); + // En-têtes CSV + fputcsv($handle, [ + 'id', 'name', 'main_tag', 'osmId', 'email', 'note', 'zip_code', 'siret', 'lat', 'lon', 'has_opening_hours', 'has_address', 'has_website', 'has_wheelchair', 'has_note' + ]); + foreach ($stats->getPlaces() as $place) { + fputcsv($handle, [ + $place->getId(), + $place->getName(), + $place->getMainTag(), + $place->getOsmId(), + $place->getEmail(), + $place->getNote(), + $place->getZipCode(), + $place->getSiret(), + $place->getLat(), + $place->getLon(), + $place->hasOpeningHours(), + $place->hasAddress(), + $place->hasWebsite(), + $place->hasWheelchair(), + $place->hasNote(), + $place->getCompletionPercentage(), + ]); + } + fclose($handle); + }); + $response->headers->set('Content-Type', 'text/csv'); + $response->headers->set('Content-Disposition', 'attachment; filename="places_'.$insee.'.csv"'); + return $response; + } +} \ No newline at end of file diff --git a/templates/admin/stats.html.twig b/templates/admin/stats.html.twig index a125ce2..4139545 100644 --- a/templates/admin/stats.html.twig +++ b/templates/admin/stats.html.twig @@ -258,27 +258,27 @@ #}
Comment est calculé le score de complétion ?
- -Comment est calculé le score de complétion ?
+