{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-ring-arc",
  "type": "registry:ui",
  "title": "Ring Arc",
  "description": "A chord as a thick band over a ring of notes (a key's seven in line-of-thirds order, so stacked thirds are neighbours), with the chord before it as a ghost band on an outer track and the next on an inner one. Notes read in one colour language: shared notes solid and tied radially to the ghost track, arriving notes in the accent, leaving notes dashed, and the shared count in the centre. Editable: drag the band to move the chord, drag an end knob to widen or narrow it (triad, seventh, ninth), tap a note to toggle it, with keyboard equivalents. An optional outer ring of real note names with semitone ticks turns to transpose.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/ring-arc.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/ring-arc\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 * Ring Arc — a chord as a band over the ring's notes, and the overlap with\n * the chord before it, drawn so it can be read at a glance and edited by hand.\n *\n * The ring's notes sit on a circle (for a key: its seven notes in\n * line-of-thirds order, so a chord of stacked thirds is a run of neighbours).\n * The chord being edited is a thick BAND over its notes; a gap in the chord\n * is a break in the band. The previous chord is a ghost band on an outer\n * track and the next one a thin band on an inner track. Every note reads in\n * one colour language (`RingTone`):\n *\n *   shared    in both chords: solid fill, tied radially to the ghost track\n *   arriving  new in this chord: the accent fill\n *   leaving   in the previous chord only: dashed outline\n *   off       in neither\n *\n * and the count of shared notes is printed in the centre.\n *\n * Editing (when `onChange` is given): drag the band round the circle to move\n * the chord (a ring step per node); drag either end knob to widen or narrow\n * it (three notes, four, five: triad, seventh, ninth); tap a note to toggle\n * it. Keyboard: the band is one tab stop (arrows move it, Shift+arrows move\n * its top end: widen, narrow); the notes are a second (arrows move between\n * them, Space or Enter toggles).\n *\n * Calibration (`outer`): an outer ring of real note names aligned to the\n * notes, with a tick for every semitone inside each step (two ticks inside a\n * minor third, three inside a major), the tonic marked. Turning that ring\n * (drag, or arrows when focused) transposes by semitones; tapping a name\n * asks to make it the tonic.\n *\n * View-only and structural: names, masks (bit i = node i) and the root are\n * the caller's. Colours come from CSS custom properties with fallbacks:\n * `--ring-shared`, `--ring-arriving`, `--ring-leaving`, `--ring-hinge`, and\n * `currentColor` for ink; `ground` knocks out lines under nodes.\n */\n\nimport { useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from \"react\"\nimport {\n  RING_ARC,\n  ringArcHit,\n  ringArcPath,\n  ringArcPoint,\n  ringMaskHas,\n  ringMaskLast,\n  ringMaskResize,\n  ringMaskRuns,\n  ringMaskShared,\n  ringMaskShift,\n  ringMaskSize,\n  ringNodeStep,\n  ringNoteTones,\n  type RingTone,\n} from \"maruaudio/ui/shared/ring-geometry\"\n\nexport interface RingArcNode {\n  /** Text in the node (a ring position \"−3\", a degree, a name). */\n  readonly label: string\n  /** Heavier rim and a ◆ mark: the hinge. */\n  readonly emphasis?: boolean\n  /** Accessible name; defaults to `label`. */\n  readonly ariaLabel?: string\n}\n\nexport interface RingArcChord {\n  /** Bit i = node i. */\n  readonly mask: number\n  /** The chord's root node (where its band starts). */\n  readonly root: number\n}\n\nexport interface RingArcOuter {\n  /** A real note name per node. */\n  readonly labels: readonly string[]\n  /** Semitones from node i to node i + 1: a tick for each semitone between. */\n  readonly intervals?: readonly number[]\n  /** The tonic's node, marked. */\n  readonly tonic?: number | null\n  /** Turn the ring: transpose by `semitones` (+1 clockwise / up). */\n  readonly onShift?: (semitones: number) => void\n  /** A name was tapped: make that note the tonic. */\n  readonly onPick?: (index: number) => void\n  readonly ariaLabel?: string\n  /** Spoken value of the ring (\"C major\"). */\n  readonly valueText?: string\n}\n\nexport interface RingArcProps {\n  readonly nodes: readonly RingArcNode[]\n  /** The chord shown and edited; null shows the ring alone. */\n  readonly chord: RingArcChord | null\n  /** The chord before it: ghost band on the outer track, ties to shared notes. */\n  readonly previous?: RingArcChord | null\n  /** The chord after it: thin band on the inner track. */\n  readonly next?: RingArcChord | null\n  /** Node drawn at twelve o'clock. */\n  readonly top?: number\n  /** Centre text; defaults to the shared count with the previous chord. */\n  readonly centre?: { readonly value: string; readonly label: string } | null\n  /** Editing: the chord's new mask (drag, resize, toggle, keys). */\n  readonly onChange?: (mask: number) => void\n  readonly outer?: RingArcOuter\n  readonly ground?: string\n  readonly className?: string\n  /** The figure fills its container's width; cap it here or by class. */\n  readonly style?: CSSProperties\n  readonly ariaLabel?: string\n}\n\ntype Drag =\n  | { readonly kind: \"move\"; readonly from: number; readonly mask: number; readonly node: number | null; moved: boolean; readonly pointer: number }\n  | { readonly kind: \"resize\"; readonly end: \"root\" | \"top\"; readonly mask: number; readonly root: number; readonly pointer: number }\n  | { readonly kind: \"toggle\"; readonly node: number; readonly pointer: number }\n  | { readonly kind: \"outer\"; readonly angle: number; turned: number; readonly pointer: number }\n\nconst SHARED = \"var(--ring-shared, currentColor)\"\nconst ARRIVING = \"var(--ring-arriving, #e0a526)\"\nconst LEAVING = \"var(--ring-leaving, currentColor)\"\nconst HINGE = \"var(--ring-hinge, currentColor)\"\nconst STEP_ANGLE = (2 * Math.PI) / 12\n\nfunction nodeFill(tone: RingTone, ground: string): string {\n  if (tone === \"shared\") return SHARED\n  if (tone === \"arriving\") return ARRIVING\n  return ground\n}\n\nexport function RingArc({\n  nodes,\n  chord,\n  previous = null,\n  next = null,\n  top = 0,\n  centre,\n  onChange,\n  outer,\n  ground = \"var(--background, #fff)\",\n  className,\n  style,\n  ariaLabel = \"A chord on the ring of notes\",\n}: RingArcProps) {\n  const svg = useRef<SVGSVGElement | null>(null)\n  const drag = useRef<Drag | null>(null)\n  const [focusNode, setFocusNode] = useState(0)\n  const [focused, setFocused] = useState<\"band\" | \"notes\" | \"outer\" | null>(null)\n  const [turn, setTurn] = useState(0)\n  const n = nodes.length\n  if (n === 0) return null\n  const { size, centre: c, notes: R, node: r } = RING_ARC\n  const mask = chord?.mask ?? 0\n  const root = chord?.root ?? 0\n  const last = ringMaskLast(mask, root, n)\n  const tones = ringNoteTones(mask, previous ? previous.mask : null, n)\n  const shared = previous ? ringMaskShared(mask, previous.mask, n) : null\n  const edit = onChange !== undefined\n  const at = (i: number, radius: number) => ringArcPoint(i, n, top, radius)\n\n  const point = (e: PointerEvent<SVGSVGElement>) => {\n    const box = svg.current!.getBoundingClientRect()\n    return ringArcHit(((e.clientX - box.left) / box.width) * size, ((e.clientY - box.top) / box.height) * size, n, top)\n  }\n  const change = (next: number) => {\n    if (next !== mask) onChange?.(next)\n  }\n\n  const onPointerDown = (e: PointerEvent<SVGSVGElement>) => {\n    const part = (e.target as Element).closest(\"[data-part]\")?.getAttribute(\"data-part\")\n    if (!part) return\n    const hit = point(e)\n    if (part === \"outer\" && outer) {\n      drag.current = { kind: \"outer\", angle: hit.angle, turned: 0, pointer: e.pointerId }\n    } else if (!edit) {\n      return\n    } else if (part === \"handle-root\" || part === \"handle-top\") {\n      drag.current = { kind: \"resize\", end: part === \"handle-root\" ? \"root\" : \"top\", mask, root, pointer: e.pointerId }\n    } else if (part === \"band\") {\n      drag.current = { kind: \"move\", from: hit.index, mask, node: null, moved: false, pointer: e.pointerId }\n    } else if (part.startsWith(\"node-\")) {\n      const index = Number(part.slice(5))\n      drag.current = ringMaskHas(mask, index)\n        ? { kind: \"move\", from: index, mask, node: index, moved: false, pointer: e.pointerId }\n        : { kind: \"toggle\", node: index, pointer: e.pointerId }\n    } else return\n    svg.current?.setPointerCapture?.(e.pointerId)\n    e.preventDefault()\n  }\n\n  const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {\n    const d = drag.current\n    if (!d || d.pointer !== e.pointerId) return\n    const hit = point(e)\n    if (d.kind === \"move\") {\n      const step = ringNodeStep(d.from, hit.index, n)\n      if (step !== 0) d.moved = true\n      if (d.moved) change(ringMaskShift(d.mask, step, n))\n    } else if (d.kind === \"resize\") {\n      change(ringMaskResize(d.mask, d.root, d.end, hit.index, n))\n    } else if (d.kind === \"outer\") {\n      let delta = hit.angle - d.angle\n      if (delta > Math.PI) delta -= 2 * Math.PI\n      if (delta < -Math.PI) delta += 2 * Math.PI\n      const semitones = Math.round(delta / STEP_ANGLE)\n      if (semitones !== d.turned && outer?.onShift) {\n        outer.onShift(semitones - d.turned)\n        d.turned = semitones\n      }\n      setTurn(((delta - semitones * STEP_ANGLE) * 180) / Math.PI)\n    }\n  }\n\n  const onPointerUp = (e: PointerEvent<SVGSVGElement>) => {\n    const d = drag.current\n    if (!d || d.pointer !== e.pointerId) return\n    drag.current = null\n    setTurn(0)\n    if (d.kind === \"outer\" && d.turned === 0 && Math.abs(turn) < 8) outer?.onPick?.(point(e).index)\n    else if (d.kind === \"toggle\") change(mask ^ (1 << d.node))\n    else if (d.kind === \"move\" && !d.moved && d.node !== null) change(d.mask ^ (1 << d.node))\n  }\n\n  const bandKeys = (e: KeyboardEvent<SVGGElement>) => {\n    const dir = e.key === \"ArrowRight\" || e.key === \"ArrowUp\" ? 1 : e.key === \"ArrowLeft\" || e.key === \"ArrowDown\" ? -1 : 0\n    if (!dir || !chord) return\n    e.preventDefault()\n    if (e.shiftKey) {\n      const size = ringMaskSize(mask, n)\n      if (dir > 0 && size < n) change(ringMaskResize(mask, root, \"top\", (last + 1) % n, n))\n      if (dir < 0 && size > 1) change(ringMaskResize(mask, root, \"top\", (last - 1 + n) % n, n))\n    } else change(ringMaskShift(mask, dir, n))\n  }\n  const noteKeys = (e: KeyboardEvent<SVGGElement>) => {\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") setFocusNode((focusNode + 1) % n)\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") setFocusNode((focusNode - 1 + n) % n)\n    else if (e.key === \" \" || e.key === \"Enter\") change(mask ^ (1 << focusNode))\n    else return\n    e.preventDefault()\n  }\n  const outerKeys = (e: KeyboardEvent<SVGGElement>) => {\n    const dir = e.key === \"ArrowRight\" || e.key === \"ArrowUp\" ? 1 : e.key === \"ArrowLeft\" || e.key === \"ArrowDown\" ? -1 : 0\n    if (!dir) return\n    e.preventDefault()\n    outer?.onShift?.(dir)\n  }\n\n  const runs = ringMaskRuns(mask, n)\n  const before = previous ? ringMaskRuns(previous.mask, n) : []\n  const after = next ? ringMaskRuns(next.mask, n) : []\n  const handleAt = (i: number, side: -1 | 1) => at(i + side * 0.36, R)\n  const centreText = centre === undefined ? (shared === null ? null : { value: String(shared), label: shared === 1 ? \"note kept\" : \"notes kept\" }) : centre\n  const focusRing = (x: number, y: number, radius: number, key: string) => (\n    <circle key={key} cx={x} cy={y} r={radius} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeDasharray=\"3 2\" />\n  )\n  const chordNames = nodes.filter((_, i) => ringMaskHas(mask, i)).map((node) => node.ariaLabel ?? node.label).join(\", \")\n\n  return (\n    <svg\n      ref={svg}\n      viewBox={`0 0 ${size} ${size}`}\n      className={className}\n      role=\"group\"\n      aria-label={ariaLabel}\n      style={{ display: \"block\", width: \"100%\", touchAction: \"none\", userSelect: \"none\", ...style }}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      onPointerCancel={onPointerUp}\n    >\n      {/* Outer calibration ring: real note names, semitone ticks, the tonic. */}\n      {outer && (\n        <g\n          data-part=\"outer\"\n          role=\"slider\"\n          tabIndex={0}\n          aria-label={outer.ariaLabel ?? \"Key: turn to transpose\"}\n          aria-valuetext={outer.valueText}\n          aria-valuemin={0}\n          aria-valuemax={11}\n          aria-valuenow={outer.tonic ?? 0}\n          onKeyDown={outerKeys}\n          onFocus={() => setFocused(\"outer\")}\n          onBlur={() => setFocused(null)}\n          style={{ cursor: outer.onShift ? \"grab\" : \"pointer\", outline: \"none\" }}\n          transform={turn ? `rotate(${turn} ${c} ${c})` : undefined}\n        >\n          <circle cx={c} cy={c} r={RING_ARC.ticks + 6} fill=\"none\" stroke=\"currentColor\" strokeWidth={22} opacity={0.06} />\n          {focused === \"outer\" && <circle cx={c} cy={c} r={RING_ARC.labels + 11} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeDasharray=\"3 2\" />}\n          {outer.intervals?.map((semitones, i) =>\n            Array.from({ length: Math.max(0, semitones - 1) }, (_, k) => {\n              const t = i + (k + 1) / semitones\n              const a = at(t, RING_ARC.ticks - 4)\n              const b = at(t, RING_ARC.ticks + 4)\n              return <line key={`t${i}-${k}`} x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke=\"currentColor\" strokeWidth={1} opacity={0.45} />\n            })\n          )}\n          {outer.labels.map((label, i) => {\n            const p = at(i, RING_ARC.labels)\n            const tonic = outer.tonic === i\n            return (\n              <g key={`o${i}`}>\n                {tonic && <circle cx={p.x} cy={p.y} r={11} fill=\"none\" stroke={ARRIVING} strokeWidth={1.6} />}\n                <text x={p.x} y={p.y + 4} textAnchor=\"middle\" fontSize={11.5} fontWeight={tonic ? 700 : 500} fill=\"currentColor\">{label}</text>\n              </g>\n            )\n          })}\n        </g>\n      )}\n\n      {/* The ring of notes. */}\n      <circle cx={c} cy={c} r={R} fill=\"none\" stroke=\"currentColor\" strokeWidth={1} opacity={0.22} />\n\n      {/* Previous chord: ghost band on the outer track, ties to the notes it shares. */}\n      {before.map((run, k) => (\n        <path key={`p${k}`} d={ringArcPath(run.start, run.length, n, top, RING_ARC.previous)} fill=\"none\" stroke={LEAVING} strokeWidth={RING_ARC.track} strokeLinecap=\"butt\" opacity={0.6} strokeDasharray=\"6 4\" />\n      ))}\n      {previous && tones.map((tone, i) => {\n        if (tone !== \"shared\") return null\n        const a = at(i, RING_ARC.previous)\n        const b = at(i, R + r)\n        return <line key={`tie${i}`} x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={SHARED} strokeWidth={3.5} strokeLinecap=\"round\" />\n      })}\n      {/* Next chord: thin band on the inner track. */}\n      {after.map((run, k) => (\n        <path key={`x${k}`} d={ringArcPath(run.start, run.length, n, top, RING_ARC.next)} fill=\"none\" stroke=\"currentColor\" strokeWidth={4} strokeLinecap=\"round\" opacity={0.28} />\n      ))}\n\n      {/* The chord: a band over its notes, broken at gaps; one tab stop. */}\n      <g\n        role={edit ? \"slider\" : undefined}\n        tabIndex={edit && chord ? 0 : undefined}\n        aria-label={edit ? \"Chord on the ring: arrows move it, Shift+arrows widen or narrow it\" : undefined}\n        aria-valuetext={chordNames || \"rest\"}\n        onKeyDown={edit ? bandKeys : undefined}\n        onFocus={() => setFocused(\"band\")}\n        onBlur={() => setFocused(null)}\n        style={{ outline: \"none\" }}\n      >\n        {runs.map((run, k) => (\n          <path\n            key={`b${k}`}\n            data-part=\"band\"\n            d={ringArcPath(run.start, run.length, n, top, R)}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={RING_ARC.band}\n            strokeLinecap=\"round\"\n            opacity={0.34}\n            style={{ cursor: edit ? \"grab\" : undefined }}\n          />\n        ))}\n        {focused === \"band\" && runs.length > 0 && <path d={ringArcPath(root, ((last - root + n) % n) + 1, n, top, R + RING_ARC.band / 2 + 4)} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeDasharray=\"3 2\" />}\n      </g>\n\n      {/* Notes. */}\n      <g\n        role=\"group\"\n        tabIndex={edit ? 0 : undefined}\n        aria-label={edit ? \"Notes: arrows choose a note, Space toggles it\" : \"Notes\"}\n        onKeyDown={edit ? noteKeys : undefined}\n        onFocus={() => setFocused(\"notes\")}\n        onBlur={() => setFocused(null)}\n        style={{ outline: \"none\" }}\n      >\n        {nodes.map((node, i) => {\n          const p = at(i, R)\n          const tone = tones[i]!\n          const fill = nodeFill(tone, ground)\n          const ink = tone === \"shared\" || tone === \"arriving\" ? ground : \"currentColor\"\n          return (\n            <g key={`n${i}`} data-part={`node-${i}`} style={{ cursor: edit ? \"pointer\" : undefined }} aria-label={`${node.ariaLabel ?? node.label}: ${tone === \"off\" ? \"not in the chord\" : tone}`} role=\"img\">\n              <circle cx={p.x} cy={p.y} r={r} fill={fill} stroke={tone === \"leaving\" ? LEAVING : node.emphasis ? HINGE : \"currentColor\"} strokeWidth={node.emphasis ? 2.6 : tone === \"leaving\" ? 1.8 : 1.2} strokeDasharray={tone === \"leaving\" ? \"3 2\" : undefined} opacity={tone === \"off\" ? 0.75 : 1} />\n              <text x={p.x} y={p.y + 4} textAnchor=\"middle\" fontSize={11} fontWeight={700} fill={ink}>{node.label}</text>\n              {node.emphasis && (() => {\n                const q = at(i, R - r - 8)\n                return <text x={q.x} y={q.y + 3} textAnchor=\"middle\" fontSize={9} fill={HINGE}>◆</text>\n              })()}\n              {focused === \"notes\" && focusNode === i && focusRing(p.x, p.y, r + 4, `f${i}`)}\n            </g>\n          )\n        })}\n      </g>\n\n      {/* End knobs: drag to widen or narrow. */}\n      {edit && chord && runs.length > 0 && (\n        <>\n          {([\"root\", \"top\"] as const).map((end) => {\n            const p = end === \"root\" ? handleAt(root, -1) : handleAt(last, 1)\n            return (\n              <circle\n                key={end}\n                data-part={`handle-${end}`}\n                cx={p.x}\n                cy={p.y}\n                r={RING_ARC.handle}\n                fill={ground}\n                stroke=\"currentColor\"\n                strokeWidth={2}\n                style={{ cursor: \"ew-resize\" }}\n                aria-hidden\n              />\n            )\n          })}\n        </>\n      )}\n\n      {/* Centre: notes kept. */}\n      {centreText && (\n        <g aria-live=\"polite\">\n          <text x={c} y={c + 4} textAnchor=\"middle\" fontSize={34} fontWeight={700} fill={SHARED}>{centreText.value}</text>\n          <text x={c} y={c + 22} textAnchor=\"middle\" fontSize={10} fill=\"currentColor\" opacity={0.75}>{centreText.label}</text>\n        </g>\n      )}\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/ring-arc.tsx"
    }
  ]
}
