'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 (
);
}
// ─── Types ──────────────────────────────────────────────────────────────────
interface ChartProps {
data: ReturnType['results']['yearlySummaries'];
labels: string[];
}