diff --git a/packages/charts/package.json b/packages/charts/package.json index 16ef51c2..347b4a62 100644 --- a/packages/charts/package.json +++ b/packages/charts/package.json @@ -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", diff --git a/packages/charts/src/RadarChart.tsx b/packages/charts/src/RadarChart.tsx new file mode 100644 index 00000000..a955aee8 --- /dev/null +++ b/packages/charts/src/RadarChart.tsx @@ -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'; + +/** + * `` — 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 ( + + {ariaLabel ?? 'Radar chart'} + + {/* 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 ( + + ); + })} + + {/* 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 ( + + + 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} + + + ); + })} + + {/* 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 ( + + ); + })} + + ); +} diff --git a/packages/charts/src/__tests__/charts.test.tsx b/packages/charts/src/__tests__/charts.test.tsx index 1dc0634a..bebbe9f5 100644 --- a/packages/charts/src/__tests__/charts.test.tsx +++ b/packages/charts/src/__tests__/charts.test.tsx @@ -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(); - 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( - , - ); + render(); 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(); + render( + + ); 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( 75%} - />, + /> ); 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( + + ); + 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(); + expect(screen.getAllByTestId('bl-radar-ring')).toHaveLength(3); + }); + + it('is NaN-safe — produces no NaN in the path', () => { + render(); + const d = screen.getAllByTestId('bl-radar-series')[0]!.getAttribute('d') ?? ''; + expect(d).not.toMatch(/NaN/); + }); +}); diff --git a/packages/charts/src/index.ts b/packages/charts/src/index.ts index 2c7acf14..f3abab4b 100644 --- a/packages/charts/src/index.ts +++ b/packages/charts/src/index.ts @@ -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';