{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-rhythm-wheel",
  "type": "registry:ui",
  "title": "Rhythm Wheel",
  "description": "A bar as a circle: N steps round a ring, the struck ones lit, joined into the polygon that IS the pattern — a lopsided triangle for the tresillo, a square for four on the floor, the same shape turned when you rotate it. Velocity is node size and brightness both, accent is its own outline ring, group ticks sit outside the ring and the downbeat carries a lit one. The playhead is a prop, fractional for a smooth sweep, because the host owns the clock. Read-only it draws a generated pattern; add onToggle and every node becomes a keyboard-reachable toggle that wraps round the ring the way the bar does.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/rhythm-wheel.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/rhythm-wheel\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 * RhythmWheel — a bar of N steps as a circle, with the struck ones lit and\n * joined into the shape they make.\n *\n * A step sequencer's row is a line, and a line has a beginning and an end. A\n * bar does not: it comes round again. Drawn on a ring, the pattern's shape is\n * the thing you read — the *rhythm polygon*, the figure you get by joining\n * consecutive onsets — and rotation becomes what it actually is, the same\n * shape turned. E(3,8) is a lopsided triangle wherever you start it; four on\n * the floor is a square; the West African bell is a shape you learn to\n * recognise the way you recognise a chord's triangle on `pitch-class-clock`.\n * Same fact about necklaces, same ring convention: step 0 at twelve o'clock,\n * winding clockwise, computed by the identical formula (`lib/rhythm-math`\n * shares it, and its tests pin the two bit-identical).\n *\n * ## Who owns what\n *\n * The HOST owns the clock. `playhead` is a prop — an absolute step position,\n * fractional for a smooth sweep — exactly as `lfo-visualizer` takes phase and\n * `step-grid` takes its cursor. Nothing in here schedules, times or animates\n * anything: no rAF, no interval, no transport. The hand is wherever the audio\n * actually is, not wherever a display timer drifted to, and a wheel with no\n * playhead is a perfectly good static drawing of a pattern.\n *\n * The host owns the pattern too. `pulses` come in, every edit reports out\n * through `onToggle`, and the wheel keeps no copy — the only state here is\n * view state (which node has keyboard focus), which belongs to the widget the\n * way a scroll position does.\n *\n * ## Two readings\n *\n *   - **Read-only** (no `onToggle`) — a drawing. Nodes are not focusable, the\n *     whole SVG is one image with a summary label, and the primary story is a\n *     Euclidean pattern computed by `euclidPattern` and handed straight in.\n *     This is the reading a module's face wants: it shows the rhythm the\n *     generator produced, and there is nothing to edit because the knobs edit\n *     it.\n *   - **Editable** (`onToggle` present) — nodes become toggle buttons. Tap\n *     any step to strike or silence it, with a roving tabindex, Left/Right (or\n *     Up/Down) walking round the ring and WRAPPING — because the ring wraps,\n *     and stopping at step 15 of 16 would be a lie about the shape — Home and\n *     End for the downbeat and the last step, Enter/Space to toggle.\n *\n * ## Encodings, and why two at once\n *\n * A pulse's VELOCITY is its node's radius AND its brightness. One cue is not\n * enough at this size: a node is a dozen pixels across, and a quiet hit drawn\n * only smaller reads as \"further away\" rather than \"softer\" — the same call\n * `step-grid` makes about cell fills and `delay-tap-display` about its pips.\n * ACCENT is a separate channel, an outline ring rather than more of either, so\n * a *quiet accented* hit stays readable as both things at once.\n *\n * The polygon is drawn faintly UNDER the nodes: it is the pattern's shape, not\n * the pattern, and it must never win a fight with the hits it connects. Group\n * ticks every `groupSize` steps sit outside the ring so the beats stay\n * countable without adding ink inside it, and step 0 carries a longer, lit\n * tick because a circle has no left-hand end to mark the downbeat with.\n *\n * ## Deliberately not here\n *\n * Timing, tempo and swing — see `lib/rhythm-math`'s header. Pattern\n * generation is not here either: the wheel draws what it is given, and\n * `euclidPattern` is a function a host calls, not a prop this component\n * secretly evaluates. And there is no drag gesture: a tap acts on POINTER\n * DOWN, like a drum pad and unlike `step-grid`'s cells (which wait for\n * release because a press there might still become a paint stroke). Nothing\n * on a wheel needs to be painted across, so nothing has to wait.\n *\n * Styling comes from CSS custom properties — see `lib/control-theme/theme.ts`\n * — with a `color` prop to override the accent for one instance. SSR-safe:\n * the whole drawing is pure maths from props, and the only DOM access lives\n * in event handlers.\n */\n\nimport { useMemo, useRef, useState } from \"react\"\nimport type {\n  CSSProperties,\n  KeyboardEvent,\n  PointerEvent as ReactPointerEvent,\n} from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  formatIntervals,\n  interOnsetIntervals,\n  playheadStep,\n  pulseMask,\n  rhythmPolygon,\n  rhythmRingLayout,\n  ringRotation,\n  wrapStep,\n  type RhythmPulse,\n} from \"maruaudio/ui/shared/rhythm-math\"\nimport {\n  useFirstInteraction,\n  type InteractionEvent,\n} from \"maruaudio/ui/shared/use-first-interaction\"\n\nexport interface RhythmWheelProps {\n  /** Steps in the bar — the nodes round the ring. */\n  steps: number\n  /**\n   * The struck steps. Anything absent is a silent step. Velocity (0..1) sets\n   * a node's size and brightness; `accent` adds an outline ring.\n   */\n  pulses: readonly RhythmPulse[]\n\n  /**\n   * Where the host's clock is, as an absolute step position. Fractional is\n   * expected — 4.5 is halfway between steps 4 and 5 — and positions past the\n   * end of the bar fold into it, so a free-running counter can be passed\n   * straight through. `null` draws no playhead at all.\n   */\n  playhead?: number | null\n\n  /**\n   * Toggle a step. Present = the wheel is editable (nodes become buttons);\n   * absent = a read-only drawing with a summary label.\n   */\n  onToggle?: (step: number, active: boolean) => void\n\n  /** Draw the inter-onset polygon under the nodes. */\n  showPolygon?: boolean\n  /** Steps per group tick. 0 disables the ticks. */\n  groupSize?: number\n  /**\n   * Print the inter-onset intervals (\"3-3-2\") in the middle. A busy bar's\n   * interval string gets long — sixteen onsets read \"1-1-1-…\" and will outrun\n   * the circle — so a host that lets the pattern get dense should fall back to\n   * {@link RhythmWheelProps.label}, which is exactly what the demo does.\n   */\n  showIntervals?: boolean\n  /** Centre text, overriding the interval readout. */\n  label?: string\n\n  /** Overall square size in pixels. */\n  size?: number\n  /** Radius of a full-velocity node. */\n  nodeRadius?: number\n  /** Override the accent colour for this instance. */\n  color?: string\n  /**\n   * Fired once, on the first gesture of any kind — the place to resume a\n   * suspended AudioContext.\n   */\n  onInteraction?: (event: InteractionEvent) => void\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/**\n * Faintest a struck node is drawn, however low its velocity — the same floor\n * `step-grid` puts on a cell fill and `delay-tap-display` on a pip. A hit must\n * stay legibly a hit; below this it starts reading as an empty step.\n */\nconst MIN_PULSE_OPACITY = 0.35\n\n/** Smallest a struck node is drawn, as a fraction of `nodeRadius`. Velocity\n * rides the rest of the way, so the size range is 0.55..1 rather than 0..1 —\n * a zero-radius circle is not a quiet hit, it is a missing one. */\nconst MIN_PULSE_SCALE = 0.55\n\nexport function RhythmWheel({\n  steps,\n  pulses,\n  playhead = null,\n  onToggle,\n  showPolygon = true,\n  groupSize = 4,\n  showIntervals = false,\n  label,\n  size = 220,\n  nodeRadius = 12,\n  color,\n  onInteraction,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: RhythmWheelProps) {\n  const accent = color ?? token.accent\n  const editable = typeof onToggle === \"function\"\n  const stepCount = Math.max(0, Math.floor(steps))\n\n  const layout = useMemo(\n    () => rhythmRingLayout(stepCount, size, nodeRadius),\n    [stepCount, size, nodeRadius],\n  )\n  const mask = useMemo(() => pulseMask(pulses, stepCount), [pulses, stepCount])\n  const pulseByStep = useMemo(() => {\n    const map = new Map<number, RhythmPulse>()\n    for (const pulse of pulses) map.set(Math.round(pulse.step), pulse)\n    return map\n  }, [pulses])\n\n  const intervals = useMemo(() => interOnsetIntervals(mask), [mask])\n  const polygon = useMemo(\n    () =>\n      showPolygon\n        ? rhythmPolygon(mask, layout.radius, layout.cx, layout.cy)\n        : [],\n    [showPolygon, mask, layout],\n  )\n\n  const { notify } = useFirstInteraction(onInteraction)\n\n  /* ------------------------------------------------------------- keyboard */\n\n  const svgRef = useRef<SVGSVGElement | null>(null)\n  const [focusStep, setFocusStep] = useState(0)\n\n  /** Move the roving focus, and take the DOM focus with it. */\n  const moveFocus = (next: number) => {\n    setFocusStep(next)\n    svgRef.current\n      ?.querySelector<SVGGElement>(`[data-step=\"${next}\"]`)\n      ?.focus()\n  }\n\n  const handleKeyDown = (step: number) => (event: KeyboardEvent) => {\n    if (!editable || stepCount === 0) return\n    notify(event)\n    let handled = true\n    if (event.key === \"Enter\" || event.key === \" \") {\n      onToggle?.(step, !mask[step])\n    } else if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n      // Wrapping, because the ring wraps. Walking off step 15 of 16 lands on\n      // the downbeat, which is where the bar actually goes next.\n      moveFocus((step + 1) % stepCount)\n    } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n      moveFocus((step - 1 + stepCount) % stepCount)\n    } else if (event.key === \"Home\") {\n      moveFocus(0)\n    } else if (event.key === \"End\") {\n      moveFocus(stepCount - 1)\n    } else {\n      handled = false\n    }\n    if (handled) event.preventDefault()\n  }\n\n  const handlePointerDown =\n    (step: number) => (event: ReactPointerEvent<SVGGElement>) => {\n      if (!editable) return\n      notify(event)\n      setFocusStep(step)\n      onToggle?.(step, !mask[step])\n    }\n\n  /* -------------------------------------------------------------- drawing */\n\n  const position = playhead === null ? null : wrapStep(playhead, stepCount)\n  const litStep =\n    position === null || stepCount === 0 ? null : playheadStep(position, stepCount)\n\n  const intervalText = formatIntervals(intervals)\n  const centreText = label ?? (showIntervals ? intervalText : null)\n\n  const summary =\n    stepCount === 0\n      ? \"Rhythm wheel, empty\"\n      : `Rhythm wheel: ${intervals.length} of ${stepCount} steps struck`\n        + (intervals.length > 1 ? `, intervals ${intervalText}` : \"\")\n  const rootLabel = ariaLabel ?? summary\n\n  const points = polygon\n    .map((vertex) => `${vertex.x.toFixed(2)},${vertex.y.toFixed(2)}`)\n    .join(\" \")\n\n  return (\n    <svg\n      ref={svgRef}\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      width={size}\n      height={size}\n      viewBox={`0 0 ${size} ${size}`}\n      role={editable ? \"group\" : \"img\"}\n      aria-label={rootLabel}\n      style={{ display: \"block\", touchAction: \"none\", ...style }}\n    >\n      {/* The ring the steps 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      {/* Step ticks outside the ring: short for a step, longer for a group\n          head, longest and lit for the downbeat — a circle has no left-hand\n          end, so the downbeat has to be marked. */}\n      {layout.nodes.map((node) => {\n        const isDownbeat = node.step === 0\n        const isGroupHead = groupSize > 0 && node.step % groupSize === 0\n        const length = isDownbeat ? 9 : isGroupHead ? 6 : 3\n        const inner = layout.radius + nodeRadius + 1\n        const scaleInner = inner / layout.radius\n        const scaleOuter = (inner + length) / layout.radius\n        const dx = node.x - layout.cx\n        const dy = node.y - layout.cy\n        return (\n          <line\n            key={`tick-${node.step}`}\n            aria-hidden\n            x1={layout.cx + dx * scaleInner}\n            y1={layout.cy + dy * scaleInner}\n            x2={layout.cx + dx * scaleOuter}\n            y2={layout.cy + dy * scaleOuter}\n            stroke={isDownbeat ? accent : token.border}\n            strokeWidth={isDownbeat || isGroupHead ? 1.5 : 1}\n            strokeLinecap=\"round\"\n            opacity={isDownbeat ? 0.9 : isGroupHead ? 0.7 : 0.4}\n          />\n        )\n      })}\n\n      {/* The rhythm polygon, under the nodes so the hits stay readable. */}\n      {polygon.length > 1 && (\n        <polygon\n          aria-hidden\n          points={points}\n          fill={accent}\n          fillOpacity={0.1}\n          stroke={accent}\n          strokeWidth={1.25}\n          strokeOpacity={0.45}\n          strokeLinejoin=\"round\"\n        />\n      )}\n\n      {/* The playhead: a hand from the centre, rotated by the host's position.\n          `ringRotation` is degrees clockwise from twelve o'clock, and its\n          agreement with the node placement is pinned in the lib's tests. */}\n      {position !== null && stepCount > 0 && (\n        <g\n          aria-hidden\n          transform={`rotate(${ringRotation(position, stepCount).toFixed(3)} ${layout.cx} ${layout.cy})`}\n        >\n          <line\n            x1={layout.cx}\n            y1={layout.cy}\n            x2={layout.cx}\n            y2={layout.cy - layout.radius}\n            stroke={accent}\n            strokeWidth={1.5}\n            strokeOpacity={0.75}\n            strokeLinecap=\"round\"\n          />\n          <circle cx={layout.cx} cy={layout.cy} r={2.5} fill={accent} />\n        </g>\n      )}\n\n      {/* The steps. */}\n      {layout.nodes.map((node) => {\n        const pulse = pulseByStep.get(node.step)\n        const active = mask[node.step] === true\n        const velocity = Math.min(1, Math.max(0, pulse?.velocity ?? 1))\n        // Velocity is size AND brightness — see the header.\n        const radius = active\n          ? nodeRadius * (MIN_PULSE_SCALE + (1 - MIN_PULSE_SCALE) * velocity)\n          : nodeRadius * 0.45\n        const onPlayhead = litStep === node.step\n        const opacity = active\n          ? Math.min(1, MIN_PULSE_OPACITY + (1 - MIN_PULSE_OPACITY) * velocity)\n          : 1\n\n        const body = (\n          <>\n            {/* The playhead's node, lit under the step itself. */}\n            {onPlayhead && (\n              <circle\n                cx={node.x}\n                cy={node.y}\n                r={nodeRadius + 3}\n                fill={accent}\n                fillOpacity={0.18}\n                stroke={accent}\n                strokeWidth={1}\n                strokeOpacity={0.6}\n              />\n            )}\n            <circle\n              cx={node.x}\n              cy={node.y}\n              r={radius}\n              fill={active ? accent : token.track}\n              fillOpacity={opacity}\n              stroke={active ? accent : token.border}\n              strokeWidth={1}\n              strokeOpacity={active ? opacity : 1}\n            />\n            {/* Accent is its own channel, so a quiet accented hit still reads\n                as both quiet and accented. */}\n            {active && pulse?.accent && (\n              <circle\n                cx={node.x}\n                cy={node.y}\n                r={radius + 3}\n                fill=\"none\"\n                stroke={token.accent2}\n                strokeWidth={1.25}\n                strokeOpacity={0.9}\n              />\n            )}\n          </>\n        )\n\n        if (!editable) {\n          return (\n            <g key={node.step} aria-hidden>\n              {body}\n            </g>\n          )\n        }\n\n        return (\n          <g\n            key={node.step}\n            data-step={node.step}\n            role=\"button\"\n            aria-pressed={active}\n            aria-label={\n              `Step ${node.step + 1} of ${stepCount}`\n              + (active ? `, on, velocity ${Math.round(velocity * 100)}%` : \", off\")\n              + (pulse?.accent ? \", accented\" : \"\")\n            }\n            tabIndex={focusStep === node.step ? 0 : -1}\n            onPointerDown={handlePointerDown(node.step)}\n            onKeyDown={handleKeyDown(node.step)}\n            onFocus={() => setFocusStep(node.step)}\n            style={{ cursor: \"pointer\", outline: \"none\" }}\n          >\n            {/* An invisible hit area, so a quiet (small) node is no harder to\n                hit than a loud one. */}\n            <circle\n              cx={node.x}\n              cy={node.y}\n              r={Math.max(nodeRadius + 4, 12)}\n              fill=\"transparent\"\n            />\n            {body}\n          </g>\n        )\n      })}\n\n      {/* Centre readout: the interval sequence a drummer would say out loud. */}\n      {centreText && (\n        <text\n          x={layout.cx}\n          y={layout.cy + 4}\n          aria-hidden\n          textAnchor=\"middle\"\n          fill={token.textMuted}\n          fontFamily={token.fontMono}\n          fontSize={Math.max(9, Math.round(size * 0.055))}\n          style={{ pointerEvents: \"none\" }}\n        >\n          {centreText}\n        </text>\n      )}\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/rhythm-wheel.tsx"
    }
  ]
}
