- Engine: Add ppa_revenue_cr, mcp_revenue_cr, tariff, units to PnLRow - Engine: Split PPA vs MCP revenue in P&L computation - Web: Collapsible rows for PPA/MCP Revenue and Opex - Web: Highlighted rows (Total Revenue, EBITDA, EBIT, PBT, PAT) - Web: Units above Tariff in breakdown, bg-blue-50 highlight - Fix sticky column z-index for horizontal scroll - CLAUDE.md: Add project documentation Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
98 lines
2.7 KiB
TypeScript
98 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip,
|
|
ReferenceLine,
|
|
ResponsiveContainer,
|
|
Cell,
|
|
} from "recharts";
|
|
|
|
export interface TornadoEntry {
|
|
param_name: string;
|
|
low_value: number;
|
|
high_value: number;
|
|
base_kpi: number;
|
|
low_kpi: number;
|
|
high_kpi: number;
|
|
swing: number;
|
|
}
|
|
|
|
interface Props {
|
|
entries: TornadoEntry[];
|
|
kpiLabel?: string;
|
|
baseValue?: number;
|
|
}
|
|
|
|
interface ChartRow {
|
|
name: string;
|
|
low: number;
|
|
high: number;
|
|
base: number;
|
|
lowLabel: string;
|
|
highLabel: string;
|
|
}
|
|
|
|
export function TornadoChart({ entries, kpiLabel = "Equity IRR", baseValue }: Props) {
|
|
if (entries.length === 0) return null;
|
|
|
|
const base = baseValue ?? entries[0]?.base_kpi ?? 0;
|
|
const isPercent = kpiLabel.toLowerCase().includes("irr") || kpiLabel.toLowerCase().includes("cuf");
|
|
|
|
const fmtKpi = (v: number) =>
|
|
isPercent ? `${(v * 100).toFixed(1)}%` : v.toFixed(2);
|
|
|
|
const data: ChartRow[] = entries.map((e) => ({
|
|
name: e.param_name,
|
|
low: Math.min(e.low_kpi, e.high_kpi) - base,
|
|
high: Math.max(e.low_kpi, e.high_kpi) - base,
|
|
base,
|
|
lowLabel: fmtKpi(Math.min(e.low_kpi, e.high_kpi)),
|
|
highLabel: fmtKpi(Math.max(e.low_kpi, e.high_kpi)),
|
|
}));
|
|
|
|
const absMax = Math.max(...data.map((d) => Math.max(Math.abs(d.low), Math.abs(d.high))));
|
|
const domain = [-absMax * 1.1, absMax * 1.1];
|
|
|
|
return (
|
|
<div>
|
|
<h3 className="font-semibold mb-3 text-sm">
|
|
Sensitivity: {kpiLabel} (base = {fmtKpi(base)})
|
|
</h3>
|
|
<ResponsiveContainer width="100%" height={Math.max(200, entries.length * 45)}>
|
|
<BarChart
|
|
data={data}
|
|
layout="vertical"
|
|
margin={{ top: 0, right: 40, left: 120, bottom: 0 }}
|
|
>
|
|
<XAxis
|
|
type="number"
|
|
domain={domain}
|
|
tickFormatter={(v) => isPercent ? `${(v * 100).toFixed(1)}%` : v.toFixed(2)}
|
|
tick={{ fontSize: 10 }}
|
|
/>
|
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
|
|
<Tooltip
|
|
formatter={(value) => {
|
|
const v = typeof value === "number" ? value : 0;
|
|
return [
|
|
`${fmtKpi(base + v)} (Δ ${isPercent ? `${(v * 100).toFixed(1)}%` : v.toFixed(2)})`,
|
|
"",
|
|
];
|
|
}}
|
|
/>
|
|
<ReferenceLine x={0} stroke="#888" strokeWidth={1.5} />
|
|
<Bar dataKey="low" name="Low" stackId="a" fill="transparent" />
|
|
<Bar dataKey="high" name="High" stackId="a" radius={[0, 3, 3, 0]}>
|
|
{data.map((entry, index) => (
|
|
<Cell key={index} fill={entry.high >= 0 ? "#10b981" : "#f43f5e"} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|