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:
parent
fc8502ac0c
commit
d04a303f98
@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@bytelyst/charts",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"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": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
@ -11,7 +11,9 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks",
|
||||
|
||||
150
packages/charts/src/RadarChart.tsx
Normal file
150
packages/charts/src/RadarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { BarChart } from '../BarChart.js';
|
||||
import { AreaChart } from '../AreaChart.js';
|
||||
import { Donut } from '../Donut.js';
|
||||
import { Gauge } from '../Gauge.js';
|
||||
import { RadarChart } from '../RadarChart.js';
|
||||
import { compactNumber, extent, filterFinite, linearScale, smoothPath } from '../utils.js';
|
||||
|
||||
beforeEach(() => cleanup());
|
||||
@ -29,7 +30,12 @@ describe('utils', () => {
|
||||
it('smoothPath returns empty for empty input, M-only for single', () => {
|
||||
expect(smoothPath([])).toBe('');
|
||||
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', () => {
|
||||
@ -57,7 +63,7 @@ describe('LineChart', () => {
|
||||
{ id: 'a', values: [1, 2, 3] },
|
||||
{ id: 'b', values: [3, 2, 1] },
|
||||
]}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByTestId('bl-line-chart-series')).toHaveLength(2);
|
||||
});
|
||||
@ -71,15 +77,13 @@ describe('LineChart', () => {
|
||||
|
||||
it('records SSR-safe useId on the title', () => {
|
||||
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', () => {
|
||||
render(
|
||||
<LineChart
|
||||
series={[{ id: 'gappy', values: [1, NaN, 3, Infinity, 5] }]}
|
||||
/>,
|
||||
);
|
||||
render(<LineChart series={[{ id: 'gappy', values: [1, NaN, 3, Infinity, 5] }]} />);
|
||||
const path = screen.getByTestId('bl-line-chart-series').querySelector('path');
|
||||
const d = path?.getAttribute('d') ?? '';
|
||||
expect(d).not.toMatch(/NaN/);
|
||||
@ -102,7 +106,7 @@ describe('BarChart', () => {
|
||||
{ id: 'b', value: 20 },
|
||||
{ id: 'c', value: 15 },
|
||||
]}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByTestId('bl-bar-chart-bar')).toHaveLength(3);
|
||||
});
|
||||
@ -114,7 +118,7 @@ describe('BarChart', () => {
|
||||
{ id: 'a', value: -5 },
|
||||
{ id: 'b', value: 5 },
|
||||
]}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
// Just confirm no throw + both rendered.
|
||||
expect(screen.getAllByTestId('bl-bar-chart-bar')).toHaveLength(2);
|
||||
@ -150,13 +154,20 @@ describe('Donut', () => {
|
||||
{ id: 's2', value: 20 },
|
||||
{ id: 's3', value: 50 },
|
||||
]}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByTestId('bl-donut-slice')).toHaveLength(3);
|
||||
});
|
||||
|
||||
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.getByTestId('bl-donut').querySelector('[data-kind="empty"]')).not.toBeNull();
|
||||
});
|
||||
@ -168,7 +179,7 @@ describe('Donut', () => {
|
||||
{ id: 'all', value: 100 },
|
||||
{ id: 'tiny', value: 0 },
|
||||
]}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('bl-donut').querySelector('[data-kind="full"]')).not.toBeNull();
|
||||
});
|
||||
@ -176,9 +187,12 @@ describe('Donut', () => {
|
||||
it('renders centerContent in the hole', () => {
|
||||
render(
|
||||
<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>}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('donut-center')).toBeDefined();
|
||||
});
|
||||
@ -206,3 +220,36 @@ describe('Gauge', () => {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -34,4 +34,7 @@ export type { DonutProps, DonutSlice } from './Donut.js';
|
||||
export { Gauge } 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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user