Files
crowdlending-app/frontend/src/components/InvChart.jsx
T
Olivier CROGUENNEC 48ed7fe65e Initial commit
2026-06-13 14:57:15 +02:00

313 lines
13 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
{/* ── En-tête ── */}
<div className="solde-chart-header">
<div className="solde-chart-info">
<div className="solde-chart-date">Capital investi · {displayDate}</div>
<div className="solde-chart-value">{displayValue}</div>
</div>
<div className="solde-chart-controls">
<div className="solde-chart-ranges">
{RANGES.map(r => (
<button key={r}
className={`solde-range-btn${range === r ? ' active' : ''}`}
onClick={() => { setRange(r); setHover(null); }}>
{r}
</button>
))}
</div>
</div>
</div>
{/* ── SVG ── */}
{xScale && (
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="inv-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BLUE} stopOpacity="0.22" />
<stop offset="70%" stopColor={BLUE} stopOpacity="0.06" />
<stop offset="100%" stopColor={BLUE} stopOpacity="0" />
</linearGradient>
<filter id="inv-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* Grille horizontale */}
{yTicks.map(({ v, y }) => (
<g key={v}>
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
{fmtK(v)}
</text>
</g>
))}
{/* Ligne zéro */}
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
)}
{/* Aire dégradée + courbe */}
<path d={areaPath} fill="url(#inv-fill)" />
<path d={linePath} fill="none" stroke={BLUE} strokeWidth="1.5"
filter="url(#inv-glow)" strokeLinejoin="round" />
{/* Labels axe X */}
{xTicks.map((p, i) => {
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
return (
<text key={i} x={xScale(p.date)} y={H - 8}
textAnchor={anchor} fill={LABEL} fontSize="10"
fontFamily="system-ui,sans-serif">
{fmtAxisDate(p.date, range)}
</text>
);
})}
{/* Hover : ligne verticale + point */}
{hover && (
<g>
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
<circle cx={hover.x} cy={hover.y} r="4.5"
fill={BLUE} stroke={BG} strokeWidth="2" />
</g>
)}
</svg>
)}
{/* ── Tooltip flottant ── */}
{hover && tooltipStyle && (
<div style={tooltipStyle}>
<div className="sg-tooltip">
<span className="sg-tooltip-date">{tooltipDate}</span>
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
</div>
</div>
)}
</div>
);
}