2025-06-21 18:37:31 +02:00
|
|
|
|
// 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
|
2025-06-23 23:36:50 +02:00
|
|
|
|
function waitForChartAndDrawBubble() {
|
|
|
|
|
if (!window.Chart || !window.ChartDataLabels) {
|
|
|
|
|
setTimeout(waitForChartAndDrawBubble, 50);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const chartCanvas = document.getElementById('bubbleChart');
|
|
|
|
|
const toggle = document.getElementById('toggleBubbleSize');
|
2025-06-21 18:37:31 +02:00
|
|
|
|
|
2025-06-23 23:36:50 +02:00
|
|
|
|
|
|
|
|
|
|
2025-06-21 18:37:31 +02:00
|
|
|
|
|
2025-06-23 23:36:50 +02:00
|
|
|
|
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;
|
2025-06-21 18:37:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-23 23:36:50 +02:00
|
|
|
|
// 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)
|
2025-06-21 18:37:31 +02:00
|
|
|
|
|
2025-06-23 23:36:50 +02:00
|
|
|
|
chartCanvas.width = (parentRect.width);
|
|
|
|
|
chartCanvas.height = (parentRect.height);
|
|
|
|
|
chartCanvas.style.width = parentRect.width + 'px';
|
|
|
|
|
chartCanvas.style.height = parentRect.height + 'px';
|
|
|
|
|
}
|
2025-06-21 18:37:31 +02:00
|
|
|
|
|
|
|
|
|
|
2025-06-23 23:36:50 +02:00
|
|
|
|
if(!getBubbleData){
|
|
|
|
|
console.log('pas de getBubbleData')
|
|
|
|
|
return ;
|
|
|
|
|
}
|
|
|
|
|
const bubbleChartData = getBubbleData(proportional);
|
2025-06-21 18:37:31 +02:00
|
|
|
|
|
2025-06-23 23:36:50 +02:00
|
|
|
|
if(!bubbleChartData){
|
|
|
|
|
console.log('pas de bubbleChartData')
|
|
|
|
|
return ;
|
|
|
|
|
}
|
2025-06-21 18:37:31 +02:00
|
|
|
|
|
2025-06-23 23:36:50 +02:00
|
|
|
|
// Calcul de la régression linéaire (moindres carrés)
|
2025-06-24 13:16:48 +02:00
|
|
|
|
// On ne fait la régression que si on veut, mais l'axe X = fraicheur, Y = complétion
|
|
|
|
|
const validPoints = bubbleChartData.filter(d => d.x !== null && d.y !== null);
|
2025-06-23 23:36:50 +02:00
|
|
|
|
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 => {
|
2025-06-24 13:16:48 +02:00
|
|
|
|
sumX += d.x;
|
2025-06-23 23:36:50 +02:00
|
|
|
|
sumY += d.y;
|
2025-06-24 13:16:48 +02:00
|
|
|
|
sumXY += d.x * d.y;
|
|
|
|
|
sumXX += d.x * d.x;
|
2025-06-23 23:36:50 +02:00
|
|
|
|
});
|
|
|
|
|
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 = [
|
2025-06-24 13:16:48 +02:00
|
|
|
|
{ x: xMin, y: slope * xMin + intercept },
|
|
|
|
|
{ x: xMax, y: slope * xMax + intercept }
|
2025-06-23 23:36:50 +02:00
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
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)',
|
2025-06-21 18:37:31 +02:00
|
|
|
|
datalabels: {
|
2025-06-23 23:36:50 +02:00
|
|
|
|
anchor: 'center',
|
|
|
|
|
align: 'center',
|
|
|
|
|
color: '#000',
|
|
|
|
|
display: true,
|
|
|
|
|
font: { weight: '400', size : "12px" },
|
|
|
|
|
formatter: (value, context) => {
|
|
|
|
|
return context.dataset.data[context.dataIndex].label;
|
2025-06-21 18:37:31 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
2025-06-23 23:36:50 +02:00
|
|
|
|
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: {
|
|
|
|
|
plugins: {
|
|
|
|
|
datalabels: {
|
|
|
|
|
display: false
|
|
|
|
|
},
|
|
|
|
|
legend: { display: true },
|
|
|
|
|
tooltip: {
|
|
|
|
|
callbacks: {
|
|
|
|
|
label: (context) => {
|
|
|
|
|
const d = context.raw;
|
|
|
|
|
if (context.dataset.type === 'line') {
|
2025-06-24 13:16:48 +02:00
|
|
|
|
return `Régression: y = ${slope.toFixed(2)} × x + ${intercept.toFixed(2)}`;
|
2025-06-23 23:36:50 +02:00
|
|
|
|
}
|
|
|
|
|
return [
|
|
|
|
|
`${d.label}`,
|
2025-06-24 12:30:39 +02:00
|
|
|
|
`Fraîcheur moyenne: ${d.freshnessDays ? d.freshnessDays.toLocaleString() + ' jours' : 'N/A'}`,
|
2025-06-24 13:16:48 +02:00
|
|
|
|
`Complétion: ${d.y.toFixed(2)}%`,
|
|
|
|
|
`Population: ${d.population ? d.population.toLocaleString() : 'N/A'}`,
|
|
|
|
|
`Nombre de lieux: ${d.r.toFixed(2)}`,
|
2025-06-24 12:30:39 +02:00
|
|
|
|
`Budget: ${d.budget ? d.budget.toLocaleString() + ' €' : 'N/A'}`,
|
|
|
|
|
`Budget/habitant: ${d.budgetParHabitant ? d.budgetParHabitant.toFixed(2) + ' €' : 'N/A'}`,
|
|
|
|
|
`Budget/lieu: ${d.budgetParLieu ? d.budgetParLieu.toFixed(2) + ' €' : 'N/A'}`
|
2025-06-23 23:36:50 +02:00
|
|
|
|
];
|
|
|
|
|
}
|
2025-06-21 18:37:31 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-23 23:36:50 +02:00
|
|
|
|
},
|
|
|
|
|
scales: {
|
|
|
|
|
x: {
|
2025-06-24 13:16:48 +02:00
|
|
|
|
type: 'linear',
|
|
|
|
|
title: { display: true, text: 'Fraîcheur moyenne (jours, plus petit = plus récent)' }
|
2025-06-23 23:36:50 +02:00
|
|
|
|
},
|
|
|
|
|
y: {
|
2025-06-24 13:16:48 +02:00
|
|
|
|
title: { display: true, text: 'Taux de complétion (%)' },
|
|
|
|
|
min: 0,
|
|
|
|
|
max: 100
|
2025-06-21 18:37:31 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-23 23:36:50 +02:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-06-21 18:37:31 +02:00
|
|
|
|
|
2025-06-23 23:36:50 +02:00
|
|
|
|
// Initial draw
|
|
|
|
|
console.log('[bubble chart] Initialisation avec taille proportionnelle ?', toggle?.checked);
|
|
|
|
|
if(drawBubbleChart){
|
2025-06-23 00:47:49 +02:00
|
|
|
|
|
2025-06-23 23:36:50 +02:00
|
|
|
|
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 population = parseInt(stat.population, 10);
|
|
|
|
|
const placesCount = parseInt(stat.placesCount, 10);
|
|
|
|
|
const completion = parseInt(stat.completionPercent, 10);
|
2025-06-24 12:30:39 +02:00
|
|
|
|
// Fraîcheur moyenne : âge moyen en jours (plus récent à droite)
|
|
|
|
|
let freshnessDays = null;
|
|
|
|
|
if (stat.osmDataDateAvg) {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const avgDate = new Date(stat.osmDataDateAvg);
|
|
|
|
|
freshnessDays = Math.round((now - avgDate) / (1000 * 60 * 60 * 24));
|
|
|
|
|
}
|
|
|
|
|
// Pour l'axe X, on veut que les plus récents soient à droite (donc X = -freshnessDays)
|
|
|
|
|
const x = freshnessDays !== null ? -freshnessDays : 0;
|
|
|
|
|
// Budget
|
|
|
|
|
const budget = stat.budgetAnnuel ? parseFloat(stat.budgetAnnuel) : null;
|
|
|
|
|
const budgetParHabitant = (budget && population) ? budget / population : null;
|
|
|
|
|
const budgetParLieu = (budget && placesCount) ? budget / placesCount : null;
|
2025-06-23 23:36:50 +02:00
|
|
|
|
return {
|
2025-06-24 12:30:39 +02:00
|
|
|
|
x: x,
|
2025-06-23 23:36:50 +02:00
|
|
|
|
y: completion,
|
|
|
|
|
r: proportional ? Math.sqrt(placesCount) * 2 : 12,
|
|
|
|
|
label: stat.name,
|
|
|
|
|
completion: stat.completionPercent || 0,
|
2025-06-24 12:30:39 +02:00
|
|
|
|
zone: stat.zone,
|
|
|
|
|
budget,
|
|
|
|
|
budgetParHabitant,
|
|
|
|
|
budgetParLieu,
|
|
|
|
|
population,
|
|
|
|
|
placesCount,
|
|
|
|
|
freshnessDays
|
2025-06-23 23:36:50 +02:00
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
// Trier du plus gros au plus petit rayon
|
|
|
|
|
if(data){
|
|
|
|
|
data.sort((a, b) => b.r - a.r);
|
2025-06-21 18:37:31 +02:00
|
|
|
|
}
|
2025-06-23 23:36:50 +02:00
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
2025-06-21 18:37:31 +02:00
|
|
|
|
waitForChartAndDrawBubble();
|
2025-06-24 12:30:39 +02:00
|
|
|
|
|
|
|
|
|
// Forcer deleteMissing=1 sur le formulaire de labourage
|
|
|
|
|
const labourerForm = document.getElementById('labourerForm');
|
|
|
|
|
if (labourerForm) {
|
|
|
|
|
labourerForm.addEventListener('submit', function(e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const zipCode = document.getElementById('selectedZipCode').value;
|
|
|
|
|
if (zipCode) {
|
|
|
|
|
window.location.href = '/admin/labourer/' + zipCode + '?deleteMissing=1';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-06-21 18:37:31 +02:00
|
|
|
|
});
|