mirror of
https://forge.chapril.org/tykayn/osm-commerces
synced 2025-10-04 17:04:53 +02:00
up stats dashboard
This commit is contained in:
parent
fd3827ee52
commit
711fc277be
5 changed files with 246 additions and 96 deletions
|
@ -29,96 +29,6 @@ window.getLabourerUrl = getLabourerUrl;
|
||||||
|
|
||||||
Chart.register(ChartDataLabels);
|
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
|
// Attendre le chargement du DOM
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
console.log('DOMContentLoaded');
|
console.log('DOMContentLoaded');
|
||||||
|
@ -267,7 +177,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
enableLabourageForm();
|
enableLabourageForm();
|
||||||
initDashboardChart();
|
|
||||||
|
|
||||||
adjustListGroupFontSize('.list-group-item');
|
adjustListGroupFontSize('.list-group-item');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -183,6 +183,8 @@ class PublicController extends AbstractController
|
||||||
'placesCount' => $stat->getPlacesCount(),
|
'placesCount' => $stat->getPlacesCount(),
|
||||||
'completionPercent' => $stat->getCompletionPercent(),
|
'completionPercent' => $stat->getCompletionPercent(),
|
||||||
'population' => $stat->getPopulation(),
|
'population' => $stat->getPopulation(),
|
||||||
|
'zone' => $stat->getZone(),
|
||||||
|
'osmDataDateAvg' => $stat->getOsmDataDateAvg() ? $stat->getOsmDataDateAvg()->format('Y-m-d') : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -632,7 +632,7 @@ function getCompletionColor(completion) {
|
||||||
if (completion.percentage < 100) {
|
if (completion.percentage < 100) {
|
||||||
content += `
|
content += `
|
||||||
<div class="alert alert-warning mt-2">
|
<div class="alert alert-warning mt-2">
|
||||||
<h6>Informations manquantes :</h6>
|
<h6>Infos manquantes :</h6>
|
||||||
<ul class="list-unstyled mb-0">
|
<ul class="list-unstyled mb-0">
|
||||||
${completion.missingFields.map(field => `<li><i class="bi bi-x-circle text-danger"></i> ${field}</li>`).join('')}
|
${completion.missingFields.map(field => `<li><i class="bi bi-x-circle text-danger"></i> ${field}</li>`).join('')}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
data-bs-html="true"
|
data-bs-html="true"
|
||||||
data-bs-content="
|
data-bs-content="
|
||||||
<div class='p-2'>
|
<div class='p-2'>
|
||||||
<h6>Informations manquantes :</h6>
|
<h6>Infos manquantes :</h6>
|
||||||
<ul class='list-unstyled mb-0'>
|
<ul class='list-unstyled mb-0'>
|
||||||
{% if not commerce.name %}
|
{% if not commerce.name %}
|
||||||
<li><i class='bi bi-x-circle text-danger'></i> Nom du commerce</li>
|
<li><i class='bi bi-x-circle text-danger'></i> Nom du commerce</li>
|
||||||
|
|
|
@ -68,6 +68,20 @@
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<canvas id="statsBubbleChart" style="min-height: 400px; width: 100%;" data-stats="{{ stats|raw }}"></canvas>
|
<canvas id="statsBubbleChart" style="min-height: 400px; width: 100%;" data-stats="{{ stats|raw }}"></canvas>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -160,5 +174,230 @@
|
||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ 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 %}
|
{% endblock %}
|
Loading…
Add table
Add a link
Reference in a new issue