// 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) // 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); 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 += d.x; sumY += d.y; sumXY += d.x * d.y; sumXX += d.x * 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 * xMin + intercept }, { x: xMax, y: slope * 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: { plugins: { datalabels: { 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)} × x + ${intercept.toFixed(2)}`; } return [ `${d.label}`, `Fraîcheur moyenne: ${d.freshnessDays ? d.freshnessDays.toLocaleString() + ' jours' : 'N/A'}`, `Complétion: ${d.y.toFixed(2)}%`, `Population: ${d.population ? d.population.toLocaleString() : 'N/A'}`, `Nombre de lieux: ${d.r.toFixed(2)}`, `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'}` ]; } } } }, scales: { x: { type: 'linear', title: { display: true, text: 'Fraîcheur moyenne (jours, plus petit = plus récent)' } }, y: { title: { display: true, text: 'Taux de complétion (%)' }, min: 0, max: 100 } } } }); // 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 population = parseInt(stat.population, 10); const placesCount = parseInt(stat.placesCount, 10); const completion = parseInt(stat.completionPercent, 10); // 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; return { x: x, y: completion, r: proportional ? Math.sqrt(placesCount) * 2 : 12, label: stat.name, completion: stat.completionPercent || 0, zone: stat.zone, budget, budgetParHabitant, budgetParLieu, population, placesCount, freshnessDays }; }); // Trier du plus gros au plus petit rayon if(data){ data.sort((a, b) => b.r - a.r); } return data; } document.addEventListener('DOMContentLoaded', function() { waitForChartAndDrawBubble(); // 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'; } }); } });