feat(charts): RadarChart + charts@0.1.1 (Wave 9.A.5)

Multi-axis polygon (spider) chart: token palette, concentric grid rings,
axis labels, NaN-safe. Pure SVG, role=img + title. 3 tests added (26/26 total);
tsc clean; published @bytelyst/charts@0.1.1 to Gitea.
This commit is contained in:
saravanakumardb1 2026-05-28 18:27:45 -07:00
parent fc8502ac0c
commit d04a303f98
4 changed files with 220 additions and 18 deletions

View File

@ -1,8 +1,8 @@
{ {
"name": "@bytelyst/charts", "name": "@bytelyst/charts",
"version": "0.1.0", "version": "0.1.1",
"type": "module", "type": "module",
"description": "Token-themed chart primitives — LineChart, BarChart, AreaChart, Donut, Gauge. Pure SVG, zero deps.", "description": "Token-themed chart primitives — LineChart, BarChart, AreaChart, Donut, Gauge, RadarChart. Pure SVG, zero deps.",
"exports": { "exports": {
".": { ".": {
"import": "./dist/index.js", "import": "./dist/index.js",
@ -11,7 +11,9 @@
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"files": ["dist"], "files": [
"dist"
],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "vitest run --pool forks", "test": "vitest run --pool forks",

View File

@ -0,0 +1,150 @@
import { useId, type CSSProperties } from 'react';
export interface RadarSeries {
/** Stable id — React key + accessible label. */
id: string;
/** One value per axis; non-finite values are treated as 0. */
values: number[];
/** Optional override colour (defaults walk the token palette). */
color?: string;
/** Optional display label (falls back to `id`). */
label?: string;
}
export interface RadarChartProps {
/** Axis (category) labels — defines the number of spokes. */
axes: string[];
/** One or more series plotted as polygons. */
series: RadarSeries[];
/** Total width/height in px. Default 260. */
size?: number;
/** Fixed maximum for the radial scale. Defaults to the largest value. */
max?: number;
/** Concentric grid rings. Default 4. */
levels?: number;
/** 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)',
];
const safe = (v: number): number => (Number.isFinite(v) ? v : 0);
/** Cartesian point for axis `i` of `n` at radius `radius`. */
function spoke(cx: number, cy: number, radius: number, i: number, n: number): [number, number] {
const angle = -Math.PI / 2 + (i / n) * 2 * Math.PI;
return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)];
}
const toPath = (pts: ReadonlyArray<[number, number]>): string =>
pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`).join(' ') + ' Z';
/**
* `<RadarChart>` multi-axis polygon (spider) chart. Pure SVG, token-themed,
* NaN-safe. Wave 9.A.5.
*/
export function RadarChart({
axes,
series,
size = 260,
max,
levels = 4,
ariaLabel,
className,
style,
}: RadarChartProps) {
const titleId = useId();
const n = axes.length;
const cx = size / 2;
const cy = size / 2;
const r = (size / 2) * 0.74; // leave room for labels
const derivedMax = max ?? Math.max(1, ...series.flatMap(s => s.values.map(v => safe(v))));
const scale = (v: number) => (derivedMax === 0 ? 0 : (safe(v) / derivedMax) * r);
return (
<svg
role="img"
aria-labelledby={titleId}
viewBox={`0 0 ${size} ${size}`}
width={size}
height={size}
data-testid="bl-radar"
className={className}
style={style}
>
<title id={titleId}>{ariaLabel ?? 'Radar chart'}</title>
{/* Concentric grid rings */}
{n >= 3 &&
Array.from({ length: levels }, (_, l) => {
const ringR = (r * (l + 1)) / levels;
const pts = Array.from({ length: n }, (_, i) => spoke(cx, cy, ringR, i, n));
return (
<path
key={`ring-${l}`}
d={toPath(pts)}
fill="none"
stroke="var(--bl-border-subtle, rgba(0,0,0,0.08))"
data-testid="bl-radar-ring"
/>
);
})}
{/* Spokes + axis labels */}
{axes.map((label, i) => {
const [ex, ey] = spoke(cx, cy, r, i, n);
const [lx, ly] = spoke(cx, cy, r + 14, i, n);
return (
<g key={`axis-${i}`}>
<line
x1={cx}
y1={cy}
x2={ex}
y2={ey}
stroke="var(--bl-border-subtle, rgba(0,0,0,0.08))"
/>
<text
x={lx}
y={ly}
fontSize={11}
textAnchor={lx > cx + 1 ? 'start' : lx < cx - 1 ? 'end' : 'middle'}
dominantBaseline="middle"
fill="var(--bl-text-secondary, #444)"
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{label}
</text>
</g>
);
})}
{/* Series polygons */}
{series.map((s, si) => {
const colour = s.color ?? TOKEN_PALETTE[si % TOKEN_PALETTE.length]!;
const pts = axes.map((_, i) => spoke(cx, cy, scale(s.values[i] ?? 0), i, n));
return (
<path
key={s.id}
d={toPath(pts)}
fill={colour}
fillOpacity={0.18}
stroke={colour}
strokeWidth={2}
strokeLinejoin="round"
data-testid="bl-radar-series"
data-series-id={s.id}
/>
);
})}
</svg>
);
}

View File

@ -6,6 +6,7 @@ import { BarChart } from '../BarChart.js';
import { AreaChart } from '../AreaChart.js'; import { AreaChart } from '../AreaChart.js';
import { Donut } from '../Donut.js'; import { Donut } from '../Donut.js';
import { Gauge } from '../Gauge.js'; import { Gauge } from '../Gauge.js';
import { RadarChart } from '../RadarChart.js';
import { compactNumber, extent, filterFinite, linearScale, smoothPath } from '../utils.js'; import { compactNumber, extent, filterFinite, linearScale, smoothPath } from '../utils.js';
beforeEach(() => cleanup()); beforeEach(() => cleanup());
@ -29,7 +30,12 @@ describe('utils', () => {
it('smoothPath returns empty for empty input, M-only for single', () => { it('smoothPath returns empty for empty input, M-only for single', () => {
expect(smoothPath([])).toBe(''); expect(smoothPath([])).toBe('');
expect(smoothPath([[1, 2]])).toBe('M 1 2'); expect(smoothPath([[1, 2]])).toBe('M 1 2');
expect(smoothPath([[0, 0], [10, 10]])).toMatch(/^M 0 0 C/); expect(
smoothPath([
[0, 0],
[10, 10],
])
).toMatch(/^M 0 0 C/);
}); });
it('compactNumber renders k-suffix, fractions, and empty for non-finite', () => { it('compactNumber renders k-suffix, fractions, and empty for non-finite', () => {
@ -57,7 +63,7 @@ describe('LineChart', () => {
{ id: 'a', values: [1, 2, 3] }, { id: 'a', values: [1, 2, 3] },
{ id: 'b', values: [3, 2, 1] }, { id: 'b', values: [3, 2, 1] },
]} ]}
/>, />
); );
expect(screen.getAllByTestId('bl-line-chart-series')).toHaveLength(2); expect(screen.getAllByTestId('bl-line-chart-series')).toHaveLength(2);
}); });
@ -71,15 +77,13 @@ describe('LineChart', () => {
it('records SSR-safe useId on the title', () => { it('records SSR-safe useId on the title', () => {
render(<LineChart series={[{ id: 'a', values: [1] }]} ariaLabel="My label" />); render(<LineChart series={[{ id: 'a', values: [1] }]} ariaLabel="My label" />);
expect(screen.getByTestId('bl-line-chart').querySelector('title')?.textContent).toBe('My label'); expect(screen.getByTestId('bl-line-chart').querySelector('title')?.textContent).toBe(
'My label'
);
}); });
it('drops NaN / Infinity from a series rather than emitting NaN in the SVG path', () => { it('drops NaN / Infinity from a series rather than emitting NaN in the SVG path', () => {
render( render(<LineChart series={[{ id: 'gappy', values: [1, NaN, 3, Infinity, 5] }]} />);
<LineChart
series={[{ id: 'gappy', values: [1, NaN, 3, Infinity, 5] }]}
/>,
);
const path = screen.getByTestId('bl-line-chart-series').querySelector('path'); const path = screen.getByTestId('bl-line-chart-series').querySelector('path');
const d = path?.getAttribute('d') ?? ''; const d = path?.getAttribute('d') ?? '';
expect(d).not.toMatch(/NaN/); expect(d).not.toMatch(/NaN/);
@ -102,7 +106,7 @@ describe('BarChart', () => {
{ id: 'b', value: 20 }, { id: 'b', value: 20 },
{ id: 'c', value: 15 }, { id: 'c', value: 15 },
]} ]}
/>, />
); );
expect(screen.getAllByTestId('bl-bar-chart-bar')).toHaveLength(3); expect(screen.getAllByTestId('bl-bar-chart-bar')).toHaveLength(3);
}); });
@ -114,7 +118,7 @@ describe('BarChart', () => {
{ id: 'a', value: -5 }, { id: 'a', value: -5 },
{ id: 'b', value: 5 }, { id: 'b', value: 5 },
]} ]}
/>, />
); );
// Just confirm no throw + both rendered. // Just confirm no throw + both rendered.
expect(screen.getAllByTestId('bl-bar-chart-bar')).toHaveLength(2); expect(screen.getAllByTestId('bl-bar-chart-bar')).toHaveLength(2);
@ -150,13 +154,20 @@ describe('Donut', () => {
{ id: 's2', value: 20 }, { id: 's2', value: 20 },
{ id: 's3', value: 50 }, { id: 's3', value: 50 },
]} ]}
/>, />
); );
expect(screen.getAllByTestId('bl-donut-slice')).toHaveLength(3); expect(screen.getAllByTestId('bl-donut-slice')).toHaveLength(3);
}); });
it('renders an empty muted ring when total is 0', () => { it('renders an empty muted ring when total is 0', () => {
render(<Donut slices={[{ id: 'a', value: 0 }, { id: 'b', value: 0 }]} />); render(
<Donut
slices={[
{ id: 'a', value: 0 },
{ id: 'b', value: 0 },
]}
/>
);
expect(screen.queryAllByTestId('bl-donut-slice')).toHaveLength(0); expect(screen.queryAllByTestId('bl-donut-slice')).toHaveLength(0);
expect(screen.getByTestId('bl-donut').querySelector('[data-kind="empty"]')).not.toBeNull(); expect(screen.getByTestId('bl-donut').querySelector('[data-kind="empty"]')).not.toBeNull();
}); });
@ -168,7 +179,7 @@ describe('Donut', () => {
{ id: 'all', value: 100 }, { id: 'all', value: 100 },
{ id: 'tiny', value: 0 }, { id: 'tiny', value: 0 },
]} ]}
/>, />
); );
expect(screen.getByTestId('bl-donut').querySelector('[data-kind="full"]')).not.toBeNull(); expect(screen.getByTestId('bl-donut').querySelector('[data-kind="full"]')).not.toBeNull();
}); });
@ -176,9 +187,12 @@ describe('Donut', () => {
it('renders centerContent in the hole', () => { it('renders centerContent in the hole', () => {
render( render(
<Donut <Donut
slices={[{ id: 'a', value: 50 }, { id: 'b', value: 50 }]} slices={[
{ id: 'a', value: 50 },
{ id: 'b', value: 50 },
]}
centerContent={<span data-testid="donut-center">75%</span>} centerContent={<span data-testid="donut-center">75%</span>}
/>, />
); );
expect(screen.getByTestId('donut-center')).toBeDefined(); expect(screen.getByTestId('donut-center')).toBeDefined();
}); });
@ -206,3 +220,36 @@ describe('Gauge', () => {
expect(screen.getByTestId('cap')).toBeDefined(); expect(screen.getByTestId('cap')).toBeDefined();
}); });
}); });
describe('RadarChart (Wave 9.A.5)', () => {
const axes = ['Speed', 'Power', 'Range', 'Cost', 'A11y'];
it('renders one polygon per series + a labelled, role=img svg', () => {
render(
<RadarChart
ariaLabel="Capability profile"
axes={axes}
series={[
{ id: 'now', values: [8, 6, 7, 4, 9] },
{ id: 'goal', values: [9, 9, 8, 6, 10] },
]}
/>
);
const svg = screen.getByTestId('bl-radar');
expect(svg.getAttribute('role')).toBe('img');
expect(screen.getByText('Capability profile')).toBeDefined();
expect(screen.getAllByTestId('bl-radar-series')).toHaveLength(2);
for (const a of axes) expect(screen.getByText(a)).toBeDefined();
});
it('draws `levels` concentric grid rings', () => {
render(<RadarChart axes={axes} levels={3} series={[{ id: 's', values: [1, 2, 3, 4, 5] }]} />);
expect(screen.getAllByTestId('bl-radar-ring')).toHaveLength(3);
});
it('is NaN-safe — produces no NaN in the path', () => {
render(<RadarChart axes={axes} series={[{ id: 's', values: [NaN, 2, Infinity, 4, 5] }]} />);
const d = screen.getAllByTestId('bl-radar-series')[0]!.getAttribute('d') ?? '';
expect(d).not.toMatch(/NaN/);
});
});

View File

@ -34,4 +34,7 @@ export type { DonutProps, DonutSlice } from './Donut.js';
export { Gauge } from './Gauge.js'; export { Gauge } from './Gauge.js';
export type { GaugeProps } from './Gauge.js'; export type { GaugeProps } from './Gauge.js';
export { RadarChart } from './RadarChart.js';
export type { RadarChartProps, RadarSeries } from './RadarChart.js';
export { extent, linearScale, smoothPath, formatNumber } from './utils.js'; export { extent, linearScale, smoothPath, formatNumber } from './utils.js';