import { useId, type CSSProperties, type ReactNode } from 'react'; export interface DonutSlice { /** Stable id — React key + accessible label. */ id: string; /** Slice value — must be ≥ 0. */ value: number; /** Optional override colour (defaults walk the token palette). */ color?: string; /** Optional display label (falls back to `id`). */ label?: string; } export interface DonutProps { slices: DonutSlice[]; /** Total diameter in px. Default 200. */ size?: number; /** Donut hole radius as a fraction of the outer radius. Default 0.6. */ innerRatio?: number; /** Slot rendered in the centre — typically a total / percent. */ centerContent?: ReactNode; /** Accessible label. */ ariaLabel?: string; className?: string; style?: CSSProperties; } const TOKEN_PALETTE = [ 'var(--bl-accent, #6366f1)', 'var(--bl-success, #10b981)', 'var(--bl-warning, #f59e0b)', 'var(--bl-info, #0ea5e9)', 'var(--bl-danger, #ef4444)', 'color-mix(in srgb, var(--bl-accent, #6366f1) 50%, transparent)', ]; /** * `` — categorical share-of-total chart. Pure SVG. * * Wave 9.A.4. Empty / all-zero data renders an unsegmented muted ring * (no NaN slices, no console warnings). */ export function Donut({ slices, size = 200, innerRatio = 0.6, centerContent, ariaLabel, className, style, }: DonutProps) { const titleId = useId(); const cx = size / 2; const cy = size / 2; const rOuter = (size / 2) * 0.96; const rInner = rOuter * Math.max(0, Math.min(0.95, innerRatio)); const total = slices.reduce((s, x) => s + Math.max(0, x.value), 0); return ( {ariaLabel ?? 'Donut chart'} {total === 0 ? ( ) : ( slices.map((slice, i) => { const colour = slice.color ?? TOKEN_PALETTE[i % TOKEN_PALETTE.length]!; const start = priorSum(slices, i) / total; const end = priorSum(slices, i + 1) / total; return ( ); }) )} {centerContent !== undefined && (
{centerContent}
)}
); } function priorSum(slices: DonutSlice[], n: number): number { let s = 0; for (let i = 0; i < n && i < slices.length; i++) { s += Math.max(0, slices[i]!.value); } return s; } function RingShape({ cx, cy, rOuter, rInner, fill, dataKind, }: { cx: number; cy: number; rOuter: number; rInner: number; fill: string; dataKind: string; }) { // Outer circle minus inner circle via even-odd path. const d = [ `M ${cx - rOuter} ${cy}`, `a ${rOuter} ${rOuter} 0 1 0 ${rOuter * 2} 0`, `a ${rOuter} ${rOuter} 0 1 0 ${-rOuter * 2} 0`, `Z`, `M ${cx - rInner} ${cy}`, `a ${rInner} ${rInner} 0 1 0 ${rInner * 2} 0`, `a ${rInner} ${rInner} 0 1 0 ${-rInner * 2} 0`, `Z`, ].join(' '); return ; } function ArcSlice({ cx, cy, rOuter, rInner, startFraction, endFraction, fill, testId, ...rest }: { cx: number; cy: number; rOuter: number; rInner: number; startFraction: number; endFraction: number; fill: string; testId?: string; [k: string]: unknown; }) { // Treat near-full slice as a closed ring (single-slice donut). if (endFraction - startFraction >= 0.999) { return ; } const a0 = startFraction * 2 * Math.PI - Math.PI / 2; const a1 = endFraction * 2 * Math.PI - Math.PI / 2; const x0o = cx + rOuter * Math.cos(a0); const y0o = cy + rOuter * Math.sin(a0); const x1o = cx + rOuter * Math.cos(a1); const y1o = cy + rOuter * Math.sin(a1); const x0i = cx + rInner * Math.cos(a0); const y0i = cy + rInner * Math.sin(a0); const x1i = cx + rInner * Math.cos(a1); const y1i = cy + rInner * Math.sin(a1); const largeArc = endFraction - startFraction > 0.5 ? 1 : 0; const d = [ `M ${x0o} ${y0o}`, `A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${x1o} ${y1o}`, `L ${x1i} ${y1i}`, `A ${rInner} ${rInner} 0 ${largeArc} 0 ${x0i} ${y0i}`, 'Z', ].join(' '); return ; }