learning_ai_common_plat/packages/charts/src/Donut.tsx
saravanakumardb1 2eaec32849 feat(charts): @bytelyst/charts@0.1.0 — Wave 9.A.1-4 LineChart / BarChart / AreaChart / Donut / Gauge
A new package — pure-SVG token-themed chart primitives, zero deps,
SSR-safe. Slots into the existing dataviz family next to
@bytelyst/data-viz (which owns Sparkline + KpiCard + Heatmap).

──────────────────────────────────────────────────────────────────
Components
──────────────────────────────────────────────────────────────────
<LineChart>   Wave 9.A.1
  - Multi-series with per-series colour override
  - Smooth (catmull-rom) or straight polyline
  - 3-tick subtle Y grid + per-tick label
  - SSR-safe title id via useId()

<BarChart>    Wave 9.A.2
  - Negative-value support via configurable baseline
  - Per-bar colour override; per-bar accessible label
  - Compact-K tick labels

<AreaChart>   Wave 9.A.3
  - Single-series with gradient fill + line stroke
  - Zero-baseline included automatically when data >= 0

<Donut>       Wave 9.A.4 (a)
  - Categorical share-of-total ring
  - Token palette walks 6 colours; per-slice override
  - Empty / all-zero data renders a muted ring (no NaN slices)
  - Single near-100% slice collapses to a closed ring
  - centerContent slot via <foreignObject>

<Gauge>       Wave 9.A.4 (b)
  - Half-circle 'fuel-tank' dial
  - NaN / out-of-range clamped safely
  - Caption slot below the dial

──────────────────────────────────────────────────────────────────
Shared utilities (src/utils.ts) — also exported
──────────────────────────────────────────────────────────────────
  - linearScale, extent (NaN-safe), smoothPath, formatNumber

──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
  ✓ pnpm -F @bytelyst/charts test  →  19/19 passing
    - utils:   3 cases (extent / linearScale / smoothPath edge cases)
    - Line:    3 cases (series count, colour override, useId aria)
    - Bar:     3 cases (count, diverging negative, colour)
    - Area:    2 cases (gradient + line, single-point safety)
    - Donut:   4 cases (slice count, empty ring, full-ring collapse,
                        centerContent slot)
    - Gauge:   4 cases (in-domain, clamp >max, NaN→min, caption)
  ✓ pnpm -F @bytelyst/charts build  →  tsc clean
  ✓ No console.log / no Math.random for ids
  ✓ All primitives honour the design-system anti-patterns doc

──────────────────────────────────────────────────────────────────
Deferred to 0.2.x
──────────────────────────────────────────────────────────────────
  - <StackedBar>, <RadarChart> (see roadmap §9.A)

Showcase routes + roadmap flips land in the paired commit.
2026-05-27 17:16:46 -07:00

210 lines
5.3 KiB
TypeScript

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)',
];
/**
* `<Donut>` — 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 (
<svg
role="img"
aria-labelledby={titleId}
viewBox={`0 0 ${size} ${size}`}
width={size}
height={size}
data-testid="bl-donut"
className={className}
style={style}
>
<title id={titleId}>{ariaLabel ?? 'Donut chart'}</title>
{total === 0 ? (
<RingShape
cx={cx}
cy={cy}
rOuter={rOuter}
rInner={rInner}
fill="var(--bl-surface-muted, rgba(0,0,0,0.06))"
dataKind="empty"
/>
) : (
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 (
<ArcSlice
key={slice.id}
cx={cx}
cy={cy}
rOuter={rOuter}
rInner={rInner}
startFraction={start}
endFraction={end}
fill={colour}
testId="bl-donut-slice"
data-slice-id={slice.id}
/>
);
})
)}
{centerContent !== undefined && (
<foreignObject
x={cx - rInner}
y={cy - rInner}
width={rInner * 2}
height={rInner * 2}
>
<div
style={{
width: '100%',
height: '100%',
display: 'grid',
placeItems: 'center',
textAlign: 'center',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
}}
>
{centerContent}
</div>
</foreignObject>
)}
</svg>
);
}
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 <path d={d} fill={fill} fillRule="evenodd" data-kind={dataKind} />;
}
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 <RingShape cx={cx} cy={cy} rOuter={rOuter} rInner={rInner} fill={fill} dataKind="full" />;
}
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 <path d={d} fill={fill} data-testid={testId} {...(rest as object)} />;
}