{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-interval-vector-chart",
  "type": "registry:ui",
  "title": "Interval Vector Chart",
  "description": "The harmonic fingerprint of a pitch-class set as six bars — one per interval class, m2 through the tritone. Invariant under transposition and inversion, so it describes what a sonority sounds like rather than how it is spelled: every major triad in every key draws the same shape. Weight to the right is tension, weight in the middle is sweetness. Takes the six counts, or the notes to compute them from.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/interval-vector-chart.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/interval-vector-chart\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 * Interval-Vector Chart — the harmonic fingerprint of a pitch-class set, as six\n * bars.\n *\n * The interval-class vector counts every interval present in a set, folded into\n * the six classes: m2, M2, m3, M3, P4 and the tritone. \"Folded\" because an\n * interval and its inversion connect the same two pitch classes — a fourth up is\n * a fifth down — so there are only six classes, not twelve.\n *\n * What makes it worth drawing rather than printing: the vector is invariant\n * under transposition AND inversion, so it is a genuine fingerprint of a\n * sonority's character rather than of its spelling. Every major triad anywhere\n * gives `[0,0,1,1,1,0]`; so does every minor triad, because they are inversions\n * of each other. Read the SHAPE and you are reading what the chord sounds like\n * independent of key: weight on the right-hand bars is tense (the tritone bar is\n * literally the dissonance), weight in the middle is sweet, a flat profile is\n * the all-interval tetrachord and sounds like nothing in particular.\n *\n * The bars are peak-normalised against the largest count with a 6% floor, so an\n * absent interval class keeps a visible stub — an empty slot and a missing slot\n * should not look the same.\n *\n * Pass `vector` if you already have the six counts, or `notes` to have them\n * computed. Exactly one is needed; `vector` wins if both are given.\n *\n * Ported from codex-music-mono's `packages/ui/src/vue/VectorClassBarchart.vue`.\n * The bar-height formula, the per-class colours and both glyph sets are carried\n * over unchanged. Deliberate deviations:\n *\n *   - Heights come from a pure exported `intervalVectorBars`, so the geometry is\n *     testable in Node and a caller can render its own bars from the same\n *     numbers.\n *   - The `notes` path is new: the Vue component took counts only, which meant\n *     every call site imported `calculateIntervalVector` separately.\n *\n * SSR-safe: pure markup 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\"\nimport { calculateIntervalVector } from \"maruaudio/ui/shared/consonance\"\n\n/** The six interval classes, with the glyphs and descriptions each bar carries. */\nconst INTERVAL_CLASSES = [\n  { glyph: \"♭2\", emoji: \"💩\", title: \"minor 2nd — dissonant, tense\" },\n  { glyph: \"2\", emoji: \"🤨\", title: \"major 2nd — open, unresolved\" },\n  { glyph: \"♭3\", emoji: \"🟦\", title: \"minor 3rd — melancholic\" },\n  { glyph: \"3\", emoji: \"🔺\", title: \"major 3rd — bright\" },\n  { glyph: \"4\", emoji: \"🌈\", title: \"perfect 4th — stable\" },\n  { glyph: \"TT\", emoji: \"😈\", title: \"tritone — edgy, unstable\" },\n] as const\n\n/**\n * Default per-class colours, running consonant → dissonant.\n *\n * Not a gradient: each class gets a colour that suits its character (the tritone\n * purple, the major third a warm yellow), carried over from the original so a\n * reader who knows one chart knows the other.\n */\nexport const INTERVAL_CLASS_COLORS: ReadonlyArray<string> = [\n  \"#bd3415\",\n  \"#9aa0a6\",\n  \"#6899c7\",\n  \"#f3de89\",\n  \"#66eaa7\",\n  \"#9968c7\",\n]\n\n/** How much of the plot a bar keeps even at zero, as a fraction. */\nconst FLOOR = 0.06\n\n/** One interval-class bar. */\nexport interface IntervalVectorBar {\n  /** Interval class, 1..6 — 1 is the minor second, 6 the tritone. */\n  intervalClass: number\n  /** The raw count of that interval class in the set. */\n  count: number\n  /** Bar height in pixels, never below the 6% floor. */\n  barHeight: number\n  /** The glyph shown for this class in the \"musical\" style. */\n  glyph: string\n  /** The glyph shown for this class in the \"emoji\" style. */\n  emoji: string\n  /** Hover description of the interval's character. */\n  title: string\n}\n\n/**\n * The six bar heights for an interval-class vector.\n *\n * Pure: no DOM, no React. Peak-normalised against the largest count, with the\n * 6% floor that keeps an absent class visible — `6% + (count/max) * 94%`,\n * carried over verbatim from the Vue original.\n *\n * The peak is floored at 1, so an all-zero vector produces six floor-height\n * stubs rather than dividing by zero — and, importantly, so a vector whose\n * largest count is 1 does not stretch that single interval to full height. A\n * set containing exactly one tritone and nothing else should not look like it is\n * saturated with tritones.\n *\n * Non-finite and negative counts read as zero. Always returns six bars whatever\n * the input length.\n */\nexport function intervalVectorBars(\n  vector: ReadonlyArray<number>,\n  height: number,\n): IntervalVectorBar[] {\n  const plot = Math.max(0, height)\n\n  // Floored at 1 — see above.\n  let max = 1\n  for (let i = 0; i < 6; i += 1) {\n    const v = vector?.[i]\n    if (typeof v === \"number\" && Number.isFinite(v) && v > max) max = v\n  }\n\n  return Array.from({ length: 6 }, (_, i) => {\n    const raw = vector?.[i]\n    const count =\n      typeof raw === \"number\" && Number.isFinite(raw) && raw > 0 ? raw : 0\n    const meta = INTERVAL_CLASSES[i]\n    return {\n      intervalClass: i + 1,\n      count,\n      barHeight: plot * (FLOOR + (count / max) * (1 - FLOOR)),\n      glyph: meta.glyph,\n      emoji: meta.emoji,\n      title: meta.title,\n    }\n  })\n}\n\nexport interface IntervalVectorChartProps {\n  /**\n   * The six interval-class counts (m2, M2, m3, M3, P4, TT), e.g. from\n   * `calculateIntervalVector`. Takes precedence over `notes`.\n   */\n  vector?: ReadonlyArray<number>\n  /**\n   * MIDI notes (or pitch classes) to compute the vector from, when you do not\n   * already have it. Reduced mod 12 and de-duplicated, so the result is the\n   * set-class fingerprint rather than a doubling-sensitive count.\n   */\n  notes?: ReadonlyArray<number>\n  /** Which glyph set to label the bars with. */\n  glyphStyle?: \"musical\" | \"emoji\"\n  /** Bar colour. A single colour overrides the per-class palette entirely. */\n  color?: string\n  /** Per-class colours, six of them. Ignored when `color` is set. */\n  barColors?: ReadonlyArray<string>\n  /** Height of the bars in pixels, excluding the counts and glyphs. */\n  height?: number\n  /** Show the count above each bar. */\n  showCounts?: boolean\n  /** Caption under the chart. Pass an empty string for none. */\n  label?: string\n  /** Overall width. Defaults to filling the container. */\n  width?: number | string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function IntervalVectorChart({\n  vector,\n  notes,\n  glyphStyle = \"musical\",\n  color,\n  barColors = INTERVAL_CLASS_COLORS,\n  height = 88,\n  showCounts = true,\n  label = \"intervals\",\n  width = \"100%\",\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: IntervalVectorChartProps) {\n  // `vector` wins; otherwise derive it from the notes. De-duplicated so the\n  // fingerprint is the set's, not the voicing's.\n  const counts =\n    vector ??\n    calculateIntervalVector([...new Set((notes ?? []).map((n) => ((n % 12) + 12) % 12))])\n\n  const bars = intervalVectorBars(counts, height)\n\n  return (\n    <div\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      role=\"img\"\n      aria-label={\n        ariaLabel ?? `Interval class vector ${bars.map((b) => b.count).join(\", \")}`\n      }\n      style={{\n        display: \"flex\",\n        flexDirection: \"column\",\n        alignItems: \"center\",\n        gap: 6,\n        width,\n        boxSizing: \"border-box\",\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      <div\n        style={{\n          display: \"flex\",\n          alignItems: \"flex-end\",\n          gap: 5,\n          width: \"100%\",\n          height,\n        }}\n      >\n        {bars.map((bar, i) => (\n          <div\n            key={bar.intervalClass}\n            aria-description={`${bar.title}: ${bar.count}`}\n            style={{\n              flex: 1,\n              minWidth: 0,\n              display: \"flex\",\n              flexDirection: \"column\",\n              alignItems: \"center\",\n              justifyContent: \"flex-end\",\n              height: \"100%\",\n              gap: 3,\n            }}\n          >\n            {showCounts && (\n              <span\n                style={{\n                  fontSize: 11,\n                  lineHeight: 1,\n                  fontVariantNumeric: \"tabular-nums\",\n                  color: bar.count === 0 ? token.textMuted : token.text,\n                }}\n              >\n                {bar.count}\n              </span>\n            )}\n\n            <div\n              style={{\n                position: \"relative\",\n                width: \"100%\",\n                height: bar.barHeight,\n                minHeight: 2,\n                borderRadius: \"3px 3px 0 0\",\n                background: color ?? barColors[i] ?? token.accent,\n              }}\n            >\n              {glyphStyle === \"emoji\" && (\n                <span\n                  style={{\n                    position: \"absolute\",\n                    bottom: 2,\n                    left: 0,\n                    width: \"100%\",\n                    textAlign: \"center\",\n                    fontSize: 13,\n                    lineHeight: 1,\n                  }}\n                >\n                  {bar.emoji}\n                </span>\n              )}\n            </div>\n\n            {glyphStyle !== \"emoji\" && (\n              <span style={{ fontSize: 10, lineHeight: 1, color: token.textMuted }}>\n                {bar.glyph}\n              </span>\n            )}\n          </div>\n        ))}\n      </div>\n\n      {label ? (\n        <div\n          style={{\n            fontSize: 10,\n            letterSpacing: \"0.08em\",\n            textTransform: \"uppercase\",\n            color: token.textMuted,\n          }}\n        >\n          {label}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/interval-vector-chart.tsx"
    }
  ]
}
