{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-eq-display",
  "type": "registry:ui",
  "title": "EQ Display",
  "description": "The magnitude response of a stack of filter bands, computed from RBJ biquad coefficients rather than measured — so the curve matches what the audio graph will actually do, with no analyser needed.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/eq-display.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/eq-display\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 * EQDisplay — the magnitude response of a stack of filter bands.\n *\n * The curve is computed, not measured: each band's biquad coefficients are\n * evaluated with the RBJ cookbook formulas and their responses multiplied\n * together, exactly as cascaded filters behave. That means the drawing matches\n * what the audio graph will actually do, and needs no analyser at all.\n *\n * Read-only by design. The geometry it draws with is not private to it, though:\n * it comes from `lib/response-geometry`, which exposes the axis mappings in both\n * directions. That is what lets a panel lay a drag overlay over this component\n * and have the handles land exactly on the curve — the overlay and the display\n * read the same scale rather than each keeping their own copy of the constants.\n * `eqDisplayScale` below is how an overlay gets that scale: the viewBox numbers\n * used to be literals inside this file and copied by hand into the panel on top\n * of it, which is a drift waiting to happen. Now there is one call.\n *\n * The one interactive-ish thing it does draw is `cursor` — a marker riding the\n * curve at a frequency the caller animates. That is a *reading*, not a control:\n * it takes no input and reports nothing, which is why it belongs on the pure\n * renderer instead of in an overlay component. See the note on the prop.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`.\n */\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport type { CSSProperties, ReactNode } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  SVG_MARKER_DIAMETER_PX,\n  SVG_MARKER_OUTLINE_DIAMETER_PX,\n  svgMarkerPath,\n} from \"maruaudio/ui/shared/svg-markers\"\nimport {\n  createResponseScale,\n  fillPath as closeFillPath,\n  responsePath,\n  sampleFrequencies,\n  bandMagnitude,\n  gainToDecibels,\n  visibleGridFrequencies,\n  type ResponseBand,\n  type ResponseFilterType,\n  type ResponseScale,\n} from \"maruaudio/ui/shared/response-geometry\"\n\n/**\n * The shapes a band can take.\n *\n * Aliased to the geometry module's type rather than re-declared, so the set\n * cannot drift: `bandpass` is available here because it is available there.\n */\nexport type EQBandType = ResponseFilterType\n\nexport interface EQBand extends ResponseBand {\n  /** Per-band curve colour, when `showBands` is on. */\n  color?: string\n}\n\n/**\n * The viewBox this component draws in.\n *\n * Exported because anything layered over an `EQDisplay` — a drag overlay, a\n * bars strip, a cursor of its own — has to use the identical viewBox and\n * `preserveAspectRatio=\"none\"` for a point at x=160 to be the same screen pixel\n * in both. Reading it from here is what stops the two from drifting.\n */\nexport const EQ_DISPLAY_VIEWBOX = {\n  width: 320,\n  height: 120,\n  padding: 8,\n} as const\n\n/** The axis props that decide where a frequency and a gain land. */\nexport interface EQDisplayAxis {\n  minFrequency?: number\n  maxFrequency?: number\n  decibelRange?: number\n}\n\n/**\n * The exact scale an `EQDisplay` with these axis props draws against.\n *\n * One call, used by the component itself and by anything drawing on top of it,\n * so a handle placed with `freqToX`/`dbToY` lands on the curve rather than near\n * it, and reads back with `xToFreq`/`yToDb` to the value the pointer is over.\n */\nexport function eqDisplayScale({\n  minFrequency = 20,\n  maxFrequency = 20000,\n  decibelRange = 18,\n}: EQDisplayAxis = {}) {\n  return createResponseScale({\n    minFrequency,\n    maxFrequency,\n    decibelRange,\n    ...EQ_DISPLAY_VIEWBOX,\n  })\n}\n\n/**\n * A marker riding the curve at one frequency — the modulation cursor.\n *\n * An auto-filter, an auto-wah or a phaser is a static response plus a moving\n * point on it, and the moving point is the whole story: the curve alone looks\n * like a filter that is not doing anything. The caller owns the animation (an\n * LFO phase, an envelope follower's output) and passes a frequency per frame;\n * this only draws where that frequency lands.\n */\nexport interface EQCursor {\n  /** Where on the frequency axis the cursor sits, in Hz. */\n  freq: number\n  /**\n   * How lit the cursor is, 0..1 — a depth, a follower level, an LFO magnitude.\n   * Drives opacity and dot size, so a sweep can fade as well as move. Defaults\n   * to 1, which is a cursor that is simply present.\n   */\n  emphasis?: number\n  /** Override colour. Defaults to the display's accent. */\n  color?: string\n  /** Draw the vertical hairline through the plot. Defaults to true. */\n  line?: boolean\n}\n\nexport interface EQDisplayProps {\n  plotBackground?: ReactNode\n  bands: ReadonlyArray<EQBand>\n  /** Needed for the bilinear transform. Defaults to 48 kHz. */\n  sampleRate?: number\n\n  width?: number | string\n  height?: number | string\n  /** Lowest frequency on the axis. */\n  minFrequency?: number\n  /** Highest frequency on the axis. */\n  maxFrequency?: number\n  /** Vertical range, in dB either side of flat. */\n  decibelRange?: number\n\n  /** Draw each band's own response behind the summed curve. */\n  showBands?: boolean\n  /** Draw a dot at each band's centre frequency. */\n  showHandles?: boolean\n  showGrid?: boolean\n  /** Numeric axes, with fixed CSS-pixel gutters; off for compact displays. */\n  showAxisLabels?: boolean\n  /** Optional effective runtime ceiling for band centers. */\n  frequencyLimit?: number\n  /** Fill the area between the curve and the 0 dB line. */\n  fill?: boolean\n\n  /**\n   * A marker riding the curve at one frequency, for modulated filters.\n   *\n   * Kept as a prop rather than a sibling overlay component on purpose: the\n   * cursor's only requirement is the axis, and the axis is this component's.\n   * An overlay would have to rebuild the scale from the same props, match the\n   * viewBox and `preserveAspectRatio`, and win a z-order argument — three ways\n   * to disagree with the curve in exchange for nothing, since a cursor takes no\n   * input and so has no interaction to keep out of a pure renderer. Passing a\n   * number is the whole API.\n   */\n  cursor?: EQCursor | null\n\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function EQDisplay({\n  bands,\n  plotBackground,\n  sampleRate = 48000,\n  width = \"100%\",\n  height = 160,\n  minFrequency = 20,\n  maxFrequency = 20000,\n  decibelRange = 18,\n  showBands = false,\n  showHandles = true,\n  showGrid = true,\n  showAxisLabels = false,\n  frequencyLimit,\n  fill = true,\n  cursor,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: EQDisplayProps) {\n  const rawId = useId()\n  const gradientId = `uikit-eq-${rawId.replace(/[^a-zA-Z0-9]/g, \"\")}`\n\n  // The axis this plot is drawn against. Everything below — the curve, the\n  // grid, the handles — reads its geometry from here, so there is exactly one\n  // definition of where a given frequency and gain land.\n  const scale = useMemo(\n    () => eqDisplayScale({ minFrequency, maxFrequency, decibelRange }),\n    [minFrequency, maxFrequency, decibelRange]\n  )\n\n  const active = useMemo(\n    () =>\n      bands\n        .filter((band) => !band.bypassed)\n        .map((band) =>\n          frequencyLimit === undefined\n            ? band\n            : { ...band, freq: Math.min(band.freq, frequencyLimit) }\n        ),\n    [bands, frequencyLimit]\n  )\n\n  // Sample the response at one point per horizontal pixel of the viewBox. The\n  // per-band and summed curves are built in one pass because the summed one is\n  // the product of the others, so computing them separately would evaluate\n  // every biquad twice.\n  const { summedPath, bandPaths } = useMemo(() => {\n    const frequencies = sampleFrequencies(scale)\n    const summed = frequencies.map(() => 1)\n    const perBand: string[] = []\n\n    for (const band of active) {\n      const decibels: number[] = []\n      for (let i = 0; i < frequencies.length; i += 1) {\n        const magnitude = bandMagnitude(band, frequencies[i], sampleRate)\n        summed[i] *= magnitude\n        decibels.push(gainToDecibels(magnitude))\n      }\n      perBand.push(responsePath(decibels, scale))\n    }\n\n    return {\n      // Wrapped rather than passed by reference: `map` supplies the index as a\n      // second argument, which gainToDecibels would read as its floor.\n      summedPath: responsePath(\n        summed.map((magnitude) => gainToDecibels(magnitude)),\n        scale\n      ),\n      bandPaths: perBand,\n    }\n  }, [active, sampleRate, scale])\n\n  // Where the cursor sits: the cascaded response evaluated at that one\n  // frequency, so the marker rides the line rather than floating near it. One\n  // extra biquad evaluation per band per frame, against the ~300 the curve\n  // itself already does — cheap enough to animate at frame rate.\n  const cursorPoint = useMemo(() => {\n    if (!cursor) return null\n    let magnitude = 1\n    for (const band of active)\n      magnitude *= bandMagnitude(band, cursor.freq, sampleRate)\n    return {\n      x: scale.freqToX(cursor.freq),\n      y: scale.dbToY(gainToDecibels(magnitude)),\n    }\n  }, [cursor, active, sampleRate, scale])\n\n  const { zeroY, padding: pad, innerWidth: innerW, innerHeight: innerH } = scale\n  const { freqToX, dbToY } = scale\n  const areaPath = closeFillPath(summedPath, scale)\n  const cursorMarkerPath = cursorPoint\n    ? svgMarkerPath(cursorPoint.x, cursorPoint.y)\n    : null\n  const cursorMarkerDiameter = cursor\n    ? 4.4 + 3.6 * Math.max(0, Math.min(1, cursor.emphasis ?? 1))\n    : 0\n\n  return (\n    <div\n      className={className}\n      style={{\n        width,\n        position: \"relative\",\n        fontFamily: token.font,\n        ...(showAxisLabels\n          ? {\n              padding: `${EQ_AXIS_GUTTERS.top}px ${EQ_AXIS_GUTTERS.right}px ${EQ_AXIS_GUTTERS.bottom}px ${EQ_AXIS_GUTTERS.left}px`,\n            }\n          : {}),\n        ...style,\n      }}\n    >\n      <div style={{ position: \"relative\" }}>\n        <svg\n          viewBox={`0 0 ${scale.width} ${scale.height}`}\n          width=\"100%\"\n          height={height}\n          preserveAspectRatio=\"none\"\n          role=\"img\"\n          aria-label={ariaLabel ?? \"EQ response curve\"}\n          className=\"uikit-control\"\n          style={{\n            display: \"block\",\n            background: token.surface,\n            borderRadius: token.radius,\n            border: `1px solid ${token.border}`,\n          }}\n        >\n          <defs>\n            <linearGradient id={gradientId} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n              <stop offset=\"0%\" stopColor={token.accent} stopOpacity=\"0.35\" />\n              <stop offset=\"100%\" stopColor={token.accent} stopOpacity=\"0.02\" />\n            </linearGradient>\n          </defs>\n\n          {plotBackground}\n\n          {showGrid && (\n            <g stroke={token.border} strokeWidth={0.4} opacity={0.45}>\n              {visibleGridFrequencies(scale).map((f) => (\n                <line\n                  key={f}\n                  x1={freqToX(f)}\n                  y1={pad}\n                  x2={freqToX(f)}\n                  y2={pad + innerH}\n                />\n              ))}\n              {(showAxisLabels ? [-decibelRange * 2 / 3, -decibelRange / 3, decibelRange / 3, decibelRange * 2 / 3] : [-decibelRange / 2, decibelRange / 2]).map((db) => (\n                <line\n                  key={db}\n                  x1={pad}\n                  y1={dbToY(db)}\n                  x2={pad + innerW}\n                  y2={dbToY(db)}\n                  strokeDasharray={showAxisLabels ? undefined : \"2 3\"}\n                />\n              ))}\n            </g>\n          )}\n\n          {/* 0 dB reference */}\n          <line\n            x1={pad}\n            y1={zeroY}\n            x2={pad + innerW}\n            y2={zeroY}\n            stroke={token.border}\n            strokeWidth={0.7}\n          />\n\n          {showBands &&\n            bandPaths.map((path, index) => (\n              <path\n                key={index}\n                d={path}\n                data-eq-curve=\"band\"\n                fill=\"none\"\n                stroke={active[index]?.color ?? token.accent2}\n                strokeWidth={0.8}\n                opacity={showAxisLabels ? 0.85 : 0.45}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            ))}\n\n          {fill && <path d={areaPath} fill={`url(#${gradientId})`} />}\n\n          <path\n            d={summedPath}\n            data-eq-curve=\"sum\"\n            fill=\"none\"\n            stroke={token.accent}\n            strokeWidth={1.8}\n            strokeLinejoin=\"round\"\n            vectorEffect=\"non-scaling-stroke\"\n          />\n\n          {cursor && cursorPoint && cursorMarkerPath && (\n            <g\n              opacity={Math.max(0, Math.min(1, cursor.emphasis ?? 1))}\n              pointerEvents=\"none\"\n              aria-hidden\n            >\n              {cursor.line !== false && (\n                <line\n                  x1={cursorPoint.x}\n                  y1={pad}\n                  x2={cursorPoint.x}\n                  y2={pad + innerH}\n                  stroke={cursor.color ?? token.accent2}\n                  strokeWidth={0.8}\n                  opacity={0.6}\n                  vectorEffect=\"non-scaling-stroke\"\n                />\n              )}\n              {/* Emphasis still changes marker size, but in screen pixels so\n                frequency and gain stretching cannot turn it into an oval. */}\n              <path\n                d={cursorMarkerPath}\n                fill=\"none\"\n                stroke={token.surface}\n                strokeWidth={cursorMarkerDiameter + 2}\n                strokeLinecap=\"round\"\n                vectorEffect=\"non-scaling-stroke\"\n              />\n              <path\n                d={cursorMarkerPath}\n                fill=\"none\"\n                stroke={cursor.color ?? token.accent2}\n                strokeWidth={cursorMarkerDiameter}\n                strokeLinecap=\"round\"\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            </g>\n          )}\n\n          {showHandles &&\n            active.map((band, index) => {\n              const marker = svgMarkerPath(\n                freqToX(band.freq),\n                dbToY(band.gain ?? 0)\n              )\n              return (\n                <g key={index}>\n                  <path\n                    d={marker}\n                    fill=\"none\"\n                    stroke={band.color ?? token.accent}\n                    strokeWidth={SVG_MARKER_OUTLINE_DIAMETER_PX}\n                    strokeLinecap=\"round\"\n                    vectorEffect=\"non-scaling-stroke\"\n                  />\n                  <path\n                    d={marker}\n                    fill=\"none\"\n                    stroke={band.color ?? token.cap}\n                    strokeWidth={SVG_MARKER_DIAMETER_PX}\n                    strokeLinecap=\"round\"\n                    vectorEffect=\"non-scaling-stroke\"\n                  />\n                </g>\n              )\n            })}\n        </svg>\n        {showAxisLabels && <EQAxisLabels scale={scale} />}\n      </div>\n    </div>\n  )\n}\n\n/** Shared physical gutters: editor hit geometry stays aligned with the plot. */\nexport const EQ_AXIS_GUTTERS = {\n  top: 12,\n  right: 16,\n  bottom: 28,\n  left: 48,\n} as const\n\n/** HTML labels retain readable glyphs while the SVG plot changes aspect ratio. */\nfunction EQAxisLabels({ scale }: { scale: ResponseScale }) {\n  const ref = useRef<HTMLDivElement | null>(null)\n  const [width, setWidth] = useState(800)\n  useEffect(() => {\n    const element = ref.current\n    if (!element) return\n    const measure = () => setWidth(element.getBoundingClientRect().width)\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(element)\n    return () => observer.disconnect()\n  }, [])\n  // The 10k tick crowds the 20k endpoint in a compact physical viewport.\n  const compactTicks = width >= 420 ? [100, 1000, 10000] : [100, 1000]\n  const grid = visibleGridFrequencies(scale).filter(\n    (frequency) => width >= 580 || compactTicks.includes(frequency)\n  )\n  const frequencies = [scale.minFrequency, ...grid, scale.maxFrequency]\n  const decibels = [\n    -scale.decibelRange,\n    -scale.decibelRange * 2 / 3,\n    -scale.decibelRange / 3,\n    0,\n    scale.decibelRange / 3,\n    scale.decibelRange * 2 / 3,\n    scale.decibelRange,\n  ]\n  return (\n    <div\n      ref={ref}\n      aria-hidden\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        pointerEvents: \"none\",\n        color: token.textMuted,\n        fontFamily: token.fontMono,\n        fontSize: \"var(--uikit-eq-axis-font-size, 10px)\",\n      }}\n    >\n      {frequencies.map((frequency, index) => (\n        <span\n          key={frequency}\n          style={{\n            position: \"absolute\",\n            left: `${(scale.freqToX(frequency) / scale.width) * 100}%`,\n            top: \"100%\",\n            marginTop: 7,\n            transform:\n              index === 0\n                ? \"none\"\n                : index === frequencies.length - 1\n                  ? \"translateX(-100%)\"\n                  : \"translateX(-50%)\",\n            whiteSpace: \"nowrap\",\n          }}\n        >\n          {frequency >= 1000 ? `${frequency / 1000}k` : Math.round(frequency)}\n        </span>\n      ))}\n      {decibels.map((db) => (\n        <span\n          key={db}\n          style={{\n            position: \"absolute\",\n            top: `${(scale.dbToY(db) / scale.height) * 100}%`,\n            right: \"100%\",\n            marginRight: 8,\n            transform: \"translateY(-50%)\",\n            whiteSpace: \"nowrap\",\n          }}\n        >\n          {db > 0 ? \"+\" : \"\"}\n          {db} dB\n        </span>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/eq-display.tsx"
    }
  ]
}
