import { useMemo, useState, useRef } from 'react'; /* ── Constantes ─────────────────────────────────────────────── */ const BLUE = '#4fa8e8'; const BG = '#070c15'; const GRID = 'rgba(255,255,255,0.055)'; const LABEL = '#4a5568'; const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT']; const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.']; const W = 900, H = 260; const PAD = { top: 16, right: 16, bottom: 32, left: 70 }; const plotW = W - PAD.left - PAD.right; const plotH = H - PAD.top - PAD.bottom; /* ── Helpers ────────────────────────────────────────────────── */ function fmtK(v) { if (v === 0) return '0 €'; const abs = Math.abs(v); if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €'; return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €'; } function fmtAxisDate(dateStr, range) { const d = new Date(dateStr + 'T00:00:00'); const day = String(d.getDate()).padStart(2, '0'); const mon = MOIS_COURT[d.getMonth()]; const yr = d.getFullYear(); if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`; if (range === '1M' || range === '3M') return `${day} ${mon}`; return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`; } function fmtValueDisplay(v) { return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €'; } /* ── Composant ──────────────────────────────────────────────── */ export default function InvChart({ rows, remboursements, reinvestissements, platYear }) { const [range, setRange] = useState('TOUT'); const [hover, setHover] = useState(null); const svgRef = useRef(null); const wrapRef = useRef(null); /* ── Date de coupure : 31/12/{platYear} pour les années passées, aujourd'hui pour l'année en cours ou sans filtre ── */ const todayStr = new Date().toISOString().slice(0, 10); const currentYear = String(new Date().getFullYear()); const cutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : todayStr; /* ── 1. Courbe capital en cours = investissements − capital remboursé ── */ const allPoints = useMemo(() => { if (!rows?.length) return []; // Tous les investissements du scope (le capRestant sera 0 pour les remboursés) const rowIds = new Set(rows.map(r => r.id)); // Variations de capital par date const deltas = {}; // +montant_investi à chaque date de souscription for (const r of rows) { const d = r.date_souscription?.slice(0, 10); if (!d || d > cutoffStr) continue; deltas[d] = (deltas[d] || 0) + (r.montant_investi ?? 0); } // +réinvestissements à leur date propre (dans le scope, avant coupure) if (reinvestissements?.length) { for (const rv of reinvestissements) { if (!rowIds.has(rv.investissement_id)) continue; const d = rv.date_reinvestissement?.slice(0, 10); if (!d || !rv.montant || d > cutoffStr) continue; deltas[d] = (deltas[d] || 0) + rv.montant; } } // -capital à chaque date de remboursement (investissements du scope, avant coupure) if (remboursements?.length) { for (const rb of remboursements) { if (!rowIds.has(rb.investissement_id)) continue; const d = rb.date_remb?.slice(0, 10); if (!d || !rb.capital || d > cutoffStr) continue; deltas[d] = (deltas[d] || 0) - rb.capital; } } // Tri chronologique + cumul let cum = 0; return Object.keys(deltas).sort().map(date => { cum += deltas[date]; return { date, value: Math.max(0, cum) }; }); }, [rows, remboursements, reinvestissements, cutoffStr]); const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0; /* ── 2. Filtrage par plage ── */ const points = useMemo(() => { if (!allPoints.length) return []; if (range === 'TOUT') { const pts = [...allPoints]; if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value }); return pts; } const now = new Date(cutoffStr + 'T00:00:00'); let fromDate = new Date(now); switch (range) { case '1J': fromDate.setDate(now.getDate() - 1); break; case '7J': fromDate.setDate(now.getDate() - 7); break; case '1M': fromDate.setMonth(now.getMonth() - 1); break; case '3M': fromDate.setMonth(now.getMonth() - 3); break; case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break; case '1A': fromDate.setFullYear(now.getFullYear() - 1); break; } const fromStr = fromDate.toISOString().slice(0, 10); const before = allPoints.filter(p => p.date < fromStr); const startV = before.length ? before[before.length - 1].value : 0; const after = allPoints.filter(p => p.date >= fromStr); const pts = [{ date: fromStr, value: startV }, ...after]; if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value }); return pts; }, [allPoints, range, cutoffStr]); /* ── 3. Échelles ── */ const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => { if (points.length < 2) return {}; const vals = points.map(p => p.value); const dataMin = Math.min(...vals); const dataMax = Math.max(...vals); const lo = Math.min(0, dataMin); const hi = Math.max(0, dataMax); const pad = (hi - lo) * 0.1 || 10; const scaleLo = lo - (lo < 0 ? pad : 0); const scaleHi = hi + pad; const valRange = scaleHi - scaleLo || 1; const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime()); const minDt = ts[0]; const maxDt = ts[ts.length - 1]; const dtRange = maxDt - minDt || 1; const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW; const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH; const step = (scaleHi - scaleLo) / 4; const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) })); const nX = Math.min(8, points.length); const xTicks = Array.from({ length: nX }, (_, i) => { const idx = Math.round((i / (nX - 1)) * (points.length - 1)); return points[idx]; }); return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) }; }, [points]); /* ── 4. Chemins SVG ── */ const { linePath, areaPath } = useMemo(() => { if (!xScale || points.length < 2) return { linePath: '', areaPath: '' }; let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`; for (let i = 1; i < points.length; i++) { line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`; } const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1); const area = `${line} V ${zeroY} H ${PAD.left} Z`; return { linePath: line, areaPath: area }; }, [points, xScale, yScale, yZero]); /* ── 5. Hover ── */ const handleMouseMove = (e) => { if (!svgRef.current || !xScale || points.length < 2) return; const rect = svgRef.current.getBoundingClientRect(); const svgX = ((e.clientX - rect.left) / rect.width) * W; const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate); let nearest = points[0], minDiff = Infinity; for (const p of points) { const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t); if (diff < minDiff) { minDiff = diff; nearest = p; } } setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date }); }; /* ── Tooltip ── */ const tooltipStyle = useMemo(() => { if (!hover) return null; const xPct = (hover.x / W) * 100; const yPct = (hover.y / H) * 100; const anchorRight = xPct > 65; return { position: 'absolute', top: `calc(${yPct}% - 64px)`, left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`, right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto', transform: 'none', pointerEvents: 'none', }; }, [hover]); if (!allPoints.length) return null; const displayLabel = platYear ? `31/12/${platYear}` : "Aujourd'hui"; const displayDate = hover ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' }) : displayLabel; const displayValue = fmtValueDisplay(hover ? hover.value : currentValue); const tooltipDate = hover ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) : null; return (
{/* ── En-tête ── */}
Capital investi · {displayDate}
{displayValue}
{RANGES.map(r => ( ))}
{/* ── SVG ── */} {xScale && ( setHover(null)} > {/* Grille horizontale */} {yTicks.map(({ v, y }) => ( {fmtK(v)} ))} {/* Ligne zéro */} {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( )} {/* Aire dégradée + courbe */} {/* Labels axe X */} {xTicks.map((p, i) => { const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'; return ( {fmtAxisDate(p.date, range)} ); })} {/* Hover : ligne verticale + point */} {hover && ( )} )} {/* ── Tooltip flottant ── */} {hover && tooltipStyle && (
{tooltipDate} {fmtValueDisplay(hover.value)}
)}
); }