147 lines
5.0 KiB
React
147 lines
5.0 KiB
React
|
|
import React from 'react';
|
||
|
|
|
||
|
|
const W = 720;
|
||
|
|
const H = 300;
|
||
|
|
const PAD = { left: 68, right: 24, top: 20, bottom: 56 };
|
||
|
|
const CW = W - PAD.left - PAD.right;
|
||
|
|
const CH = H - PAD.top - PAD.bottom;
|
||
|
|
|
||
|
|
function money(v) {
|
||
|
|
const n = Number(v) || 0;
|
||
|
|
if (n >= 1000) return `$${(n / 1000).toFixed(0)}k`;
|
||
|
|
return `$${n.toFixed(0)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function fullMoney(v) {
|
||
|
|
return (Number(v) || 0).toLocaleString(undefined, {
|
||
|
|
style: 'currency', currency: 'USD', maximumFractionDigits: 2,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildPoints(track, startBalance, maxMonths) {
|
||
|
|
const all = [{ month: 0, balance: startBalance }, ...track];
|
||
|
|
return all.map(({ month, balance }) => ({
|
||
|
|
x: PAD.left + (month / maxMonths) * CW,
|
||
|
|
y: PAD.top + CH - (balance / startBalance) * CH,
|
||
|
|
month,
|
||
|
|
balance,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function toLine(pts) {
|
||
|
|
return pts.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack = [], startBalance = 1 }) {
|
||
|
|
const maxMonths = Math.max(minTrack.length, currentTrack.length, simTrack.length, 12);
|
||
|
|
const bal = Math.max(startBalance, 1);
|
||
|
|
|
||
|
|
const minPts = buildPoints(minTrack, bal, maxMonths);
|
||
|
|
const currentPts = buildPoints(currentTrack, bal, maxMonths);
|
||
|
|
const simPts = buildPoints(simTrack, bal, maxMonths);
|
||
|
|
|
||
|
|
const xStep = maxMonths <= 24 ? 6 : maxMonths <= 60 ? 12 : 24;
|
||
|
|
const xLabels = [];
|
||
|
|
for (let m = xStep; m <= maxMonths; m += xStep) {
|
||
|
|
xLabels.push(m);
|
||
|
|
}
|
||
|
|
|
||
|
|
const yTicks = [0, 0.25, 0.5, 0.75, 1];
|
||
|
|
|
||
|
|
const showCurrent = currentTrack.length > 0 &&
|
||
|
|
currentTrack.some((c, i) => (minTrack[i]?.balance ?? null) !== c.balance);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="w-full overflow-hidden rounded-xl border border-border/60 bg-background/60">
|
||
|
|
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Loan payoff chart" className="h-auto w-full">
|
||
|
|
|
||
|
|
{/* Grid + Y axis */}
|
||
|
|
{yTicks.map(tick => {
|
||
|
|
const y = PAD.top + CH - tick * CH;
|
||
|
|
return (
|
||
|
|
<g key={tick}>
|
||
|
|
<line x1={PAD.left} x2={PAD.left + CW} y1={y} y2={y}
|
||
|
|
stroke="currentColor" opacity="0.08" strokeWidth="1" />
|
||
|
|
<text x={PAD.left - 6} y={y + 4} fontSize="11" fill="currentColor"
|
||
|
|
opacity="0.5" textAnchor="end">
|
||
|
|
{money(bal * tick)}
|
||
|
|
</text>
|
||
|
|
</g>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
|
||
|
|
{/* X axis labels */}
|
||
|
|
{xLabels.map(m => {
|
||
|
|
const x = PAD.left + (m / maxMonths) * CW;
|
||
|
|
return (
|
||
|
|
<text key={m} x={x} y={H - 38} fontSize="11" fill="currentColor"
|
||
|
|
opacity="0.5" textAnchor="middle">
|
||
|
|
{m}mo
|
||
|
|
</text>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
|
||
|
|
{/* X axis baseline */}
|
||
|
|
<line x1={PAD.left} x2={PAD.left + CW} y1={PAD.top + CH} y2={PAD.top + CH}
|
||
|
|
stroke="currentColor" opacity="0.12" strokeWidth="1" />
|
||
|
|
|
||
|
|
{/* Min-only track (slate dashed) */}
|
||
|
|
{minPts.length > 1 && (
|
||
|
|
<polyline points={toLine(minPts)} fill="none"
|
||
|
|
stroke="#94a3b8" strokeWidth="1.5"
|
||
|
|
strokeDasharray="6,4" strokeLinecap="round" strokeLinejoin="round"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Current snowball plan (indigo dashed) */}
|
||
|
|
{showCurrent && currentPts.length > 1 && (
|
||
|
|
<polyline points={toLine(currentPts)} fill="none"
|
||
|
|
stroke="#818cf8" strokeWidth="1.5"
|
||
|
|
strokeDasharray="9,5" strokeLinecap="round" strokeLinejoin="round"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Simulation track (amber solid, prominent) */}
|
||
|
|
{simPts.length > 1 && (
|
||
|
|
<>
|
||
|
|
<polyline points={toLine(simPts)} fill="none"
|
||
|
|
stroke="#f59e0b" strokeWidth="3"
|
||
|
|
strokeLinecap="round" strokeLinejoin="round"
|
||
|
|
/>
|
||
|
|
{/* Endpoint dot */}
|
||
|
|
{(() => {
|
||
|
|
const last = simPts[simPts.length - 1];
|
||
|
|
return <circle cx={last.x} cy={last.y} r="4" fill="#f59e0b" />;
|
||
|
|
})()}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Tooltips at 6-month intervals on sim track */}
|
||
|
|
{simPts.filter(p => p.month > 0 && p.month % 6 === 0).map(p => (
|
||
|
|
<circle key={p.month} cx={p.x} cy={p.y} r="3" fill="#f59e0b" opacity="0.7">
|
||
|
|
<title>{`Month ${p.month}: ${fullMoney(p.balance)} remaining`}</title>
|
||
|
|
</circle>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* Legend */}
|
||
|
|
<g transform={`translate(${PAD.left}, ${H - 22})`} fontSize="11" fill="currentColor" opacity="0.7">
|
||
|
|
<line x1="0" x2="16" y1="0" y2="0" stroke="#94a3b8" strokeWidth="1.5" strokeDasharray="4,3" />
|
||
|
|
<text x="20" y="4">Min only</text>
|
||
|
|
|
||
|
|
{showCurrent && (
|
||
|
|
<>
|
||
|
|
<line x1="80" x2="96" y1="0" y2="0" stroke="#818cf8" strokeWidth="1.5" strokeDasharray="6,4" />
|
||
|
|
<text x="100" y="4">Snowball plan</text>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<line x1={showCurrent ? 198 : 80} x2={showCurrent ? 214 : 96} y1="0" y2="0"
|
||
|
|
stroke="#f59e0b" strokeWidth="2.5" />
|
||
|
|
<text x={showCurrent ? 218 : 100} y="4">Simulation</text>
|
||
|
|
</g>
|
||
|
|
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|