learning_ai_common_plat/packages/charts/src/RadarChart.tsx
saravanakumardb1 d04a303f98 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.
2026-05-28 18:27:45 -07:00

151 lines
4.3 KiB
TypeScript

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>
);
}