up stats dashboard

This commit is contained in:
Tykayn 2025-06-21 12:24:06 +02:00 committed by tykayn
parent fd3827ee52
commit 711fc277be
5 changed files with 246 additions and 96 deletions

View file

@ -29,96 +29,6 @@ window.getLabourerUrl = getLabourerUrl;
Chart.register(ChartDataLabels);
function initDashboardChart() {
const chartCanvas = document.getElementById('statsBubbleChart');
if (!chartCanvas) {
return;
}
if (!chartCanvas.dataset.stats || chartCanvas.dataset.stats.length <= 2) { // <= 2 pour ignorer '[]'
console.log("Les données du graphique sont vides ou absentes, le graphique ne sera pas affiché.");
return;
}
const statsData = JSON.parse(chartCanvas.dataset.stats);
if (statsData && statsData.length > 0) {
const bubbleChartData = statsData.map(stat => {
const population = parseInt(stat.population, 10);
const placesCount = parseInt(stat.placesCount, 10);
const ratio = population > 0 ? (placesCount / population) * 1000 : 0;
return {
x: population,
y: ratio,
r: Math.sqrt(placesCount) * 2,
label: stat.name,
completion: stat.completionPercent || 0
};
});
new Chart(chartCanvas.getContext('2d'), {
type: 'bubble',
data: {
datasets: [{
label: 'Villes',
data: bubbleChartData,
backgroundColor: bubbleChartData.map(d => `rgba(255, 99, 132, ${d.completion / 100})`),
borderColor: 'rgba(255, 99, 132, 1)',
}]
},
options: {
responsive: true,
plugins: {
datalabels: {
anchor: 'center',
align: 'center',
color: '#000',
font: {
weight: 'bold'
},
formatter: (value, context) => {
return context.dataset.data[context.dataIndex].label;
}
},
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context) => {
const d = context.raw;
return [
`${d.label}`,
`Population: ${d.x.toLocaleString()}`,
`Lieux / 1000 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: 'Commerces pour 1000 habitants'
}
}
}
}
});
}
}
// Attendre le chargement du DOM
document.addEventListener('DOMContentLoaded', () => {
console.log('DOMContentLoaded');
@ -266,8 +176,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
enableLabourageForm();
initDashboardChart();
enableLabourageForm();
adjustListGroupFontSize('.list-group-item');
});

View file

@ -183,6 +183,8 @@ class PublicController extends AbstractController
'placesCount' => $stat->getPlacesCount(),
'completionPercent' => $stat->getCompletionPercent(),
'population' => $stat->getPopulation(),
'zone' => $stat->getZone(),
'osmDataDateAvg' => $stat->getOsmDataDateAvg() ? $stat->getOsmDataDateAvg()->format('Y-m-d') : null,
];
}
}

View file

@ -632,7 +632,7 @@ function getCompletionColor(completion) {
if (completion.percentage < 100) {
content += `
<div class="alert alert-warning mt-2">
<h6>Informations manquantes :</h6>
<h6>Infos manquantes :</h6>
<ul class="list-unstyled mb-0">
${completion.missingFields.map(field => `<li><i class="bi bi-x-circle text-danger"></i> ${field}</li>`).join('')}
</ul>

View file

@ -23,7 +23,7 @@
data-bs-html="true"
data-bs-content="
<div class='p-2'>
<h6>Informations manquantes :</h6>
<h6>Infos manquantes :</h6>
<ul class='list-unstyled mb-0'>
{% if not commerce.name %}
<li><i class='bi bi-x-circle text-danger'></i> Nom du commerce</li>

View file

@ -68,6 +68,20 @@
<div class="card-body">
<canvas id="statsBubbleChart" style="min-height: 400px; width: 100%;" data-stats="{{ stats|raw }}"></canvas>
</div>
<pre>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
Fraîcheur des données OSM (âge moyen par trimestre)
</div>
<div class="card-body">
<canvas id="freshnessHistogram" style="min-height: 300px; width: 100%;"></canvas>
</div>
</div>
</div>
</div>
@ -160,5 +174,230 @@
{% block javascripts %}
{{ parent() }}
{# Le script du graphique est maintenant dans assets/app.js #}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const chartCanvas = document.getElementById('statsBubbleChart');
if (!chartCanvas) {
console.warn('Canvas #statsBubbleChart introuvable');
return;
}
const statsData = {{ stats|raw }};
console.log('statsData', statsData);
if (!statsData || statsData.length === 0) {
chartCanvas.parentNode.innerHTML += '<div class="text-muted p-3">Aucune donnée de statistiques à afficher pour le graphique.</div>';
return;
}
const bubbleChartData = statsData.map(stat => {
const population = parseInt(stat.population, 10);
const placesCount = parseInt(stat.placesCount, 10);
const ratio = population > 0 ? (placesCount / population) * 1000 : 0;
return {
x: population,
y: ratio,
r: Math.sqrt(placesCount) * 2,
label: stat.name,
completion: stat.completionPercent || 0
};
});
// 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 }
];
}
Chart.register(window.ChartDataLabels);
const bubbleChart = new Chart(chartCanvas.getContext('2d'), {
type: 'bubble',
data: {
datasets: [
{
label: 'Villes',
data: bubbleChartData,
backgroundColor: bubbleChartData.map(d => `rgba(255, 99, 132, ${d.completion / 100})`),
borderColor: 'rgba(255, 99, 132, 1)',
},
// regressionLine ? {
// label: 'Régression linéaire',
// type: 'line',
// data: regressionLine,
// borderColor: 'rgba(0, 0, 0, 0.7)',
// borderWidth: 2,
// pointRadius: 0,
// fill: false,
// order: 0,
// tension: 0
// } : null
].filter(Boolean)
},
options: {
responsive: true,
plugins: {
datalabels: {
anchor: 'center',
align: 'center',
color: '#000',
font: {
weight: 'bold'
},
formatter: (value, context) => {
return context.dataset.data[context.dataIndex].label;
}
},
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 / 1000 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: 'Commerces pour 1000 habitants'
}
}
}
}
});
// 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 = statsData[dataIndex];
if (stat && stat.zone) {
window.location.href = '/admin/stats/' + stat.zone;
}
}
};
// HISTOGRAMME FRAÎCHEUR
const freshnessCanvas = document.getElementById('freshnessHistogram');
if (freshnessCanvas) {
// Regrouper les villes par trimestre d'âge moyen OSM
const statsData = {{ stats|raw }};
const quarterCounts = {};
const quarterCities = {};
statsData.forEach(stat => {
if (!stat.osmDataDateAvg) return;
const date = new Date(stat.osmDataDateAvg);
if (isNaN(date)) return;
const year = date.getFullYear();
const month = date.getMonth(); // 0-11
const quarter = Math.floor(month / 3) + 1;
const key = `${year}-T${quarter}`;
if (!quarterCounts[key]) quarterCounts[key] = 0;
if (!quarterCities[key]) quarterCities[key] = [];
quarterCounts[key]++;
quarterCities[key].push(stat.name);
});
// Ordonner les trimestres
const sortedKeys = Object.keys(quarterCounts).sort();
const data = sortedKeys.map(k => quarterCounts[k]);
const labels = sortedKeys;
const citiesPerQuarter = sortedKeys.map(k => quarterCities[k]);
new Chart(freshnessCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Nombre de villes',
data: data,
backgroundColor: 'rgba(54, 162, 235, 0.7)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
datalabels: {
anchor: 'end',
align: 'end',
color: '#333',
font: { weight: 'bold', size: 10 },
formatter: function(value, context) {
const idx = context.dataIndex;
const villes = citiesPerQuarter[idx] || [];
if (villes.length === 0) return '';
let txt = villes.slice(0, 5).join(', ');
if (villes.length > 5) txt += ', ...';
return txt;
}
}
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(ctx) {
const idx = ctx.dataIndex;
const villes = citiesPerQuarter[idx] || [];
let label = `Villes : ${ctx.parsed.y}`;
if (villes.length > 0) {
label += '\n' + villes.join(', ');
}
return label;
}
}
},
datalabels: {
display: true
}
},
scales: {
x: {
title: { display: true, text: 'Trimestre de l\'âge moyen OSM' }
},
y: {
beginAtZero: true,
title: { display: true, text: 'Nombre de villes' }
}
}
},
plugins: [ChartDataLabels]
});
}
});
</script>
{% endblock %}