import { useMemo, useState, useRef, useCallback } from 'react'; /* ── Palette ────────────────────────────────────────────────── */ const PALETTE = [ '#e8547a', '#9333ea', '#0d9488', '#f59e0b', '#3b82f6', '#10b981', '#f97316', '#06b6d4', '#a855f7', '#84cc16', '#ec4899', '#14b8a6', ]; /* ── Algorithme treemap (binary split équilibré) ─────────────── */ function buildTreemap(items, x, y, w, h) { if (!items.length) return []; if (items.length === 1) return [{ ...items[0], x, y, w, h }]; const total = items.reduce((s, i) => s + i.value, 0); let best = 1, bestDiff = Infinity, acc = 0; for (let i = 0; i < items.length - 1; i++) { acc += items[i].value; const diff = Math.abs(acc - (total - acc)); if (diff < bestDiff) { bestDiff = diff; best = i + 1; } } const g1 = items.slice(0, best); const g2 = items.slice(best); const r1 = g1.reduce((s, i) => s + i.value, 0) / total; if (w >= h) { const w1 = w * r1; return [...buildTreemap(g1, x, y, w1, h), ...buildTreemap(g2, x + w1, y, w - w1, h)]; } else { const h1 = h * r1; return [...buildTreemap(g1, x, y, w, h1), ...buildTreemap(g2, x, y + h1, w, h - h1)]; } } function fmtAmount(v) { return Math.round(v).toLocaleString('fr-FR') + ' €'; } function wrapText(text, maxWidth, fontSize) { const charW = fontSize * 0.58; const maxChars = Math.max(1, Math.floor(maxWidth / charW)); const words = text.split(' '); const lines = []; let current = ''; for (const word of words) { const candidate = current ? current + ' ' + word : word; if (candidate.length <= maxChars) { current = candidate; } else { if (current) lines.push(current); current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word; } } if (current) lines.push(current); return lines.slice(0, 3); } /* ── Composant ──────────────────────────────────────────────── */ export default function InteretsDistributionChart({ rows, netMode }) { const [hoveredIdx, setHoveredIdx] = useState(null); const [tooltip, setTooltip] = useState(null); const svgRef = useRef(null); const wrapRef = useRef(null); /* Intérêts cumulés par plateforme */ const data = useMemo(() => { if (!rows?.length) return []; const byPlat = {}; for (const r of rows) { const key = r.plateforme_nom || `#${r.plateforme_id}`; const val = netMode ? (r.interets_nets || 0) : (r.interets_bruts || 0); byPlat[key] = (byPlat[key] || 0) + val; } return Object.entries(byPlat) .filter(([, v]) => v > 0) .sort(([, a], [, b]) => b - a) .map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] })); }, [rows, netMode]); const total = data.reduce((s, i) => s + i.value, 0); const W = 440, H = 290, GAP = 3; const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]); const handleMouseMove = useCallback((e, cell, idx) => { if (!wrapRef.current) return; const wrapRect = wrapRef.current.getBoundingClientRect(); setHoveredIdx(idx); setTooltip({ x: e.clientX - wrapRect.left, y: e.clientY - wrapRect.top, cell }); }, []); const handleMouseLeave = useCallback(() => { setHoveredIdx(null); setTooltip(null); }, []); if (!cells.length) return null; const TIP_W = 160, TIP_H = 66; const tipStyle = tooltip ? (() => { const ww = wrapRef.current?.offsetWidth || 440; const wh = wrapRef.current?.offsetHeight || 360; let tx = tooltip.x + 14; let ty = tooltip.y - TIP_H / 2; if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14; if (ty < 4) ty = 4; if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4; return { left: tx, top: ty }; })() : null; const modeLabel = netMode ? 'Nets' : 'Bruts'; return (
{/* ── En-tête ── */}
Intérêts {modeLabel}
par Plateforme
{/* ── Treemap SVG ── */}
{cells.map((cell, i) => { const PAD = 4; const gx = cell.x + GAP / 2 + PAD; const gy = cell.y + GAP / 2 + PAD; const gw = Math.max(cell.w - GAP - PAD * 2, 0); const gh = Math.max(cell.h - GAP - PAD * 2, 0); return ( ); })} {cells.map((cell, i) => { const gx = cell.x + GAP / 2; const gy = cell.y + GAP / 2; const gw = Math.max(cell.w - GAP, 0); const gh = Math.max(cell.h - GAP, 0); const cx = gx + gw / 2; const cy = gy + gh / 2; const pct = ((cell.value / total) * 100).toFixed(0); const amt = fmtAmount(cell.value); const isHovered = hoveredIdx === i; const fsAmt = Math.min(15, Math.max(10, gw / 7.5)); const fsName = Math.min(12, Math.max(8, gw / 9.5)); const fsPct = Math.min(11, Math.max(7, gw / 11)); const lineH = 15; const canShowAmt = gw > 36 && gh > 20; const isLandscape = gw > gh; const combinedText = `${cell.name} — ${pct} %`; const combinedCharW = fsName * 0.58; const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10); const nameLines = (gw > 40 && gh > 30) ? wrapText(cell.name, gw - 10, fsName) : []; const canShowName = nameLines.length > 0; const CLIP_PAD = 4; const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0); const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH; const textItems = []; { const slots = []; if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 }); if (combinedFits) { slots.push({ type: 'combined', text: combinedText, h: lineH }); } else { if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH })); if (canShowPct) slots.push({ type: 'pct', h: lineH }); } const totalH = slots.reduce((s, sl) => s + sl.h, 0); let y = cy - totalH / 2 + lineH / 2; for (const sl of slots) { if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y }); if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y }); if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y }); if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y }); y += sl.h; } } return ( handleMouseMove(e, { ...cell, pct, amt }, i)} > {isHovered && ( )} {textItems.map(l => ( {l.text} ))} ); })} {/* ── Tooltip ── */} {tooltip && tipStyle && (
{tooltip.cell.amt}
{tooltip.cell.name}
{tooltip.cell.pct} %
)}
); }