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.
151 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|