HextorUI
lib

Radar geometry

radarLayout / radarRing / scorableMax — the single source of the Skill Radar silhouette.

npx shadcn@latest add @hextor/radar-geometry

Source

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.

Dependencies

@hextor/axes