import { useState, type PointerEvent, type ReactNode } from "react"; import { Pie } from "@visx/shape"; import { Group } from "@visx/group"; import { localPoint } from "@visx/event"; import { ChartTooltip, TooltipRow } from "./ChartTooltip"; export interface DonutBreakdownPart { key: string; label: string; /** Positive magnitude; parts should sum to (or near) the slice's own value. */ value: number; /** Name of an HSL colour token, e.g. "--chart-green". */ colorVar: string; } export interface DonutSlice { name: string; value: number; color: string; /** What this slice's value is made of, e.g. cost basis vs. gain. Shown as a * small segmented bar and a value per part in the hover tooltip; omit when * there's nothing to break the slice down into. */ breakdown?: DonutBreakdownPart[]; } interface Props { data: DonutSlice[]; size: number; thickness?: number; formatValue: (n: number) => string; /** Centre readout when nothing is hovered, e.g. the total. */ centerLabel: string; centerValue: ReactNode; /** Highlight a slice from outside (e.g. the table row being hovered). When * given, it takes over from the donut's own hover state. */ active?: string | null; /** Called when the pointer or focus moves onto / off a slice. */ onActiveChange?: (name: string | null) => void; } /** A thin bar split by explicit parts (not auto-assigned identity colours, * unlike WeightBar) — used for a value's own composition, e.g. principal vs * gain, where colour means direction rather than "which entity". */ function CompositionBar({ parts, total }: { parts: DonutBreakdownPart[]; total: number }) { const shown = parts.filter((p) => p.value !== 0); if (shown.length === 0 || total <= 0) return null; return (
); } /** * A donut whose centre is the readout: hovering or focusing a slice swaps the * centre from the total to that slice's name, value and share, no floating * tooltip covering the ring. The active slice lifts slightly; the rest fade. * * A slice with `breakdown` also gets a floating tooltip near the pointer * (the ring is curved, so a fixed-position readout can't sit "on" a slice the * way a bar or bin can) showing what the value is made of: a small segmented * bar plus one row per part. */ export function DonutChart({ data, size, thickness = 30, formatValue, centerLabel, centerValue, active: activeProp, onActiveChange }: Props) { const [internalActive, setInternalActive] = useState{hovered.name}