{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-pitch-class-clock",
  "type": "registry:ui",
  "title": "Pitch Class Clock",
  "description": "The twelve pitch classes round a dial with the sounding ones in the value fill, joined into a polygon, so a chord becomes a recognisable shape. Order the ring by semitone, fifth or whole tone. size is pixels or \"fill\" (scales to its container). With onToggle the dial becomes an input: twelve named checkboxes in one roving Tab stop (arrows walk the ring, Home and End), toggled by click, Enter or Space; labelOf respells node text; note names from music-theory.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/pitch-class-clock.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/pitch-class-clock\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * Pitch-Class Clock — the twelve pitch classes round a dial, with the sounding\n * ones lit and joined into the chord's shape.\n *\n * Collapsing pitch to pitch class throws away octave and voicing, and what\n * survives is the thing that actually names a chord. Drawn on a ring, a chord\n * becomes a POLYGON, and the polygon is a fingerprint: every major triad is the\n * same triangle at a different rotation, every minor triad its mirror, a\n * diminished seventh a perfect square. Transposition rotates the shape; quality\n * changes it. That is a fact about the music you can see at a glance and cannot\n * read off a note list.\n *\n * `sequence` chooses the ring order, and it changes which chords look simple.\n * \"chromatic\" spaces notes by semitone, so scales are arcs and triads are\n * lopsided. \"fifths\" spaces them by fifth, so diatonic material clusters and a\n * triad opens out. \"+2\" walks in whole tones. The nodes stay equidistant in all\n * three — only the labels move — so the shapes stay comparable.\n *\n * Ported from codex-music-mono's `ClockOctave.vue` (which delegated each node to\n * a `NoteClock.vue`; both are folded into this one file, since a node is a\n * circle and a label). The angle formula and ring ordering are carried over\n * exactly. Deliberate deviations:\n *\n *   - The layout is an exported pure function (`clockLayout`), so the ring\n *     ordering and vertex order are testable in Node rather than only visible.\n *   - Colours come from the kit's tokens instead of a private set of\n *     `--clock-*` / `--note-clock-*` custom properties. One theming contract for\n *     the whole kit beats a bespoke one per component, and `color` overrides it.\n *     Sounding nodes use the value fill (`--uikit-value`), not the accent, and\n *     every node label stands at least 4.5:1 against its disc.\n *   - `notes` accepts raw MIDI numbers as well as pitch classes — they are\n *     reduced mod 12 — so a live note list can be passed straight through.\n *\n * ## Editing a set\n *\n * With `onToggle` the clock becomes an input: each node is a\n * `role=\"checkbox\"`, checked when it sounds, named by `nodeLabel` (\"Pitch\n * class 4\" by default), and a click, Enter or Space asks for that pitch class\n * to be toggled. The clock stays controlled: `notes` is still the truth, so a\n * consumer that refuses a toggle simply does not change it. The dial is then a\n * named `role=\"group\"` rather than an image and one Tab stop (roving\n * tabindex): the arrow keys walk the ring (Right and Down clockwise, Left and\n * Up back), Home and End go to its first and last node, and Tab leaves. The\n * stop starts on the first sounding node. Without `onToggle` nothing changes:\n * a picture, one `role=\"img\"`.\n *\n * `size` is the square's side in pixels, or \"fill\": the SVG then takes its\n * container's box and the dial scales to the smaller side (drawn on a\n * 240-unit square, so `nodeRadius` is in those units).\n *\n * `pointer` draws a tuner needle at a continuous pitch: the integer part picks\n * the ring node and the fraction leans toward the next semitone, so a note 30\n * cents sharp sits visibly past its node. Values are reduced mod 12, and\n * MIDI pitches (for example 64.3) can be passed straight through.\n *\n * `glowing` lists the pitch classes stimulated right now. Each gets a halo\n * that lights at once and, when it stops being listed, fades out over\n * `glowFadeMs` through a CSS transition — a trail of what was just played\n * that needs no re-render to decay.\n *\n * `labelOf` spells the text inside each node, for a dial that reads in\n * pitch-class integers (0 1 2 … 9 t e) rather than note names.\n * `nodeRadiusOf` can shrink individual discs without changing the ring\n * geometry, focus rings or hit targets. The caller owns the data behind that\n * visual size (for example an observed note distribution).\n *\n * SSR-safe: pure SVG computed from props, no measurement, no effects.\n *\n * Styling comes from CSS custom properties — see `lib/control-theme/theme.ts`.\n */\n\nimport { useRef, useState, type CSSProperties, type KeyboardEvent } from \"react\"\nimport { pitchClassName } from \"maruaudio/ui/shared/music-theory-pitch-class\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  CONSOLE_ON_VALUE,\n  CONSOLE_VALUE,\n  ConsoleStyles,\n} from \"maruaudio/ui/shared/console-styles\"\n\n/** A pitch class's default node text: its sharp name (\"C♯\"). */\nfunction noteName(pitchClass: number): string {\n  return pitchClassName(pitchClass, \"unicode-sharp\")\n}\n\n/** The side of the square a `size=\"fill\"` dial is drawn on, in SVG units. */\nexport const CLOCK_FILL_SIZE = 240\n\n/**\n * The ring index a key moves focus to from ring index `index`, or null for a\n * key the dial does not handle: Right and Down step clockwise, Left and Up\n * back (both wrap), Home and End go to the first and last node.\n */\nexport function clockKeyTarget(index: number, key: string): number | null {\n  switch (key) {\n    case \"ArrowRight\":\n    case \"ArrowDown\":\n      return mod(index + 1, 12)\n    case \"ArrowLeft\":\n    case \"ArrowUp\":\n      return mod(index - 1, 12)\n    case \"Home\":\n      return 0\n    case \"End\":\n      return 11\n    default:\n      return null\n  }\n}\n\n/** The ring orderings on offer. */\nexport type ClockSequence = \"chromatic\" | \"fifths\" | \"+2\"\n\n/** How the sounding notes are joined up. */\nexport type ClockChordShape = \"polygon\" | \"lines\" | \"none\"\n\n/** One node on the dial. */\nexport interface ClockNode {\n  /** The pitch class this node represents, 0..11. */\n  pitchClass: number\n  /** Its position round the ring, 0..11 — NOT the pitch class unless chromatic. */\n  index: number\n  x: number\n  y: number\n}\n\n/** The dial's layout: nodes plus the geometry they were placed with. */\nexport interface ClockLayout {\n  cx: number\n  cy: number\n  /** Ring radius, inset so the nodes sit fully inside the box. */\n  radius: number\n  /** The twelve nodes, in RING order (index 0 first). */\n  nodes: ClockNode[]\n}\n\n/** Positive modulo, so negative pitch classes wrap forward. */\nfunction mod(n: number, m: number): number {\n  return ((n % m) + m) % m\n}\n\n/**\n * The twelve pitch classes in ring order for a given sequence.\n *\n * Each ordering is generated by repeatedly stepping a fixed interval from C:\n * 1 semitone for chromatic, 7 for fifths, 2 for whole tones. All three step\n * sizes are coprime with 12, so each visits every pitch class exactly once\n * before returning — which is precisely why these three and not, say, 3 or 4.\n */\nexport function clockSequence(sequence: ClockSequence): number[] {\n  const interval = sequence === \"fifths\" ? 7 : sequence === \"+2\" ? 2 : 1\n  const seq: number[] = []\n  let note = 0\n  for (let i = 0; i < 12; i += 1) {\n    seq.push(note)\n    note = mod(note + interval, 12)\n  }\n  return seq\n}\n\n/**\n * Node positions for the dial.\n *\n * Pure: no DOM, no React. Positions are EQUIDISTANT regardless of `sequence` —\n * the sequence decides which pitch class lands on which node, not where the\n * nodes are — so a chord's shape stays comparable across orderings.\n *\n * `startAtTop` puts ring index 0 at twelve o'clock and winds clockwise; without\n * it, index 0 sits at three o'clock (the legacy origin).\n */\nexport function clockLayout(\n  sequence: ClockSequence = \"chromatic\",\n  size = 220,\n  nodeRadius = 14,\n  startAtTop = true\n): ClockLayout {\n  const cx = size / 2\n  const cy = size / 2\n  const radius = size / 2 - nodeRadius - 2\n  const order = clockSequence(sequence)\n\n  const nodes = order.map((pitchClass, index) => {\n    // Negative sweep so the ring winds clockwise on screen; the quarter turn is\n    // what lifts index 0 from three o'clock to twelve.\n    const theta = -2 * Math.PI * (index / 12) + (startAtTop ? Math.PI / 2 : 0)\n    return {\n      pitchClass,\n      index,\n      x: cx + radius * Math.cos(theta),\n      y: cy - radius * Math.sin(theta),\n    }\n  })\n\n  return { cx, cy, radius, nodes }\n}\n\n/**\n * The sounding nodes in RING order — the chord polygon's vertices.\n *\n * Ring order, not the order the notes were given: a polygon drawn in input\n * order would cross itself and stop being a recognisable shape. Pure.\n */\nexport function chordVertices(\n  layout: ClockLayout,\n  notes: ReadonlyArray<number>\n): ClockNode[] {\n  const lit = new Set((notes ?? []).map((n) => mod(n, 12)))\n  return layout.nodes.filter((node) => lit.has(node.pitchClass))\n}\n\nexport interface PitchClassClockProps {\n  /**\n   * The sounding notes: MIDI note numbers or pitch classes, reduced mod 12 and\n   * deduped, so `[60, 64, 67]` and `[0, 4, 7]` light the same nodes.\n   */\n  notes?: ReadonlyArray<number>\n  /**\n   * The square's side in pixels, or \"fill\" to take the container's box (the\n   * dial scales to its smaller side). Defaults to 220.\n   */\n  size?: number | \"fill\"\n  /** Radius of each note node, in pixels (in SVG units with \"fill\"). */\n  nodeRadius?: number\n  /** Optional visual radius per pitch class; nodeRadius remains the layout and focus radius. */\n  nodeRadiusOf?: (pitchClass: number) => number\n  /** Ring ordering: by semitone, by fifth, or by whole tone. */\n  sequence?: ClockSequence\n  /** How the sounding notes are joined: a filled polygon, an open path, or not at all. */\n  chordShape?: ClockChordShape\n  /** Put ring index 0 at twelve o'clock. False puts it at three o'clock. */\n  startAtTop?: boolean\n  /** Draw the note name inside each node. */\n  showLabels?: boolean\n  /** Highlight and connector colour. Defaults to the value fill (`--uikit-value`). */\n  color?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n  /**\n   * Make the nodes toggles: each becomes a checkbox in one roving Tab stop,\n   * and a click, Enter or Space reports its pitch class here. Without it the\n   * clock is a read-only picture.\n   */\n  onToggle?: (pitchClass: number) => void\n  /** Accessible name of a node when `onToggle` is set. Defaults to \"Pitch class n\". */\n  nodeLabel?: (pitchClass: number) => string\n  /** The text drawn inside a node. Defaults to the note name (\"C♯\"). */\n  labelOf?: (pitchClass: number) => string\n  /** A continuous pitch (MIDI or pitch class, fractional cents allowed) drawn as a tuner needle. */\n  pointer?: number | null\n  /** Needle colour. Defaults to the text colour. */\n  pointerColor?: string\n  /** Pitch classes (or MIDI notes) stimulated now; their halos fade after release. */\n  glowing?: ReadonlyArray<number>\n  /** Halo colour. Defaults to the highlight colour. */\n  glowColor?: string\n  /** How long a released halo takes to fade, in ms. Defaults to 2000. */\n  glowFadeMs?: number\n}\n\n/**\n * The needle tip for a continuous pitch, or null when there is none. The\n * nearest ring node is the anchor and the signed deviation leans toward the\n * neighbouring semitone's node, so every sequence keeps cents readable. Pure.\n */\nexport function clockPointer(\n  layout: ClockLayout,\n  sequence: ClockSequence,\n  pitch: number | null | undefined,\n  inset = 0,\n  startAtTop = true\n): { x: number; y: number; pitchClass: number; cents: number } | null {\n  if (pitch === null || pitch === undefined || !Number.isFinite(pitch))\n    return null\n  const nearest = Math.round(pitch)\n  const cents = Math.round((pitch - nearest) * 100)\n  const pitchClass = mod(nearest, 12)\n  const order = clockSequence(sequence)\n  const index = order.indexOf(pitchClass)\n  const neighbour = order.indexOf(mod(nearest + (cents < 0 ? -1 : 1), 12))\n  // Lean along the shorter way round the ring toward the neighbour's node.\n  const step = mod(neighbour - index + 6, 12) - 6\n  const position = index + (step * Math.abs(cents)) / 100\n  const theta = -2 * Math.PI * (position / 12) + (startAtTop ? Math.PI / 2 : 0)\n  const radius = layout.radius - inset\n  return {\n    x: layout.cx + radius * Math.cos(theta),\n    y: layout.cy - radius * Math.sin(theta),\n    pitchClass,\n    cents,\n  }\n}\n\n/** The default accessible name of an editable node. */\nexport function defaultPitchClassNodeLabel(pitchClass: number): string {\n  return `Pitch class ${pitchClass}`\n}\n\nexport function PitchClassClock({\n  notes = [],\n  size = 220,\n  nodeRadius = 14,\n  nodeRadiusOf,\n  sequence = \"chromatic\",\n  chordShape = \"polygon\",\n  startAtTop = true,\n  showLabels = true,\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n  onToggle,\n  nodeLabel = defaultPitchClassNodeLabel,\n  labelOf,\n  pointer,\n  pointerColor,\n  glowing,\n  glowColor,\n  glowFadeMs = 2000,\n}: PitchClassClockProps) {\n  const fill = size === \"fill\"\n  const side = fill ? CLOCK_FILL_SIZE : size\n  const layout = clockLayout(sequence, side, nodeRadius, startAtTop)\n  const lit = new Set((notes ?? []).map((n) => mod(n, 12)))\n  const accent = color ?? CONSOLE_VALUE\n  // Text on a sounding disc: dark on the default value fill; a caller's own\n  // colour keeps the surface colour, as before.\n  const onAccent = color === undefined ? CONSOLE_ON_VALUE : token.surface\n  const vertices = chordVertices(layout, notes)\n  const glowSet = glowing ? new Set(glowing.map((n) => mod(n, 12))) : null\n  const needle = clockPointer(\n    layout,\n    sequence,\n    pointer,\n    nodeRadius + 4,\n    startAtTop\n  )\n  const points = vertices\n    .map((v) => `${v.x.toFixed(1)},${v.y.toFixed(1)}`)\n    .join(\" \")\n  const nodeRefs = useRef<(SVGGElement | null)[]>([])\n  // The ring index holding the Tab stop once the user has moved it.\n  const [focusIndex, setFocusIndex] = useState<number | null>(null)\n\n  // A connector needs at least two vertices to be a shape rather than a dot.\n  const showConnector = chordShape !== \"none\" && vertices.length > 1\n\n  const label =\n    ariaLabel ??\n    (lit.size > 0\n      ? `Pitch-class clock: ${[...lit]\n          .sort((a, b) => a - b)\n          .map(noteName)\n          .join(\", \")} sounding`\n      : \"Pitch-class clock, no notes sounding\")\n\n  const editable = onToggle !== undefined\n  const tabStop = focusIndex ?? vertices[0]?.index ?? 0\n  const onNodeKeyDown = (\n    event: KeyboardEvent<SVGGElement>,\n    node: ClockNode\n  ) => {\n    const target = clockKeyTarget(node.index, event.key)\n    if (target !== null) {\n      event.preventDefault()\n      setFocusIndex(target)\n      nodeRefs.current[target]?.focus()\n      return\n    }\n    if (event.key !== \"Enter\" && event.key !== \" \") return\n    event.preventDefault()\n    if (!event.repeat) onToggle?.(node.pitchClass)\n  }\n\n  const dial = (\n    <svg\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      width={fill ? \"100%\" : side}\n      height={fill ? \"100%\" : side}\n      viewBox={`0 0 ${side} ${side}`}\n      role={editable ? \"group\" : \"img\"}\n      aria-label={label}\n      data-pitch-class-clock={editable ? \"editable\" : undefined}\n      data-size={fill ? \"fill\" : undefined}\n      style={{\n        display: \"block\",\n        ...(fill ? { maxWidth: \"100%\", maxHeight: \"100%\" } : {}),\n        ...style,\n      }}\n    >\n      {/* The ring the nodes sit on. */}\n      <circle\n        cx={layout.cx}\n        cy={layout.cy}\n        r={layout.radius}\n        fill=\"none\"\n        stroke={token.border}\n        strokeWidth={1}\n      />\n\n      {/* The chord's shape. Drawn under the nodes so the nodes stay readable. */}\n      {showConnector &&\n        (chordShape === \"polygon\" ? (\n          <polygon\n            points={points}\n            fill={accent}\n            fillOpacity={0.14}\n            stroke={accent}\n            strokeWidth={1.5}\n            strokeOpacity={0.7}\n            strokeLinejoin=\"round\"\n          />\n        ) : (\n          <polyline\n            points={points}\n            fill=\"none\"\n            stroke={accent}\n            strokeWidth={1.5}\n            strokeOpacity={0.7}\n            strokeLinejoin=\"round\"\n          />\n        ))}\n\n      {needle && (\n        <g data-pitch-class-pointer={needle.cents} aria-hidden=\"true\">\n          <line\n            x1={layout.cx}\n            y1={layout.cy}\n            x2={needle.x}\n            y2={needle.y}\n            stroke={pointerColor ?? token.text}\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n          />\n          <circle\n            cx={layout.cx}\n            cy={layout.cy}\n            r={3}\n            fill={pointerColor ?? token.text}\n          />\n        </g>\n      )}\n\n      {layout.nodes.map((node) => {\n        const active = lit.has(node.pitchClass)\n        const visualRadius = nodeRadiusOf\n          ? Math.max(1, Math.min(nodeRadius, nodeRadiusOf(node.pitchClass)))\n          : nodeRadius\n        const text = labelOf\n          ? labelOf(node.pitchClass)\n          : noteName(node.pitchClass)\n        const glowOn = glowSet?.has(node.pitchClass) ?? false\n        // Always mounted while `glowing` is set, so the opacity change is a\n        // transition: instant on, eased out over glowFadeMs when released.\n        const halo = glowSet && (\n          <circle\n            r={nodeRadius + 6}\n            fill={glowColor ?? accent}\n            data-pc-glow={glowOn ? \"on\" : \"off\"}\n            style={{\n              opacity: glowOn ? 0.55 : 0,\n              transition: glowOn\n                ? \"opacity 60ms linear\"\n                : `opacity ${glowFadeMs}ms cubic-bezier(0.3, 0.3, 0.6, 1)`,\n              pointerEvents: \"none\",\n            }}\n          />\n        )\n        const disc = (\n          <circle\n            r={visualRadius}\n            fill={active ? accent : token.track}\n            stroke={active ? accent : token.border}\n            strokeWidth={1}\n            data-pc-node-disc={editable ? \"\" : undefined}\n          />\n        )\n        // Full text colour on an unlit disc: the muted colour falls under\n        // 4.5:1 on the track.\n        const caption = showLabels && (\n          <text\n            y={4}\n            textAnchor=\"middle\"\n            fill={active ? onAccent : token.text}\n            fontFamily={token.font}\n            fontSize={11}\n            fontWeight={active ? 600 : 400}\n            style={{ pointerEvents: \"none\" }}\n            aria-hidden={editable ? true : undefined}\n          >\n            {text}\n          </text>\n        )\n        if (!editable) {\n          return (\n            <g key={node.index} transform={`translate(${node.x}, ${node.y})`}>\n              {halo}\n              {disc}\n              {caption}\n            </g>\n          )\n        }\n        return (\n          <g\n            key={node.index}\n            ref={(element) => {\n              nodeRefs.current[node.index] = element\n            }}\n            transform={`translate(${node.x}, ${node.y})`}\n            role=\"checkbox\"\n            aria-checked={active}\n            aria-label={nodeLabel(node.pitchClass)}\n            tabIndex={node.index === tabStop ? 0 : -1}\n            data-pc-node={node.pitchClass}\n            style={{ cursor: \"pointer\" }}\n            onFocus={() => setFocusIndex(node.index)}\n            onClick={() => onToggle(node.pitchClass)}\n            onKeyDown={(event) => onNodeKeyDown(event, node)}\n          >\n            {/* Keep the interaction target steady when a visual node shrinks. */}\n            <circle r={nodeRadius + 3} fill=\"transparent\" />\n            <circle\n              r={nodeRadius + 3}\n              fill=\"none\"\n              stroke={token.focus}\n              strokeWidth={2}\n              data-pc-focus-ring=\"\"\n            />\n            {halo}\n            {disc}\n            {caption}\n          </g>\n        )\n      })}\n    </svg>\n  )\n  // The focus-ring rules are an HTML stylesheet, hoisted beside the dial:\n  // React only hoists a <style> outside SVG.\n  return editable ? (\n    <>\n      <ConsoleStyles />\n      {dial}\n    </>\n  ) : (\n    dial\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/pitch-class-clock.tsx"
    }
  ]
}
