thibaud frere
fix pie chart rendering
31b7760
raw
history blame
13.5 kB
<div class="d3-pie" style="width:100%;margin:10px 0;"></div>
<style>
.d3-pie .legend { font-size: 12px; line-height: 1.35; color: var(--text-color); }
.d3-pie .legend .items { display:flex; flex-wrap:wrap; gap:8px 14px; align-items:center; justify-content:center; }
.d3-pie .legend .item { display:flex; align-items:center; gap:8px; white-space:nowrap; }
.d3-pie .legend .swatch { width:14px; height:14px; border-radius:3px; display:inline-block; border: 1px solid var(--border-color); }
.d3-pie .caption { font-size: 14px; font-weight: 800; fill: var(--text-color); }
.d3-pie .nodata { font-size: 12px; fill: var(--muted-color); }
.d3-pie .slice-label { font-size: 11px; font-weight: 700; fill: var(--text-color); paint-order: stroke; stroke: rgba(255,255,255,0.2); stroke-width: 3px; }
</style>
<script>
(() => {
const THIS_SCRIPT = document.currentScript;
const ensureD3 = (cb) => {
if (window.d3 && typeof window.d3.select === 'function') return cb();
let s = document.getElementById('d3-cdn-script');
if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
s.addEventListener('load', onReady, { once: true });
if (window.d3) onReady();
};
const bootstrap = () => {
const scriptEl = THIS_SCRIPT;
const host = scriptEl && scriptEl.parentElement;
let container = null;
if (host && host.querySelector) {
container = host.querySelector('.d3-pie');
}
if (!container) {
let sib = scriptEl && scriptEl.previousElementSibling;
while (sib && !(sib.classList && sib.classList.contains('d3-pie'))) {
sib = sib.previousElementSibling;
}
container = sib || document.querySelector('.d3-pie');
}
if (!container) return;
if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
// Tooltip
container.style.position = container.style.position || 'relative';
let tip = container.querySelector('.d3-tooltip'); let tipInner;
if (!tip) {
tip = document.createElement('div'); tip.className = 'd3-tooltip';
Object.assign(tip.style, {
position:'absolute', top:'0px', left:'0px', transform:'translate(-9999px, -9999px)', pointerEvents:'none',
padding:'8px 10px', borderRadius:'8px', fontSize:'12px', lineHeight:'1.35', border:'1px solid var(--border-color)',
background:'var(--surface-bg)', color:'var(--text-color)', boxShadow:'0 4px 24px rgba(0,0,0,.18)', opacity:'0', transition:'opacity .12s ease'
});
tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign='left'; tip.appendChild(tipInner); container.appendChild(tip);
} else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
// SVG scaffolding
const svg = d3.select(container).append('svg').attr('width','100%').style('display','block').attr('preserveAspectRatio','xMidYMin meet');
const gRoot = svg.append('g');
const gLegend = gRoot.append('foreignObject').attr('class','legend');
const gPlots = gRoot.append('g').attr('class','plots');
// Metrics (order and labels as in the Python script)
const METRICS = [
{ key:'answer_total_tokens', name:'Answer Tokens', title:'Weighted by Answer Tokens', letter:'a' },
{ key:'total_samples', name:'Number of Samples', title:'Weighted by Number of Samples', letter:'b' },
{ key:'total_turns', name:'Number of Turns', title:'Weighted by Number of Turns', letter:'c' },
{ key:'total_images', name:'Number of Images', title:'Weighted by Number of Images', letter:'d' }
];
// CSV: load from public path
const CSV_PATHS = [
'/data/vision.csv'
];
const fetchFirstAvailable = async (paths) => {
for (const p of paths) {
try {
const res = await fetch(p, { cache: 'no-cache' });
if (res.ok) { return await res.text(); }
} catch (_) { /* try next */ }
}
throw new Error('CSV not found: vision.csv');
};
const parseCsv = (text) => d3.csvParse(text, (d) => ({
subset_name: (d['subset_name']||'').trim(),
eagle_cathegory: (d['eagle_cathegory']||'').trim(),
answer_total_tokens: +((d['answer_total_tokens']||'0').toString().trim()) || 0,
total_samples: +((d['total_samples']||'0').toString().trim()) || 0,
total_turns: +((d['total_turns']||'0').toString().trim()) || 0,
total_images: +((d['total_images']||'0').toString().trim()) || 0
}));
// Layout
let width=800; const margin = { top: 8, right: 24, bottom: 0, left: 24 };
const CAPTION_GAP = 24; // espace entre titre et donut
const GAP_X = 20; // espace entre colonnes
const GAP_Y = 12; // espace entre lignes
const LEGEND_HEIGHT = 62; // hauteur de la légende
const TOP_OFFSET = 4; // décalage vertical supplémentaire pour aérer le haut
const updateSize = () => {
width = container.clientWidth || 800;
svg.attr('width', width);
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
return { innerWidth: width - margin.left - margin.right };
};
function renderLegend(categories, colorOf, width, x, legendY){
const legendHeight = LEGEND_HEIGHT;
gLegend.attr('x', x).attr('y', legendY).attr('width', width).attr('height', legendHeight);
const root = gLegend.selectAll('div').data([0]).join('xhtml:div');
root
.style('height', legendHeight + 'px')
.style('display', 'flex')
.style('align-items', 'center')
.style('justify-content', 'center');
root.html(`<div class="items">${categories.map(c => `<div class="item"><span class="swatch" style="background:${colorOf(c)}"></span><span style="font-weight:500">${c}</span></div>`).join('')}</div>`);
}
function drawPies(rows){
const { innerWidth } = updateSize();
// Catégories (triées) + échelle de couleurs harmonisée avec banner.html
const categories = Array.from(new Set(rows.map(r => r.eagle_cathegory || 'Unknown'))).sort();
const color = d3.scaleOrdinal().domain(categories).range(d3.schemeTableau10);
const colorOf = (cat) => color(cat || 'Unknown');
// Clear plots
gPlots.selectAll('*').remove();
// Colonnes responsives: tenter 4 colonnes si possible, sinon descendre à 3/2/1
const selectCols = () => {
const MIN_RADIUS = 80; // garantir lisibilité
const allowed = [4, 2, 1]; // seulement 4 / 2 / 1 colonnes
// 1) essayer avec contrainte de rayon minimal
for (const c of allowed) {
const cw = (innerWidth - GAP_X * (c - 1)) / c;
const r = Math.max(30, Math.min(cw * 0.42, 120));
const gw = c * (r * 2) + (c - 1) * GAP_X;
if (gw <= innerWidth && r >= MIN_RADIUS) {
return { c, r };
}
}
// 2) sinon, première config qui tient (même si plus petit rayon)
for (const c of allowed) {
const cw = (innerWidth - GAP_X * (c - 1)) / c;
const r = Math.max(30, Math.min(cw * 0.42, 120));
const gw = c * (r * 2) + (c - 1) * GAP_X;
if (gw <= innerWidth) {
return { c, r };
}
}
// 3) fallback très petit écran
const r1 = Math.max(30, Math.min(innerWidth * 0.42, 120));
return { c: 1, r: r1 };
};
const { c: cols, r: radius } = selectCols();
const rowsCount = Math.ceil(METRICS.length / cols);
const innerR = Math.round(radius * 0.28);
// Calculer un espacement effectif pour occuper toute la largeur disponible
const baseGap = GAP_X;
const effectiveGapX = cols > 1
? Math.max(baseGap, Math.floor((innerWidth - cols * (radius * 2)) / (cols - 1)))
: 0;
// largeur réelle de la grille avec l'espacement effectif
const gridWidth = cols * (radius * 2) + (cols - 1) * effectiveGapX;
const xOffset = Math.max(0, Math.floor((innerWidth - gridWidth) / 2));
gPlots.attr('transform', `translate(${xOffset},${TOP_OFFSET})`);
const perRowHeight = Math.ceil(radius * 2 + CAPTION_GAP + 20); // donut + caption + marge
const plotsHeight = rowsCount * perRowHeight + (rowsCount - 1) * GAP_Y;
const pie = d3.pie().sort(null).value(d => d.value).padAngle(0.02);
const arc = d3.arc().innerRadius(innerR).outerRadius(radius).cornerRadius(3);
const arcLabel = d3.arc().innerRadius((innerR + radius) / 2).outerRadius((innerR + radius) / 2);
// Positionner la légende sous les graphiques, calée sur la grille centrée
const legendY = TOP_OFFSET + plotsHeight + 4;
renderLegend(categories, colorOf, gridWidth, xOffset, legendY);
const captions = new Map(METRICS.map(m => [m.key, `${m.title}`]));
METRICS.forEach((metric, idx) => {
// Aggregate by category
const totals = new Map(); categories.forEach(c => totals.set(c, 0));
rows.forEach(r => { totals.set(r.eagle_cathegory, totals.get(r.eagle_cathegory) + (r[metric.key] || 0)); });
const values = categories.map(c => ({ category: c, value: totals.get(c) || 0 }));
const totalSum = d3.sum(values, d => d.value);
const rowIdx = Math.floor(idx / cols);
const colIdx = idx % cols;
const cx = colIdx * ((radius * 2) + effectiveGapX) + radius;
const cy = TOP_OFFSET + rowIdx * (perRowHeight + GAP_Y) + CAPTION_GAP + radius;
const gCell = gPlots.append('g').attr('transform', `translate(${cx},${cy})`);
if (!totalSum || totalSum <= 0) {
gCell.append('text').attr('class','nodata').attr('text-anchor','middle').attr('dy','0').text('No data for this metric');
} else {
const data = pie(values);
const percent = (v) => (v / totalSum) * 100;
// Slices
const slices = gCell.selectAll('path.slice').data(data).enter().append('path').attr('class','slice')
.attr('d', arc)
.attr('fill', d => colorOf(d.data.category))
.attr('stroke', 'var(--surface-bg)')
.attr('stroke-width', 1.2)
.on('mouseenter', function(ev, d){
d3.select(this).attr('stroke', 'rgba(0,0,0,0.85)').attr('stroke-width', 1);
const p = percent(d.data.value);
tipInner.innerHTML = `<div><strong>${d.data.category}</strong></div><div><strong>${metric.name}</strong> ${d.data.value.toLocaleString()}</div><div><strong>Share</strong> ${p.toFixed(1)}%</div>`;
tip.style.opacity = '1';
})
.on('mousemove', function(ev){
const [mx, my] = d3.pointer(ev, container); const offsetX = 12, offsetY = 12; tip.style.transform = `translate(${Math.round(mx+offsetX)}px, ${Math.round(my+offsetY)}px)`;
})
.on('mouseleave', function(){ tip.style.opacity='0'; tip.style.transform='translate(-9999px, -9999px)'; d3.select(this).attr('stroke','var(--surface-bg)'); });
// Percentage labels (>= 3%)
gCell.selectAll('text.slice-label').data(data.filter(d => percent(d.data.value) >= 3)).enter()
.append('text').attr('class','slice-label').style('pointer-events','none')
.attr('transform', d => `translate(${arcLabel.centroid(d)})`)
.attr('text-anchor','middle')
.text(d => `${percent(d.data.value).toFixed(1)}%`);
}
// Caption above donut
gCell.append('text')
.attr('class','caption')
.attr('text-anchor','middle')
.attr('y', -(radius + (CAPTION_GAP - 6)))
.text(captions.get(metric.key));
});
// Définir la hauteur totale du SVG après avoir placé les éléments
const totalHeight = Math.ceil(margin.top + TOP_OFFSET + plotsHeight + 4 + LEGEND_HEIGHT + margin.bottom);
svg.attr('height', totalHeight);
}
async function init(){
try {
const text = await fetchFirstAvailable(CSV_PATHS);
const rows = parseCsv(text);
drawPies(rows);
// Resize handling
const rerender = () => drawPies(rows);
if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(container); }
else { window.addEventListener('resize', rerender); }
} catch (err) {
const pre = document.createElement('pre'); pre.textContent = (err && err.message) ? err.message : String(err);
pre.style.color = 'var(--danger, #b00020)'; pre.style.fontSize = '12px'; pre.style.whiteSpace = 'pre-wrap';
container.appendChild(pre);
}
}
init();
};
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
})();
</script>