{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-chord-graph",
  "type": "registry:ui",
  "title": "Chord Graph",
  "description": "How the chords in a progression connect, as a shared-pitch-class network: an edge between every pair sharing a note, weighted by how many. A chart tells you the order chords arrive in; this tells you why one slides into the next and another lands like a cut — so you can see which substitutions are available before trying them.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/chord-graph.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/chord-graph\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 * Chord Graph — how the chords in a progression connect, drawn as a network of\n * shared notes.\n *\n * A chord chart tells you the order chords arrive in. It does not tell you why\n * one leads to another comfortably and another lands with a jolt. That is\n * largely about SHARED PITCH CLASSES: C and Am hold two notes in common and\n * slide into each other almost imperceptibly, C and F♯ share nothing and feel\n * like a cut. This draws that relation directly — an edge between every pair of\n * chords that shares at least one note, weighted by how many.\n *\n * The result is a map of a progression's internal gravity. A tightly-connected\n * cluster is a set of chords that will interchange freely; an isolated node is a\n * chord that will always sound like an arrival or a departure. Reading it tells\n * you which substitutions are available before you try them.\n *\n * Two layouts, answering different questions. \"ring\" spaces the chords evenly in\n * the order you gave them, so the progression's own sequence is the geometry and\n * edge lengths mean nothing — best for seeing which non-adjacent chords are\n * secretly related. \"fifths\" places each chord at the circle-of-fifths angle of\n * its ROOT (`pcs[0]`), so distance on screen becomes harmonic distance and a\n * diatonic progression collapses into a tight arc; chords sharing a root get\n * pushed to different radii so they stay individually clickable.\n *\n * No Vue original — written for this kit. `chordGraphLayout` is the pure core:\n * nodes, edges and shared-note counts with no DOM, so the graph can be computed\n * headlessly, tested, or fed to a different renderer.\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 type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\n\nconst TWO_PI = Math.PI * 2\n\n/** One chord to place on the graph. */\nexport interface ChordGraphChord {\n  /** What to write on the node, e.g. `\"Am7\"`. */\n  label: string\n  /**\n   * The chord's pitch classes. `pcs[0]` is treated as the ROOT, which is what\n   * the \"fifths\" layout positions by, so pass them root-first. Reduced mod 12.\n   */\n  pcs: ReadonlyArray<number>\n  /** Per-chord colour override. Falls back to the component's `color`. */\n  color?: string\n}\n\n/** Where the chords go. */\nexport type ChordGraphLayoutMode = \"ring\" | \"fifths\"\n\n/** A placed chord. */\nexport interface ChordGraphNode {\n  /** Index into the input array — edges refer to chords by this. */\n  index: number\n  label: string\n  /** The chord's unique pitch classes, sorted. */\n  pcs: number[]\n  /** The root pitch class (`pcs[0]` as given, reduced), or null for an empty chord. */\n  rootPc: number | null\n  color?: string\n  x: number\n  y: number\n}\n\n/** A shared-note relation between two chords. */\nexport interface ChordGraphEdge {\n  /** Index of the earlier chord. */\n  from: number\n  /** Index of the later chord. Always greater than `from` — each pair appears once. */\n  to: number\n  /** How many pitch classes the two share. Always at least 1. */\n  shared: number\n  /** The shared pitch classes themselves, sorted. */\n  sharedPcs: number[]\n  /** `shared` scaled into 0..1 against the strongest edge in the graph — drives stroke weight. */\n  strength: number\n}\n\n/** The computed graph. */\nexport interface ChordGraphData {\n  nodes: ChordGraphNode[]\n  /** Every pair sharing at least one pitch class, `from` < `to`. */\n  edges: ChordGraphEdge[]\n  cx: number\n  cy: number\n  /** The radius nodes are placed at (the outer ring, for the \"fifths\" layout). */\n  radius: number\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/** Node circle radius — also the inset that keeps nodes inside the box. */\nconst NODE_RADIUS = 18\n\n/**\n * Lay out a chord network: positions, and an edge for every pair of chords\n * sharing at least one pitch class.\n *\n * Pure: no DOM, no React. Pitch classes are reduced mod 12 and deduped, so raw\n * MIDI note numbers work and an octave doubling never counts as a second shared\n * note.\n *\n * In \"ring\" the chords are spaced evenly in input order starting at twelve\n * o'clock. In \"fifths\" each sits at the circle-of-fifths angle of its root, and\n * chords sharing a root are pulled inward in turn so they do not stack — the\n * first stays on the rim, the next sits a step in, and so on.\n */\nexport function chordGraphLayout(\n  chords: ReadonlyArray<ChordGraphChord>,\n  size = 280,\n  layout: ChordGraphLayoutMode = \"ring\",\n): ChordGraphData {\n  const cx = size / 2\n  const cy = size / 2\n  const radius = Math.max(0, size / 2 - NODE_RADIUS - 10)\n  const list = chords ?? []\n\n  // How far each successive same-angle chord is pulled in, in the fifths\n  // layout. A third of the radius keeps three collisions legible before they\n  // start crowding the hub.\n  const RADIAL_STEP = radius * 0.28\n\n  // Tracks how many chords have already claimed each fifths slot, so the next\n  // one can be offset inward instead of landing on top of it.\n  const slotUsage = new Map<number, number>()\n\n  const nodes: ChordGraphNode[] = list.map((chord, index) => {\n    const unique = [...new Set((chord.pcs ?? []).map((pc) => mod(pc, 12)))].sort((a, b) => a - b)\n    const rawRoot = chord.pcs?.[0]\n    const rootPc = typeof rawRoot === \"number\" && Number.isFinite(rawRoot) ? mod(rawRoot, 12) : null\n\n    let angle: number\n    let r: number\n    if (layout === \"fifths\" && rootPc !== null) {\n      // Circle-of-fifths angle: multiply by seven to turn a chromatic pitch\n      // class into a fifths slot.\n      const slot = mod(rootPc * 7, 12)\n      angle = (slot / 12) * TWO_PI\n      const used = slotUsage.get(slot) ?? 0\n      slotUsage.set(slot, used + 1)\n      r = Math.max(NODE_RADIUS, radius - used * RADIAL_STEP)\n    } else {\n      // Ring: evenly spaced in the order given.\n      angle = list.length > 0 ? (index / list.length) * TWO_PI : 0\n      r = radius\n    }\n\n    return {\n      index,\n      label: chord.label,\n      pcs: unique,\n      rootPc,\n      color: chord.color,\n      // Screen mapping: sine on x, minus cosine on y — angle 0 at twelve\n      // o'clock, winding clockwise, matching the circle-of-fifths wheel.\n      x: cx + Math.sin(angle) * r,\n      y: cy - Math.cos(angle) * r,\n    }\n  })\n\n  // Every unordered pair, once, keeping only those that actually share a note.\n  const edges: ChordGraphEdge[] = []\n  let maxShared = 0\n  for (let i = 0; i < nodes.length; i += 1) {\n    for (let j = i + 1; j < nodes.length; j += 1) {\n      const a = new Set(nodes[i].pcs)\n      const sharedPcs = nodes[j].pcs.filter((pc) => a.has(pc))\n      if (sharedPcs.length === 0) continue\n      if (sharedPcs.length > maxShared) maxShared = sharedPcs.length\n      edges.push({ from: i, to: j, shared: sharedPcs.length, sharedPcs, strength: 0 })\n    }\n  }\n  // Normalise against the strongest edge present, so the contrast between a\n  // one-note and a three-note link is visible whatever the chords' size.\n  for (const edge of edges) {\n    edge.strength = maxShared > 0 ? edge.shared / maxShared : 0\n  }\n\n  return { nodes, edges, cx, cy, radius }\n}\n\nexport interface ChordGraphProps {\n  /** The chords to plot. Give each one's pitch classes ROOT FIRST. */\n  chords: ReadonlyArray<ChordGraphChord>\n  /** Emphasise this chord and the edges touching it. Null or omitted for none. */\n  activeIndex?: number | null\n  /** Overall square size in pixels. */\n  size?: number\n  /** Even spacing in input order, or placement by root on the circle of fifths. */\n  layout?: ChordGraphLayoutMode\n  /** Default node and edge colour. Individual chords can override it. */\n  color?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function ChordGraph({\n  chords,\n  activeIndex = null,\n  size = 280,\n  layout = \"ring\",\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: ChordGraphProps) {\n  const { nodes, edges, cx, cy } = chordGraphLayout(chords, size, layout)\n  const accent = color ?? token.accent\n  const active = activeIndex ?? null\n\n  return (\n    <svg\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      width={size}\n      height={size}\n      viewBox={`0 0 ${size} ${size}`}\n      role=\"img\"\n      aria-label={\n        ariaLabel\n        ?? `Chord network: ${nodes.length} chords, ${edges.length} shared-note connections`\n      }\n      style={{ display: \"block\", overflow: \"visible\", ...style }}\n    >\n      {/* Edges first, so nodes sit on top of them. */}\n      {edges.map((edge) => {\n        const a = nodes[edge.from]\n        const b = nodes[edge.to]\n        const touchesActive = active !== null && (edge.from === active || edge.to === active)\n        // One shared note is a thin hairline, three is a solid tie.\n        const strokeWidth = 1 + edge.strength * 3.5\n        // When something is selected, its edges stay lit and the rest recede —\n        // that contrast is what makes a busy graph readable.\n        const opacity = active === null ? 0.2 + edge.strength * 0.45 : touchesActive ? 0.5 + edge.strength * 0.45 : 0.08\n        return (\n          <line\n            key={`${edge.from}-${edge.to}`}\n            x1={a.x}\n            y1={a.y}\n            x2={b.x}\n            y2={b.y}\n            stroke={accent}\n            strokeWidth={touchesActive ? strokeWidth + 0.75 : strokeWidth}\n            strokeLinecap=\"round\"\n            opacity={opacity}\n          />\n        )\n      })}\n\n      {nodes.map((node) => {\n        const isActive = active === node.index\n        const fill = node.color ?? accent\n        const r = isActive ? NODE_RADIUS + 3 : NODE_RADIUS\n        return (\n          <g key={node.index} transform={`translate(${node.x}, ${node.y})`}>\n            <circle\n              r={r}\n              fill={fill}\n              fillOpacity={isActive ? 1 : 0.85}\n              stroke={isActive ? token.text : fill}\n              strokeWidth={isActive ? 2 : 1}\n              opacity={active === null || isActive ? 1 : 0.5}\n            />\n            <text\n              y={4}\n              textAnchor=\"middle\"\n              fill={token.surface}\n              fontFamily={token.font}\n              fontSize={isActive ? 12 : 11}\n              fontWeight={600}\n              opacity={active === null || isActive ? 1 : 0.6}\n              style={{ pointerEvents: \"none\" }}\n            >\n              {node.label}\n            </text>\n          </g>\n        )\n      })}\n\n      {/* A faint hub, purely to give the ring a centre to read against. */}\n      <circle cx={cx} cy={cy} r={2} fill={token.textMuted} opacity={0.4} />\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/chord-graph.tsx"
    }
  ]
}
