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",
|
"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",
|
||||||
|
|||||||
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 { 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user