Initial commit
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user