Skill Radar
The shareable identity object: a five-axis radar plus the accurate bar read of the same scores.
npx shadcn@latest add @hextor/skill-radarSource
"use client";
import { useId, useState } from "react";
import type { AxisMeta, AxisScore } from "@/registry/hextor/lib/axes";
import { radarLayout, radarRing } from "@/registry/hextor/lib/radar-geometry";
import { cn } from "@/registry/hextor/lib/utils";
import { resolveLabels, type RadarLabels } from "@/registry/hextor/components/hextor/skill-radar/labels";
/**
* The Skill Radar — the shareable identity object, not the primary read.
*
* A radar is a genuinely poor instrument for *comparing* magnitudes: its area
* reads as a quantity but is not one, and the silhouette changes wildly with
* axis order. Two decisions make it honest enough to ship:
*
* 1. Every axis is normalised to % of its own max. Raw points would make
* correctness (max 40) dwarf code quality (max 5) and the shape would encode
* the rubric's weighting rather than the user's skill.
* 2. Axis order is frozen in `AXES` forever, so one person's shape is
* comparable to another's and to their own from six months ago.
*
* The numbers still live next to it, and `<AxisBars>` is the accurate read.
* This exists because a recognisable silhouette is what someone screenshots.
*
* One series → one hue, no legend (the vertex labels carry identity).
*/
export function SkillRadar({
axes,
size = 260,
className,
labels,
}: {
axes: AxisScore[];
size?: number;
className?: string;
/** Overrides `defaultLabels` (Thai) per-axis; pass nothing to keep them. */
labels?: Partial<RadarLabels>;
}) {
const gradId = useId();
const [hover, setHover] = useState<string | null>(null);
const copy = resolveLabels(labels);
const R = size / 2 - 46; // headroom for vertex labels
const geom = { size, radius: R };
/**
* Geometry comes from `lib/radar-geometry.ts`, shared with the share card's
* static renderer. Two implementations of the same silhouette would
* eventually disagree, and a shape that differs between the report and the
* screenshotted card is a shape nobody can compare.
*
* Withheld axes are EXCLUDED from the polygon rather than plotted at zero —
* see the note on `RadarLayout.polygon`. The spoke stays, dashed, with an
* em-dash label, so the omission is visible instead of silent.
*/
const { cx, cy, vertices: points, polygon, scoredCount, anyWithheld } =
radarLayout(axes, geom);
const ring = (frac: number) => radarRing(frac, geom);
return (
<div className={cn("relative", className)}>
<svg
viewBox={`0 0 ${size} ${size}`}
width={size}
height={size}
role="img"
aria-label={`Skill radar: ${points
.map((p) => `${p.meta.labelEn} ${Math.round(p.pct * 100)}%`)
.join(", ")}`}
className="overflow-visible"
>
<defs>
<radialGradient id={gradId}>
<stop offset="0%" stopColor="var(--foreground)" stopOpacity="0.20" />
<stop offset="100%" stopColor="var(--foreground)" stopOpacity="0.07" />
</radialGradient>
</defs>
{/* recessive grid — rings at 25/50/75/100% */}
{[0.25, 0.5, 0.75, 1].map((f) => (
<polygon
key={f}
points={ring(f)}
fill="none"
stroke="var(--viz-grid)"
strokeWidth={1}
/>
))}
{points.map((p) => (
<line
key={p.meta.key}
x1={cx}
y1={cy}
x2={p.ox}
y2={p.oy}
stroke={p.withheld ? "var(--viz-ink-muted)" : "var(--viz-grid)"}
strokeWidth={1}
strokeDasharray={p.withheld ? "3 3" : undefined}
opacity={p.withheld ? 0.5 : 1}
/>
))}
{/* the series — single hue, 2px stroke */}
{scoredCount >= 3 && (
<polygon
points={polygon}
fill={`url(#${gradId})`}
stroke="var(--foreground)"
strokeWidth={2}
strokeLinejoin="round"
className="transition-all duration-500 ease-out"
/>
)}
{/* vertices carry axis identity; hollow when the axis is withheld */}
{points.map((p) => (
<g key={p.meta.key}>
{/* No marker for a withheld axis — any position on the spoke would
read as a value. The dashed spoke and the em-dash carry it. */}
{!p.withheld && (
<circle
cx={p.x}
cy={p.y}
r={hover === p.meta.key ? 6 : 4.5}
fill={p.meta.cssVar}
stroke="var(--background)"
strokeWidth={2}
className="transition-all"
/>
)}
{/* hit target larger than the mark; on the outer label for withheld */}
<circle
cx={p.withheld ? p.ox : p.x}
cy={p.withheld ? p.oy : p.y}
r={14}
fill="transparent"
onMouseEnter={() => setHover(p.meta.key)}
onMouseLeave={() => setHover(null)}
/>
</g>
))}
{/* direct labels — identity is never colour-alone */}
{points.map((p) => {
return (
<g key={`l-${p.meta.key}`}>
<text
x={p.lx}
y={p.ly - 3}
textAnchor={p.anchor}
className="fill-muted-foreground text-[10px]"
>
{p.meta.labelEn}
</text>
<text
x={p.lx}
y={p.ly + 9}
textAnchor={p.anchor}
className="tnum fill-foreground text-[11px] font-medium"
>
{p.withheld ? "—" : `${Math.round(p.pct * 100)}%`}
</text>
</g>
);
})}
</svg>
{hover && (
<RadarTooltip
axis={points.find((p) => p.meta.key === hover)!}
copy={copy}
/>
)}
{anyWithheld && (
<p className="mt-1 text-center text-[10px] text-muted-foreground">
{copy.withheldFootnote}
</p>
)}
</div>
);
}
/**
* A hand-rolled panel, not a tooltip primitive: it is ONE stable panel pinned
* to the bottom of the radar, not one anchored per-vertex, so it does not jump
* as the pointer moves between vertices near the edge of the SVG.
*/
function RadarTooltip({
axis,
copy,
}: {
axis: { meta: AxisMeta; score?: AxisScore; withheld: boolean };
copy: RadarLabels;
}) {
const axisCopy = copy.axes[axis.meta.key];
return (
<div className="pointer-events-none absolute inset-x-0 -bottom-1 mx-auto w-max max-w-[92%] rounded-md border bg-popover px-2.5 py-1.5 text-popover-foreground shadow-md">
<div className="flex items-center gap-1.5 text-xs font-medium">
<span
className="size-2 shrink-0 rounded-full"
style={{ background: axis.meta.cssVar }}
/>
{axisCopy.label}
<span className="tnum text-muted-foreground">
{axis.withheld
? "— / " + axis.meta.max
: `${axis.score?.points.toFixed(1)} / ${axis.meta.max}`}
</span>
</div>
<p className="mt-0.5 max-w-[38ch] text-[10px] leading-snug text-muted-foreground">
{axis.withheld ? axis.score?.withheldReason : axisCopy.blurb}
</p>
</div>
);
}
Docs
A radar is a genuinely poor instrument for comparing magnitudes — its area reads as a quantity but is not one, and the silhouette changes wildly with axis order. Two decisions make this one honest enough to ship: every axis is normalised to a percentage of its OWN max (raw points would let correctness, max 40, dwarf code quality, max 5, so the shape would encode the rubric's weighting rather than the person's skill), and axis order is frozen forever in @hextor/axes. Ship <AxisBars> alongside it — bars beat the radar at every job except being memorable, and being memorable is the radar's entire job.
A withheld axis is EXCLUDED from the polygon rather than plotted at zero. Pulling its vertex to the centre would draw someone as having failed an axis you merely declined to measure. The dashed spoke and the em-dash label carry the omission instead, and the footnote says so in words.
No headless-library surface: the hover panel is a plain absolutely-positioned div, pinned below the radar rather than anchored per-vertex so it does not jump as the pointer moves between vertices. Nothing here needs Base UI.
Copy defaults to Thai via defaultLabels. Pass labels to override per axis — you should not have to fork the frozen axis table to change a sentence.
Needs @hextor/viz-tokens: axis colour is read through the --axis-* custom properties, applied inline rather than as Tailwind classes because v4's scanner cannot see a class assembled at runtime.