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.
210 lines
5.3 KiB
TypeScript
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)} />;
|
|
}
|