Radar geometry
radarLayout / radarRing / scorableMax — the single source of the Skill Radar silhouette.
npx shadcn@latest add @hextor/radar-geometrySource
import { AXES, type AxisMeta, type AxisScore } from "@/registry/hextor/lib/axes";
/**
* Radar geometry — the single source of the silhouette.
*
* There is more than one renderer of this shape (the interactive block, and
* whatever bakes the static share card), and they MUST agree. A shape that
* differs by a few degrees between the page and the image someone screenshots
* is a shape nobody can compare, which defeats the reason axis order is frozen
* in the first place.
*
* So the angles live here, once. Nothing in this file may depend on the
* caller's array order — `AXES` is the order, always.
*/
export interface RadarVertex {
meta: AxisMeta;
score?: AxisScore;
/** 0..1 of this axis' own max. Zero when withheld — never plotted, see below. */
pct: number;
withheld: boolean;
/** Vertex at the data value. */
x: number;
y: number;
/** Outer end of the spoke, at 100%. */
ox: number;
oy: number;
/** Label anchor, just outside the outer ring. */
lx: number;
ly: number;
/** Derived from the label's side of the centre. */
anchor: "start" | "middle" | "end";
}
export interface RadarLayout {
cx: number;
cy: number;
r: number;
vertices: RadarVertex[];
/**
* Points string for the series polygon, built from the SCORED axes only.
*
* A withheld axis is excluded rather than plotted at zero. Pulling its vertex
* to the centre would draw the user as having failed an axis we simply
* declined to measure — the shape would blame them for our benchmark
* hardware. The dashed spoke and the em-dash label carry the omission instead.
*/
polygon: string;
scoredCount: number;
anyWithheld: boolean;
}
/**
* Normalisation note: every axis is plotted as a percentage of ITS OWN max, not
* as raw points. Raw points would let correctness (max 40) dwarf code quality
* (max 5), so the silhouette would encode the rubric's weighting rather than the
* person's skill, and everyone's shape would look roughly the same.
*/
export function radarLayout(
axes: AxisScore[],
opts: { size: number; radius: number; labelGap?: number },
): RadarLayout {
const { size, radius: r } = opts;
const labelGap = opts.labelGap ?? 26;
const cx = size / 2;
const cy = size / 2;
const byKey = new Map(axes.map((a) => [a.axis, a]));
const step = 360 / AXES.length;
const vertices: RadarVertex[] = AXES.map((meta, i) => {
const score = byKey.get(meta.key);
/**
* Three different ways an axis can be unplottable, deliberately collapsed
* into one: explicitly withheld, absent from the input entirely, or
* carrying a max that cannot produce a percentage.
*
* Only the first is what callers usually mean, but the other two used to
* fall through to `pct = 0` and be drawn as a genuine, honestly-earned
* score of nothing — the exact outcome the withheld path exists to
* prevent, reached by forgetting an array entry. A non-positive max was
* worse still: it yields NaN/Infinity coordinates and the polygon silently
* disappears. If we cannot compute an honest percentage, we do not draw
* one.
*/
const withheld = !score || score.withheld === true || !(score.max > 0);
const pct = withheld ? 0 : score.points / score.max;
// -90° puts axis 1 at twelve o'clock and the rest run clockwise. Both the
// start angle and the direction are part of the frozen identity.
const angle = (-90 + i * step) * (Math.PI / 180);
const lx = cx + Math.cos(angle) * (r + labelGap);
return {
meta,
score,
pct,
withheld,
x: cx + Math.cos(angle) * r * pct,
y: cy + Math.sin(angle) * r * pct,
ox: cx + Math.cos(angle) * r,
oy: cy + Math.sin(angle) * r,
lx,
ly: cy + Math.sin(angle) * (r + labelGap),
anchor:
Math.abs(lx - cx) < size * 0.05 ? "middle" : lx > cx ? "start" : "end",
};
});
const scored = vertices.filter((v) => !v.withheld);
return {
cx,
cy,
r,
vertices,
polygon: scored.map((v) => `${v.x},${v.y}`).join(" "),
scoredCount: scored.length,
anyWithheld: scored.length !== vertices.length,
};
}
/** Ring polygon at a fraction of the radius, for the recessive grid. */
export function radarRing(
frac: number,
opts: { size: number; radius: number },
): string {
const cx = opts.size / 2;
const cy = opts.size / 2;
const step = 360 / AXES.length;
return AXES.map((_, i) => {
const a = (-90 + i * step) * (Math.PI / 180);
return `${cx + Math.cos(a) * opts.radius * frac},${cy + Math.sin(a) * opts.radius * frac}`;
}).join(" ");
}
/** Sum of the maxima that were actually scorable — withheld axes excluded. */
export function scorableMax(axes: AxisScore[]): number {
return axes.filter((a) => !a.withheld).reduce((s, a) => s + a.max, 0);
}
Docs
Split out from the renderer so that every surface drawing this shape — the interactive block, a static share card, an OG image — produces the identical silhouette. Two implementations would eventually disagree, and a shape that differs between the page and the screenshot is a shape nobody can compare.
The start angle (-90°, axis 1 at twelve o'clock) and the clockwise direction are part of the frozen identity, not styling.
An axis is treated as withheld — excluded from the polygon, dashed spoke, em-dash label — in three cases, not one: explicitly flagged withheld, absent from the input array entirely, or carrying a max that cannot produce a percentage. The last two used to be drawn as an honestly-earned score of zero, which is the exact outcome the withheld path exists to prevent, reached by forgetting an array entry.