MyMidas/frontend/src/pages/investments/PortfolioCharts.tsx
megaproxy 1854026a76 Fix theme text visibility, tooltip colours, and chart hover states
- Extract shared TOOLTIP_STYLE and ACTIVE_DOT to utils/chartTheme.ts so
  all four chart files use one source of truth
- itemStyle now uses card-foreground (not muted-foreground) — guarantees
  tooltip text is readable on all themes including Terminal, Vault, Synthwave
- cursor now uses primary at 12% opacity — always visible and thematic,
  replaces near-invisible muted-foreground at 8% opacity
- activeDot is now explicit on every Line/Area — prevents Recharts default
  white dot breaking dark themes
- Terminal: muted-foreground bumped 38%→55% lightness, border lightened,
  warning brightened, text-shadow scoped to headings/labels/table cells
  (was applying to every p and span, causing tooltip glow bleed)
- Synthwave: muted-foreground bumped 56%→68% lightness for legibility
- Vault: muted-foreground bumped 52%→60% lightness

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 23:14:36 +00:00

286 lines
10 KiB
TypeScript

import {
PieChart, Pie, Cell, Tooltip, ResponsiveContainer,
BarChart, Bar, XAxis, YAxis, CartesianGrid, ReferenceLine, LabelList,
} from "recharts";
import { formatCurrency } from "@/utils/currency";
import type { PortfolioSummary, HoldingResponse } from "@/api/investments";
import { TOOLTIP_STYLE } from "@/utils/chartTheme";
const COLORS = [
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
];
const TYPE_COLORS: Record<string, string> = {
stock: "#6366f1",
etf: "#22c55e",
crypto: "#f97316",
fund: "#14b8a6",
bond: "#f59e0b",
other: "#94a3b8",
};
function typeColor(type: string) {
return TYPE_COLORS[type.toLowerCase()] ?? TYPE_COLORS.other;
}
function DonutTooltip({ active, payload, currency }: any) {
if (!active || !payload?.length) return null;
const d = payload[0];
return (
<div className="bg-card border border-border rounded-lg px-3 py-2 text-xs shadow-lg">
<p className="font-semibold">{d.name}</p>
<p className="text-muted-foreground">{formatCurrency(d.value, currency)}</p>
<p className="text-muted-foreground">{d.payload.pct?.toFixed(1)}%</p>
</div>
);
}
// ── Donut: allocation by holding ─────────────────────────────────────────────
export function AllocationDonut({ portfolio }: { portfolio: PortfolioSummary }) {
const val = (h: HoldingResponse) => Number(h.current_value || h.cost_basis_total);
const total = portfolio.holdings.reduce((s, h) => s + val(h), 0);
const data = portfolio.holdings
.filter((h) => val(h) > 0)
.map((h, i) => ({
name: h.symbol,
value: val(h),
pct: (val(h) / total) * 100,
fill: COLORS[i % COLORS.length],
}));
if (data.length === 0) return null;
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Allocation by Holding</p>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius="55%"
outerRadius="80%"
paddingAngle={2}
dataKey="value"
>
{data.map((d) => (
<Cell key={d.name} fill={d.fill} stroke="transparent" />
))}
</Pie>
<Tooltip content={<DonutTooltip currency={portfolio.currency} />} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
{data.map((d) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: d.fill }} />
<span>{d.name}</span>
<span className="text-foreground font-medium">{d.pct.toFixed(1)}%</span>
</div>
))}
</div>
</div>
);
}
// ── Donut: allocation by asset type ──────────────────────────────────────────
export function AssetTypeDonut({ portfolio }: { portfolio: PortfolioSummary }) {
const byType: Record<string, number> = {};
for (const h of portfolio.holdings) {
const t = (h.asset_type || "other").toLowerCase();
byType[t] = (byType[t] ?? 0) + Number(h.current_value || h.cost_basis_total);
}
const total = Object.values(byType).reduce((s, v) => s + v, 0);
const data = Object.entries(byType)
.filter(([, v]) => v > 0)
.map(([type, value]) => ({
name: type.charAt(0).toUpperCase() + type.slice(1),
value,
pct: (value / total) * 100,
fill: typeColor(type),
}))
.sort((a, b) => b.value - a.value);
if (data.length === 0) return null;
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Allocation by Asset Type</p>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius="55%"
outerRadius="80%"
paddingAngle={2}
dataKey="value"
>
{data.map((d) => (
<Cell key={d.name} fill={d.fill} stroke="transparent" />
))}
</Pie>
<Tooltip content={<DonutTooltip currency={portfolio.currency} />} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
{data.map((d) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: d.fill }} />
<span>{d.name}</span>
<span className="text-foreground font-medium">{d.pct.toFixed(1)}%</span>
</div>
))}
</div>
</div>
);
}
// ── Bar: cost basis vs current value ─────────────────────────────────────────
function CurrencyYAxis({ value, currency }: { value: number; currency: string }) {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}k`;
return formatCurrency(value, currency);
}
export function CostVsValueChart({ portfolio }: { portfolio: PortfolioSummary }) {
const data = portfolio.holdings
.filter((h) => h.cost_basis_total > 0)
.map((h) => ({
symbol: h.symbol,
cost: h.cost_basis_total,
value: h.current_value ?? h.cost_basis_total,
gain: (h.current_value ?? h.cost_basis_total) - h.cost_basis_total,
}))
.sort((a, b) => b.value - a.value);
if (data.length === 0) return null;
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Cost Basis vs Current Value</p>
<ResponsiveContainer width="100%" height={Math.max(200, data.length * 52)}>
<BarChart data={data} margin={{ top: 4, right: 16, left: 16, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="symbol"
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={(v) => CurrencyYAxis({ value: v, currency: portfolio.currency })}
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
width={52}
/>
<Tooltip
{...TOOLTIP_STYLE}
formatter={(value: number, name: string) => [
formatCurrency(value, portfolio.currency),
name === "cost" ? "Cost basis" : "Current value",
]}
/>
<Bar dataKey="cost" name="cost" fill="hsl(var(--muted-foreground))" opacity={0.4} radius={[4, 4, 0, 0]} barSize={20} />
<Bar dataKey="value" name="value" radius={[4, 4, 0, 0]} barSize={20}>
{data.map((d) => (
<Cell
key={d.symbol}
fill={d.gain >= 0 ? "hsl(var(--success))" : "hsl(var(--destructive))"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm bg-muted-foreground opacity-40" />
Cost basis
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm" style={{ background: "hsl(var(--success))" }} />
Current value (gain)
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm" style={{ background: "hsl(var(--destructive))" }} />
Current value (loss)
</div>
</div>
</div>
);
}
// ── Bar: return % per holding ─────────────────────────────────────────────────
export function ReturnChart({ portfolio }: { portfolio: PortfolioSummary }) {
const data = portfolio.holdings
.filter((h) => h.unrealised_gain_pct != null)
.map((h) => ({
symbol: h.symbol,
pct: Number(h.unrealised_gain_pct),
gain: h.unrealised_gain ?? 0,
}))
.sort((a, b) => b.pct - a.pct);
if (data.length === 0) return null;
const absMax = Math.max(...data.map((d) => Math.abs(d.pct)), 5);
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Return % by Holding</p>
<ResponsiveContainer width="100%" height={Math.max(180, data.length * 44)}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 4, right: 48, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
<XAxis
type="number"
domain={[-absMax, absMax]}
tickFormatter={(v) => `${v.toFixed(0)}%`}
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
/>
<YAxis
type="category"
dataKey="symbol"
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
width={48}
/>
<ReferenceLine x={0} stroke="hsl(var(--border))" strokeWidth={1.5} />
<Tooltip
{...TOOLTIP_STYLE}
formatter={(value: number) => [`${Number(value).toFixed(2)}%`, "Return"]}
/>
<Bar dataKey="pct" radius={[0, 4, 4, 0]} barSize={18}>
<LabelList
dataKey="pct"
position="right"
formatter={(v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(1)}%`}
style={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
/>
{data.map((d) => (
<Cell
key={d.symbol}
fill={d.pct >= 0 ? "hsl(var(--success))" : "hsl(var(--destructive))"}
fillOpacity={0.85}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}