import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; import { formatCryptoAmount, maskAmount } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; import type { CryptoHolding } from "@/types"; interface CryptoAllocationBodyProps { holdings: CryptoHolding[]; isLoading: boolean; hidden: boolean; } // Fixed categorical order, same 8-slot ramp the rest of the app already // validated (see net_worth_service.py's _CLASS_META comment) — a coin never // gets a freshly generated hue. Only the first 7 get their own color; an 8th+ // coin folds into "Other" (--series-other) rather than cycling back through // the ramp, per the dataviz skill's categorical-color rule. const SERIES_COLORS = [ "var(--series-1)", "var(--series-2)", "var(--series-3)", "var(--series-4)", "var(--series-5)", "var(--series-6)", "var(--series-7)", ]; const OTHER_COLOR = "var(--series-other)"; const MAX_SLICES = SERIES_COLORS.length; export interface Slice { key: string; name: string; symbol: string | null; amount: number; color: string; } // Exported for direct unit testing (see CryptoAllocationBody.test.ts) — // the donut/table JSX below isn't worth rendering just to check the math. export function buildSlices(holdings: CryptoHolding[]): Slice[] { const priced = holdings .filter((h) => h.value !== null && Number(h.value) > 0) .map((h) => ({ key: String(h.asset_id), name: h.name, symbol: h.symbol, amount: Number(h.value) })) .sort((a, b) => b.amount - a.amount); const top = priced.slice(0, MAX_SLICES).map((item, index) => ({ ...item, color: SERIES_COLORS[index] })); const rest = priced.slice(MAX_SLICES); if (rest.length === 0) return top; const otherTotal = rest.reduce((sum, item) => sum + item.amount, 0); return [...top, { key: "other", name: "Other", symbol: null, amount: otherTotal, color: OTHER_COLOR }]; } function DonutTooltip({ active, payload, hidden, otherLabel, }: { active?: boolean; payload?: Array<{ payload: Slice & { percent: number } }>; hidden: boolean; otherLabel: string; }) { if (!active || !payload?.length) return null; const slice = payload[0].payload; return (
{slice.key === "other" ? otherLabel : slice.name}
{maskAmount(formatCryptoAmount(slice.amount), hidden)} · {slice.percent.toFixed(1)}%
| {slice.key === "other" ? t("crypto.allocation.other") : slice.symbol} | {slice.percent.toFixed(1)}% | {maskAmount(formatCryptoAmount(slice.amount), hidden)} |