{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-lissajous-scope",
  "type": "registry:ui",
  "title": "Lissajous Scope",
  "description": "A circular vectorscope trace: one mono signal plotted against a phase-shifted copy of itself, so a single channel draws circles and rosettes. Phosphor persistence via a per-frame fade, and the geometry is a pure exported function you can test or render headlessly.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/lissajous-scope.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/lissajous-scope\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 * Lissajous Scope — a circular (vectorscope-style) trace from one mono signal.\n *\n * Where the linear `Oscilloscope` plots amplitude against time, this plots the\n * buffer against a **phase-shifted copy of itself**: X from `data[i]`, Y from\n * `data[i + offset]`. One signal, self-referenced — which is why it draws\n * circles and rosettes without needing a stereo pair. `phaseShift` is the\n * parameter that defines the figure: a quarter-period offset on a sine turns\n * the degenerate diagonal line into a circle, and other offsets sweep through\n * the ellipses and loops in between.\n *\n * Instead of clearing each frame it paints a translucent wash over the previous\n * one, so old trails decay rather than vanish — the phosphor persistence of a\n * real CRT scope. `fadeSpeed` is that wash's alpha: lower means longer trails.\n *\n * The geometry lives in `lissajousPoints`, a pure function with no canvas,\n * React or DOM in it — so the figure can be unit-tested in Node, or fed to a\n * WebGL or offline renderer without dragging this component along.\n *\n * A module face can also draw a figure DERIVED FROM PARAMETER VALUES instead\n * of live audio (a face may not create an AudioContext): pass `figure`, a\n * function from t in [0, 1] to a normalised point, or `points`, an already\n * sampled list. The same trace, glow, trail ramp and head dot render it; the\n * frame repaints when the figure changes rather than in an animation loop.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`.\n */\n\nimport { useCallback, useMemo, useRef } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport {\n  canvasColor,\n  token,\n} from \"maruaudio/ui/shared/theme\"\nimport {\n  useAudioCanvas,\n  type AudioSourceProps,\n} from \"maruaudio/ui/shared/use-audio-source\"\n\n/** A point of the figure in normalised space: both axes run -1..1. */\nexport interface LissajousPoint {\n  x: number\n  y: number\n}\n\nexport interface LissajousGeometryParams {\n  /**\n   * Offset between the X and Y reads, as a fraction of the buffer (0..1).\n   * Converted to a sample offset — this is what sets the figure's shape.\n   */\n  phaseShift?: number\n  /** How many points to plot. Clamped to half the buffer. */\n  trailLength?: number\n}\n\n/** 248° as a fraction of a turn — the reference instrument's default figure. */\nexport const DEFAULT_PHASE_SHIFT = 248 / 360\n\n/**\n * The figure, as points in normalised -1..1 space.\n *\n * Pure: no canvas, no DOM, no React. Screen mapping is deliberately left to the\n * caller so the same core can drive a canvas, a WebGL buffer, an SVG path or an\n * offline render, and so the shape can be asserted on directly in a test.\n *\n * Sampling walks the buffer in `bufferLength / count` strides rather than\n * taking a contiguous run, so the trail spans the whole frame at any length\n * instead of showing a progressively smaller window of it.\n */\nexport function lissajousPoints(\n  data: Float32Array,\n  { phaseShift = DEFAULT_PHASE_SHIFT, trailLength = 450 }: LissajousGeometryParams = {},\n): LissajousPoint[] {\n  const bufferLength = data.length\n  if (bufferLength === 0) return []\n\n  // Half the buffer is the ceiling: beyond it the stride rounds to zero and\n  // every point would read the same sample.\n  const count = Math.max(1, Math.min(Math.floor(trailLength), Math.floor(bufferLength / 2)))\n  const step = Math.max(1, Math.floor(bufferLength / count))\n  // A fractional phase wraps the same way a whole one does, so the caller can\n  // pass 1.25 without getting an out-of-range index.\n  const offset = Math.floor(bufferLength * (((phaseShift % 1) + 1) % 1))\n\n  const points: LissajousPoint[] = []\n  for (let i = 0; i < count; i += 1) {\n    const index = i * step\n    if (index >= bufferLength) break\n    points.push({\n      x: data[index],\n      y: data[(index + offset) % bufferLength],\n    })\n  }\n  return points\n}\n\n/**\n * A parameter-derived figure: t runs 0..1 along the trace (the head is t = 1),\n * returning a point in normalised -1..1 space.\n */\nexport type LissajousFigure = (t: number) => LissajousPoint\n\n/**\n * Sample a parameter-derived figure into `count` points, t = 0 through 1\n * inclusive so a periodic figure closes on itself. Non-finite points are\n * dropped rather than drawn to infinity. Pure, like `lissajousPoints`.\n */\nexport function sampleLissajousFigure(\n  figure: LissajousFigure,\n  count = 450,\n): LissajousPoint[] {\n  const n = Math.max(2, Math.floor(count))\n  const points: LissajousPoint[] = []\n  for (let i = 0; i < n; i += 1) {\n    const point = figure(i / (n - 1))\n    if (Number.isFinite(point.x) && Number.isFinite(point.y))\n      points.push({ x: point.x, y: point.y })\n  }\n  return points\n}\n\nexport interface LissajousScopeProps extends AudioSourceProps, LissajousGeometryParams {\n  /**\n   * Draw this figure instead of live audio: a function from t (0..1 along the\n   * trace) to a normalised -1..1 point, sampled at `trailLength` points. For a\n   * face display computed from parameter values. Takes precedence over\n   * `analyser`/`getData`; `phaseShift` does not apply.\n   */\n  figure?: LissajousFigure\n  /**\n   * Draw these already sampled -1..1 points (in trace order, head last)\n   * instead of live audio. Takes precedence over `figure` and the audio props.\n   */\n  points?: readonly LissajousPoint[]\n  width?: number | string\n  height?: number\n  /**\n   * Alpha of the per-frame wash that fades the previous frame. Lower means\n   * longer persistence. 0 disables fading entirely (the figure accumulates).\n   */\n  fadeSpeed?: number\n  /** Trace thickness in CSS pixels at the head of the trail. */\n  lineWidth?: number\n  /**\n   * Size multiplier for the figure. 1 means a full-scale signal spans the\n   * shorter axis exactly; below 1 insets it, above 1 lets it overshoot.\n   */\n  zoomLevel?: number\n  /** Additive multi-pass bloom around the trace. Costs a pass per level. */\n  enableGlow?: boolean\n  /** Number of bloom passes, 1–5. Only read when `enableGlow` is on. */\n  glowIntensity?: number\n  /** Draw the centre crosshair. */\n  showGrid?: boolean\n  /** Trace colour. Defaults to the accent token. */\n  color?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/** Parse `#rgb`/`#rrggbb` to components, so glow passes can vary alpha. */\nfunction parseColor(color: string): { r: number; g: number; b: number } | null {\n  const hex = color.trim()\n  if (!hex.startsWith(\"#\")) return null\n  const body = hex.slice(1)\n  if (body.length === 3) {\n    return {\n      r: Number.parseInt(body[0] + body[0], 16),\n      g: Number.parseInt(body[1] + body[1], 16),\n      b: Number.parseInt(body[2] + body[2], 16),\n    }\n  }\n  if (body.length === 6) {\n    return {\n      r: Number.parseInt(body.slice(0, 2), 16),\n      g: Number.parseInt(body.slice(2, 4), 16),\n      b: Number.parseInt(body.slice(4, 6), 16),\n    }\n  }\n  return null\n}\n\nexport function LissajousScope({\n  figure,\n  points: suppliedPoints,\n  analyser,\n  getData,\n  dataSize = 2048,\n  paused = false,\n  width = \"100%\",\n  height = 220,\n  phaseShift = DEFAULT_PHASE_SHIFT,\n  trailLength = 450,\n  fadeSpeed = 0.1,\n  lineWidth = 1,\n  zoomLevel = 0.6,\n  enableGlow = false,\n  glowIntensity = 3,\n  showGrid = true,\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: LissajousScopeProps) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null)\n  // A parameter-derived figure, when one is supplied; null means live audio.\n  const figurePoints = useMemo(\n    () =>\n      suppliedPoints\n        ? suppliedPoints.filter(\n            (point) => Number.isFinite(point.x) && Number.isFinite(point.y),\n          )\n        : figure\n          ? sampleLissajousFigure(figure, trailLength)\n          : null,\n    [suppliedPoints, figure, trailLength],\n  )\n\n  const draw = useCallback(\n    ({\n      ctx,\n      width: w,\n      height: h,\n      data,\n    }: {\n      ctx: CanvasRenderingContext2D\n      width: number\n      height: number\n      data: Float32Array | null\n    }) => {\n      const styles = getComputedStyle(ctx.canvas)\n      const stroke = canvasColor(ctx.canvas, color ?? token.accent)\n      const grid = styles.getPropertyValue(\"--uikit-border\").trim() || \"rgba(148,163,184,0.35)\"\n      const surface = styles.getPropertyValue(\"--uikit-surface\").trim() || \"#14161d\"\n\n      // Persistence: wash the previous frame toward the surface colour instead\n      // of clearing it. The canvas element is transparent (the CSS background\n      // shows through), so the wash has to paint the surface colour itself —\n      // `clearRect` alpha compositing would leave the old trail untouched.\n      const rgb = parseColor(surface)\n      if (figurePoints) {\n        // A figure from parameter values repaints only when it changes, so a\n        // wash would leave the previous figure ghosted forever: clear instead.\n        ctx.clearRect(0, 0, w, h)\n      } else if (fadeSpeed <= 0) {\n        // No fade requested: still need a base coat on the very first frame,\n        // and after that the figure simply accumulates.\n        if (!data) ctx.clearRect(0, 0, w, h)\n      } else {\n        ctx.fillStyle = rgb\n          ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${fadeSpeed})`\n          : surface\n        ctx.globalAlpha = rgb ? 1 : fadeSpeed\n        ctx.fillRect(0, 0, w, h)\n        ctx.globalAlpha = 1\n      }\n\n      // A Lissajous figure is only undistorted on square axes: mapping X and Y\n      // by different scales turns circles into ellipses. Scale both by the\n      // shorter side and centre the result.\n      const size = Math.min(w, h) * zoomLevel\n      const centreX = w / 2\n      const centreY = h / 2\n\n      if (showGrid) {\n        ctx.strokeStyle = grid\n        ctx.globalAlpha = 0.35\n        ctx.lineWidth = 1\n        ctx.beginPath()\n        ctx.moveTo(centreX, 0)\n        ctx.lineTo(centreX, h)\n        ctx.moveTo(0, centreY)\n        ctx.lineTo(w, centreY)\n        ctx.stroke()\n        ctx.globalAlpha = 1\n      }\n\n      if (!figurePoints && (!data || data.length === 0)) return\n\n      const points =\n        figurePoints ?? lissajousPoints(data!, { phaseShift, trailLength })\n      if (points.length < 2) return\n\n      // Normalised -1..1 → canvas. Y is inverted: positive amplitude is up.\n      const toX = (x: number) => centreX + (x * size) / 2\n      const toY = (y: number) => centreY - (y * size) / 2\n\n      ctx.lineCap = \"round\"\n      ctx.lineJoin = \"round\"\n      ctx.save()\n\n      /**\n       * The trail's alpha ramps along its length — the head bright, the tail\n       * faded — which is what gives the figure a direction of travel. Rather\n       * than one stroke per segment (hundreds a frame, times the glow-pass\n       * count), the ramp is approximated with a handful of contiguous alpha\n       * BANDS: over a long trail a few bands are visually identical to a\n       * per-point ramp at a fraction of the draw calls. Bands overlap by one\n       * point so there is no seam.\n       */\n      const BANDS = 6\n      const strokeTrail = (segmentWidth: number, alphaScale: number, rgbStroke?: string) => {\n        ctx.lineWidth = segmentWidth\n        if (rgbStroke) ctx.strokeStyle = rgbStroke\n        const n = points.length\n        const bandCount = Math.min(BANDS, n - 1)\n        for (let band = 0; band < bandCount; band += 1) {\n          const start = Math.floor((band * (n - 1)) / bandCount)\n          const end = Math.floor(((band + 1) * (n - 1)) / bandCount)\n          if (end <= start) continue\n          ctx.globalAlpha = (end / n) * alphaScale\n          ctx.beginPath()\n          ctx.moveTo(toX(points[start].x), toY(points[start].y))\n          for (let i = start + 1; i <= end; i += 1) {\n            ctx.lineTo(toX(points[i].x), toY(points[i].y))\n          }\n          ctx.stroke()\n        }\n      }\n\n      if (enableGlow && glowIntensity > 0) {\n        // Additive blending: overlapping wide, faint passes sum toward white\n        // in the middle, which reads as bloom rather than as a thick line.\n        ctx.globalCompositeOperation = \"lighter\"\n        const passes = Math.max(1, Math.min(5, Math.floor(glowIntensity)))\n        const glow = parseColor(stroke)\n        for (let pass = passes; pass >= 1; pass -= 1) {\n          const passWidth = lineWidth + pass * 8\n          // Quadratic falloff: the widest pass is also the faintest, so the\n          // bloom tapers off smoothly instead of banding.\n          const passAlpha = 0.4 / (pass * pass)\n          ctx.strokeStyle = glow\n            ? `rgba(${glow.r}, ${glow.g}, ${glow.b}, ${passAlpha})`\n            : stroke\n          strokeTrail(passWidth, passAlpha)\n        }\n        ctx.globalCompositeOperation = \"source-over\"\n        ctx.shadowBlur = 10 * (glowIntensity / 3)\n        ctx.shadowColor = stroke\n      }\n\n      ctx.strokeStyle = stroke\n      strokeTrail(lineWidth, 0.9)\n\n      ctx.shadowBlur = 0\n      ctx.globalAlpha = 1\n      ctx.restore()\n\n      // The head of the trail: a dot marking the current sample, which is what\n      // makes the figure read as being traced rather than as a static shape.\n      const head = points[points.length - 1]\n      ctx.globalAlpha = 1\n      ctx.fillStyle = stroke\n      ctx.beginPath()\n      ctx.arc(toX(head.x), toY(head.y), Math.max(1, lineWidth * 2), 0, Math.PI * 2)\n      ctx.fill()\n    },\n    [\n      figurePoints,\n      color,\n      fadeSpeed,\n      zoomLevel,\n      showGrid,\n      phaseShift,\n      trailLength,\n      lineWidth,\n      enableGlow,\n      glowIntensity,\n    ],\n  )\n\n  useAudioCanvas(canvasRef, {\n    analyser,\n    getData,\n    dataSize,\n    paused,\n    kind: \"time\",\n    draw,\n    frameKey: figurePoints,\n  })\n\n  return (\n    <canvas\n      ref={canvasRef}\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      role=\"img\"\n      aria-label={\n        ariaLabel ?? (figurePoints ? \"Lissajous figure\" : \"Lissajous oscilloscope\")\n      }\n      style={{\n        display: \"block\",\n        width,\n        height,\n        background: token.surface,\n        borderRadius: token.radius,\n        border: `1px solid ${token.border}`,\n        ...style,\n      }}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/lissajous-scope.tsx"
    }
  ]
}
