'use client'; import React, { useMemo } from 'react'; import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, BarElement, ArcElement, Title, Tooltip, Legend, Filler, type ChartOptions, } from 'chart.js'; import { Line, Bar, Doughnut } from 'react-chartjs-2'; import { useSimulation } from '@/contexts/SimulationContext'; import { formatCurrencyShort, formatCurrency } from '@/lib/formatters'; // Register ChartJS components ChartJS.register( CategoryScale, LinearScale, PointElement, LineElement, BarElement, ArcElement, Title, Tooltip, Legend, Filler ); // ─── Color palette ────────────────────────────────────────────────────────── const COLORS = { capital: { bg: 'rgba(37, 99, 235, 0.15)', border: 'rgb(37, 99, 235)' }, interest: { bg: 'rgba(245, 158, 11, 0.15)', border: 'rgb(245, 158, 11)' }, insurance: { bg: 'rgba(139, 92, 246, 0.15)', border: 'rgb(139, 92, 246)' }, charges: { bg: 'rgba(107, 114, 128, 0.15)', border: 'rgb(107, 114, 128)' }, rent: { bg: 'rgba(239, 68, 68, 0.15)', border: 'rgb(239, 68, 68)' }, projected: { bg: 'rgba(37, 99, 235, 0.15)', border: 'rgb(37, 99, 235)' }, resale: { bg: 'rgba(5, 150, 105, 0.15)', border: 'rgb(5, 150, 105)' }, negative: { bg: 'rgba(220, 38, 38, 0.15)', border: 'rgb(220, 38, 38)' }, }; const defaultTooltipOptions = { mode: 'index' as const, intersect: false, backgroundColor: 'rgba(15, 23, 42, 0.9)', titleColor: '#fff', bodyColor: '#cbd5e1', borderColor: 'rgba(148, 163, 184, 0.3)', borderWidth: 1, cornerRadius: 8, padding: 12, bodySpacing: 6, titleSpacing: 4, usePointStyle: true, }; const defaultLegendOptions = { position: 'bottom' as const, labels: { usePointStyle: true, pointStyle: 'circle', padding: 16, font: { size: 11 }, color: '#64748b', }, }; // ─── Main Charts Component ───────────────────────────────────────────────── export default function Charts() { const { results } = useSimulation(); const data = results.yearlySummaries.filter((y) => y.year <= results.maxDuration); const labels = data.map((y) => `An ${y.year}`); return (
{/* 1. Remaining balance over time */} {/* 2. Capital vs Interest vs Insurance breakdown */} {/* 3. Monthly comparison: rent vs projected */} {/* 4. Resale projection */}
{/* Cost breakdown donut */}
); } // ─── Chart Components ─────────────────────────────────────────────────────── function RemainingBalanceChart({ data, labels }: ChartProps) { const { results } = useSimulation(); const chartData = useMemo(() => { // Stack per-loan remaining balances const datasets = results.loanResults.map((lr, idx) => { const colors = [ COLORS.capital, COLORS.interest, COLORS.insurance, COLORS.charges, COLORS.rent, ]; const color = colors[idx % colors.length]; return { label: lr.loan.name, data: data.map((y) => { const yearData = lr.yearly.find((yr) => yr.year === y.year); return yearData?.remaining ?? 0; }), borderColor: color.border, backgroundColor: color.bg, fill: true, tension: 0.3, pointRadius: 0, pointHitRadius: 10, borderWidth: 2, }; }); return { labels, datasets }; }, [data, labels, results.loanResults]); const options: ChartOptions<'line'> = { responsive: true, maintainAspectRatio: false, interaction: defaultTooltipOptions, plugins: { tooltip: { ...defaultTooltipOptions, callbacks: { label: (ctx) => `${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y ?? 0)}`, }, }, legend: defaultLegendOptions, }, scales: { x: { grid: { display: false }, ticks: { color: '#94a3b8', font: { size: 10 } }, }, y: { stacked: true, grid: { color: 'rgba(148, 163, 184, 0.1)' }, ticks: { color: '#94a3b8', font: { size: 10 }, callback: (v) => formatCurrencyShort(v as number), }, }, }, }; return (
); } function PaymentBreakdownChart({ data, labels }: ChartProps) { const chartData = useMemo( () => ({ labels, datasets: [ { label: 'Capital', data: data.map((y) => y.totalPrincipal), backgroundColor: COLORS.capital.border, borderRadius: 3, }, { label: 'Intérêts', data: data.map((y) => y.totalInterest), backgroundColor: COLORS.interest.border, borderRadius: 3, }, { label: 'Assurance', data: data.map((y) => y.totalInsurance), backgroundColor: COLORS.insurance.border, borderRadius: 3, }, ], }), [data, labels] ); const options: ChartOptions<'bar'> = { responsive: true, maintainAspectRatio: false, plugins: { tooltip: { ...defaultTooltipOptions, callbacks: { label: (ctx) => `${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y ?? 0)}`, }, }, legend: defaultLegendOptions, }, scales: { x: { stacked: true, grid: { display: false }, ticks: { color: '#94a3b8', font: { size: 10 } }, }, y: { stacked: true, grid: { color: 'rgba(148, 163, 184, 0.1)' }, ticks: { color: '#94a3b8', font: { size: 10 }, callback: (v) => formatCurrencyShort(v as number), }, }, }, }; return (
); } function ComparisonChart({ data, labels }: ChartProps) { const chartData = useMemo( () => ({ labels, datasets: [ { label: 'Mensualité propriétaire', data: data.map((y) => y.monthlyTotal), borderColor: COLORS.projected.border, backgroundColor: COLORS.projected.bg, fill: true, tension: 0.3, pointRadius: 2, pointHitRadius: 10, borderWidth: 2, }, { label: 'Coût locataire', data: data.map((y) => y.currentMonthlyTotal), borderColor: COLORS.rent.border, backgroundColor: COLORS.rent.bg, fill: true, tension: 0.3, pointRadius: 2, pointHitRadius: 10, borderWidth: 2, borderDash: [5, 3], }, ], }), [data, labels] ); const options: ChartOptions<'line'> = { responsive: true, maintainAspectRatio: false, plugins: { tooltip: { ...defaultTooltipOptions, callbacks: { label: (ctx) => `${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y ?? 0)}/mois`, }, }, legend: defaultLegendOptions, }, scales: { x: { grid: { display: false }, ticks: { color: '#94a3b8', font: { size: 10 } }, }, y: { grid: { color: 'rgba(148, 163, 184, 0.1)' }, ticks: { color: '#94a3b8', font: { size: 10 }, callback: (v) => formatCurrencyShort(v as number), }, }, }, }; return (
); } function ResaleChart({ data, labels }: ChartProps) { const chartData = useMemo( () => ({ labels, datasets: [ { label: 'Plus/moins-value nette', data: data.map((y) => y.netResale), borderColor: data.map((y) => y.netResale >= 0 ? COLORS.resale.border : COLORS.negative.border ), backgroundColor: data.map((y) => y.netResale >= 0 ? COLORS.resale.bg : COLORS.negative.bg ), borderWidth: 2, borderRadius: 3, }, ], }), [data, labels] ); const options: ChartOptions<'bar'> = { responsive: true, maintainAspectRatio: false, plugins: { tooltip: { ...defaultTooltipOptions, callbacks: { label: (ctx) => `Plus/moins-value: ${formatCurrency(ctx.parsed.y ?? 0)}`, }, }, legend: { display: false }, }, scales: { x: { grid: { display: false }, ticks: { color: '#94a3b8', font: { size: 10 } }, }, y: { grid: { color: 'rgba(148, 163, 184, 0.1)' }, ticks: { color: '#94a3b8', font: { size: 10 }, callback: (v) => formatCurrencyShort(v as number), }, }, }, }; return (
); } function CostDonut() { const { results } = useSimulation(); const chartData = useMemo( () => ({ labels: ['Capital', 'Intérêts', 'Assurance'], datasets: [ { data: [results.totalCapital, results.totalInterest, results.totalInsurance], backgroundColor: [ COLORS.capital.border, COLORS.interest.border, COLORS.insurance.border, ], borderWidth: 0, hoverOffset: 8, }, ], }), [results] ); const options: ChartOptions<'doughnut'> = { responsive: true, maintainAspectRatio: true, cutout: '65%', plugins: { tooltip: { ...defaultTooltipOptions, callbacks: { label: (ctx) => { const total = results.grandTotal; const pct = total > 0 ? ((ctx.parsed / total) * 100).toFixed(1) : 0; return `${ctx.label}: ${formatCurrency(ctx.parsed)} (${pct}%)`; }, }, }, legend: defaultLegendOptions, }, }; return (
{formatCurrency(results.grandTotal)}
Coût total
); } // ─── Chart Card Wrapper ───────────────────────────────────────────────────── function ChartCard({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } // ─── Types ────────────────────────────────────────────────────────────────── interface ChartProps { data: ReturnType['results']['yearlySummaries']; labels: string[]; }