256 lines
10 KiB
React
256 lines
10 KiB
React
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 (
|
|
<div className="dist-chart-wrap" ref={wrapRef}>
|
|
|
|
{/* ── En-tête ── */}
|
|
<div className="dist-chart-header">
|
|
<span className="dist-chart-title">Intérêts {modeLabel}</span>
|
|
<div className="dist-dropdown">
|
|
par Plateforme
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M6 9l6 6 6-6"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Treemap SVG ── */}
|
|
<div style={{ position: 'relative' }}>
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox={`0 0 ${W} ${H}`}
|
|
style={{ width: '100%', height: 'auto', display: 'block' }}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
<defs>
|
|
<linearGradient id="idt-shadow" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#000" stopOpacity="0" />
|
|
<stop offset="100%" stopColor="#000" stopOpacity="0.4" />
|
|
</linearGradient>
|
|
<filter id="idt-txt-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodColor="#000" floodOpacity="0.55"/>
|
|
</filter>
|
|
{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 (
|
|
<clipPath key={i} id={`idt-clip-${i}`}>
|
|
<rect x={gx} y={gy} width={gw} height={gh} />
|
|
</clipPath>
|
|
);
|
|
})}
|
|
</defs>
|
|
|
|
{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 (
|
|
<g key={i} style={{ cursor: 'pointer' }}
|
|
onMouseMove={e => handleMouseMove(e, { ...cell, pct, amt }, i)}
|
|
>
|
|
<rect x={gx} y={gy} width={gw} height={gh}
|
|
rx="5" fill={cell.color}
|
|
opacity={hoveredIdx !== null && !isHovered ? 0.45 : 0.9}
|
|
style={{ transition: 'opacity .15s' }}
|
|
/>
|
|
<rect x={gx} y={gy} width={gw} height={gh}
|
|
rx="5" fill="url(#idt-shadow)" opacity="0.35" />
|
|
{isHovered && (
|
|
<rect x={gx} y={gy} width={gw} height={gh}
|
|
rx="5" fill="none"
|
|
stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
|
|
)}
|
|
<g clipPath={`url(#idt-clip-${i})`}>
|
|
{textItems.map(l => (
|
|
<text key={l.key} x={cx} y={l.y}
|
|
textAnchor="middle" dominantBaseline="middle"
|
|
fill="white"
|
|
fillOpacity={hoveredIdx !== null && !isHovered ? 0.3 : l.op}
|
|
fontSize={l.fs} fontWeight={l.fw}
|
|
fontFamily="system-ui,-apple-system,sans-serif"
|
|
filter="url(#idt-txt-shadow)"
|
|
style={{ transition: 'fill-opacity .15s' }}>
|
|
{l.text}
|
|
</text>
|
|
))}
|
|
</g>
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
|
|
{/* ── Tooltip ── */}
|
|
{tooltip && tipStyle && (
|
|
<div className="dist-tooltip" style={{ left: tipStyle.left, top: tipStyle.top }}>
|
|
<div className="dist-tooltip-dot" style={{ background: tooltip.cell.color }} />
|
|
<div>
|
|
<div className="dist-tooltip-amt">{tooltip.cell.amt}</div>
|
|
<div className="dist-tooltip-name">{tooltip.cell.name}</div>
|
|
<div className="dist-tooltip-pct">{tooltip.cell.pct} %</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|