{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-chladni-plate",
  "type": "registry:ui",
  "title": "Chladni Plate",
  "description": "Nodal patterns for a set of frequencies — the figure sand draws on a driven metal plate. Either a particle simulation finding the nodes over time or the standing wave as a field, on a square or circular plate. Parameter-driven, so it needs no audio source.",
  "dependencies": [
    "@webgpu/types",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-goniometer.json",
    "https://pluginui.com/r/core-spectrogram.json"
  ],
  "files": [
    {
      "path": "components/ui/chladni-plate.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/chladni-plate\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 * Chladni Plate — nodal patterns for a set of frequencies.\n *\n * Drive a metal plate at a frequency and sand on it collects along the lines\n * that are not moving. This draws that: `frequencies` picks the plate's modes,\n * and either a field render (the standing wave as a heatmap) or a particle\n * simulation (sand finding the nodes over a few seconds) shows the figure.\n *\n * All the maths lives in `lib/chladni-formulas` — pure, no canvas, no DOM,\n * ported verbatim from the Vue original and pinned by unit tests. This file is\n * only the renderer, which is why it can be swapped for a WebGL one without\n * touching a line of physics.\n *\n * No audio source: this is param-driven. Feed it frequencies from held notes,\n * an analyser's peak bin, sliders, or a constant — the component does not care\n * where they came from and never touches Web Audio.\n *\n * Ported from the osc1 Vue `ChladniPlatePanel.vue` (and the `ChladniPlate` it\n * wrapped). Deliberate deviations, matching the Lissajous port's policy:\n *\n *   - No p5.js. The original pulled in the whole library for a 2D canvas and\n *     a particle array; that is a large dependency for `fillRect` in a loop,\n *     and the kit's registry components carry no runtime dependencies.\n *   - No enable/disable gate. The Vue panel defaulted the whole visualisation\n *     to *off* because the p5 sim burned CPU at idle. Here the render is cheap\n *     enough to leave running, and the standard `paused` prop is the off\n *     switch — the shared contract, not a bespoke one.\n *   - No FPS-based auto-degradation: nothing silently lowers `particleCount`\n *     or `resolution` behind the caller's back. Pass smaller numbers if you\n *     want a cheaper render.\n *   - `plateShape` and the mode overrides, which the panel exposed as its own\n *     local UI state, are unconditional props here.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`.\n */\n\nimport { ChladniNodalView } from \"@/components/ui/chladni-nodal-view\"\nimport type { WebGpuSpectrogramSession } from \"maruaudio/ui/shared/webgpu-spectrogram\"\nimport { useCallback, useEffect, useRef, useMemo } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  defaultConfig,\n  frequencyToModes,\n  initializeParticles,\n  prepareChladniField,\n  stepChladniParticle,\n  type Particle,\n} from \"maruaudio/ui/shared/chladni-formulas\"\n\nexport type ChladniVariant = \"particles\" | \"field\" | \"nodal\"\nexport type ChladniPlateShape = \"square\" | \"circular\"\n\nexport interface ChladniPlateProps {\n  /** Nodal variant uses GPU analytic particle projection; Canvas fallback caps at 512. */\n  renderer?: \"auto\" | \"canvas2d\"\n  gpuSession?: WebGpuSpectrogramSession | null\n  /** Host playhead in seconds. Omit only for a free-running visual demo. */\n  time?: number\n  /**\n   * The frequencies driving the plate, in Hz. Each is mapped to a mode pair\n   * and the patterns are superposed. An empty list draws a still plate.\n   */\n  frequencies?: number[]\n  /**\n   * Mode numbers, bypassing the frequency mapping entirely. When set, this\n   * wins over `frequencies` — the Vue panel's \"Direct Mode Control\" toggle,\n   * expressed as the presence or absence of a prop rather than a flag.\n   */\n  modes?: { m: number; n: number }\n  /** Sand finding the nodes over time, or the standing wave as a heatmap. */\n  variant?: ChladniVariant\n  /** Plate outline. Circular masks the square field to an inscribed disc. */\n  plateShape?: ChladniPlateShape\n  /** How many particles to simulate. Only read by the particle variant. */\n  particleCount?: number\n  /** Random-walk scale. Higher means a livelier, blurrier pattern. */\n  vibrationStrength?: number\n  /** Field sampling grid, per axis. Only read by the field variant. */\n  resolution?: number\n  /** First mode's amplitude coefficient. */\n  a?: number\n  /** Transposed mode's amplitude coefficient. */\n  b?: number\n  /** Stop the animation and hold the current frame. */\n  paused?: boolean\n  /** Particle / nodal-line colour. Defaults to the accent token. */\n  color?: string\n  size?: number | string\n  height?: number\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/** Parse `#rgb`/`#rrggbb` so the field render can vary alpha per cell. */\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\n/** Inside the inscribed disc, for the circular plate mask. Unit-square coords. */\nfunction insidePlate(x: number, y: number, shape: ChladniPlateShape): boolean {\n  if (shape !== \"circular\") return true\n  const dx = x - 0.5\n  const dy = y - 0.5\n  return dx * dx + dy * dy <= 0.25\n}\n\nexport function ChladniPlate({\n  frequencies = [440],\n  modes,\n  variant = \"particles\",\n  plateShape = \"square\",\n  particleCount = variant === \"nodal\" ? 8192 : 2000,\n  renderer,\n  gpuSession,\n  time,\n  vibrationStrength = defaultConfig.vibrationStrength,\n  resolution = 80,\n  a = 1,\n  b = 1,\n  paused = false,\n  color,\n  size = \"100%\",\n  height = 260,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: ChladniPlateProps) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null)\n  const particlesRef = useRef<Particle[]>([])\n\n  // Props the loop reads every frame. Held in a ref so changing a frequency\n  // does not tear down and restart the animation — which would scatter the\n  // particles and lose the pattern they had already found.\n  const field = useMemo(\n    () => prepareChladniField(frequencies, a, b, modes),\n    [frequencies, a, b, modes]\n  )\n  const settingsRef = useRef({\n    field,\n    frequencies,\n    modes,\n    variant,\n    plateShape,\n    vibrationStrength,\n    resolution,\n    a,\n    b,\n    color,\n  })\n  useEffect(() => {\n    settingsRef.current = {\n      field,\n      frequencies,\n      modes,\n      variant,\n      plateShape,\n      vibrationStrength,\n      resolution,\n      a,\n      b,\n      color,\n    }\n  })\n\n  // Reseed only when the population size changes. Growing or shrinking the\n  // array in place would be possible, but a resize is rare and a clean scatter\n  // is the honest thing to show.\n  useEffect(() => {\n    if (variant === \"nodal\") return\n    particlesRef.current = initializeParticles(\n      Math.max(0, Math.floor(particleCount))\n    )\n  }, [particleCount, variant])\n\n  /**\n   * Displacement at a point, honouring the `modes` override.\n   *\n   * Kept here rather than in the pure module because it is a prop-resolution\n   * rule, not physics — the maths module has no opinion about which of two\n   * props wins.\n   */\n  const displacement = useCallback(\n    (x: number, y: number, s: typeof settingsRef.current): number =>\n      s.field(x, y),\n    []\n  )\n\n  useEffect(() => {\n    if (typeof window === \"undefined\") return\n\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const ctx = canvas.getContext(\"2d\")\n    if (!ctx) return\n\n    let raf = 0\n    let w = 0\n    let h = 0\n\n    // HiDPI: match the backing store to the CSS box, and work in CSS pixels.\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2)\n      const rect = canvas.getBoundingClientRect()\n      w = rect.width\n      h = rect.height\n      canvas.width = Math.max(1, Math.floor(w * dpr))\n      canvas.height = Math.max(1, Math.floor(h * dpr))\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n    }\n    resize()\n\n    const observer =\n      typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(resize) : null\n    observer?.observe(canvas)\n    // A canvas sized in percentages gets its real geometry from the parent.\n    if (canvas.parentElement) observer?.observe(canvas.parentElement)\n\n    const render = () => {\n      if (document.hidden) return\n      // Not laid out yet: a zero-width canvas draws nothing, forever.\n      if (w < 1 || h < 1) {\n        resize()\n        if (w < 1 || h < 1) return\n      }\n\n      const s = settingsRef.current\n      const styles = getComputedStyle(canvas)\n      const surface =\n        styles.getPropertyValue(\"--uikit-surface\").trim() || \"#14161d\"\n      const stroke =\n        s.color ??\n        (styles.getPropertyValue(\"--uikit-accent\").trim() || \"#6366f1\")\n      const rgb = parseColor(stroke)\n\n      ctx.fillStyle = surface\n      ctx.fillRect(0, 0, w, h)\n\n      // The plate is square in the maths; inscribe it in the box and centre it\n      // so a non-square canvas does not stretch the pattern into ellipses.\n      const plate = Math.min(w, h)\n      const originX = (w - plate) / 2\n      const originY = (h - plate) / 2\n\n      if (s.variant === \"field\") {\n        const cells = Math.max(2, Math.floor(s.resolution))\n        const cell = plate / cells\n        for (let i = 0; i < cells; i += 1) {\n          for (let j = 0; j < cells; j += 1) {\n            const x = (i + 0.5) / cells\n            const y = (j + 0.5) / cells\n            if (!insidePlate(x, y, s.plateShape)) continue\n            // Brightest at the nodes: 1 - |displacement| is where the sand is.\n            const value = Math.abs(displacement(x, y, s))\n            const alpha = Math.max(0, 1 - Math.min(1, value))\n            if (alpha <= 0.01) continue\n            ctx.fillStyle = rgb\n              ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`\n              : stroke\n            // y is flipped so the maths' origin sits at the bottom-left.\n            ctx.fillRect(\n              originX + i * cell,\n              originY + plate - (j + 1) * cell,\n              cell + 0.5,\n              cell + 0.5\n            )\n          }\n        }\n      } else {\n        const particles = particlesRef.current\n        const dotSize = Math.max(1, plate / 320)\n        ctx.fillStyle = stroke\n        for (let i = 0; i < particles.length; i += 1) {\n          // The pure step is frequency-driven, so a direct (m, n) override\n          // takes the local variant that reads the modes straight.\n          const moved = stepChladniParticle(\n            particles[i],\n            s.field,\n            s.vibrationStrength\n          )\n          particles[i] = moved\n          if (!insidePlate(moved.x, moved.y, s.plateShape)) continue\n          ctx.fillRect(\n            originX + moved.x * plate,\n            originY + plate - moved.y * plate,\n            dotSize,\n            dotSize\n          )\n        }\n      }\n\n      if (s.plateShape === \"circular\") {\n        // Outline the plate so a circular one reads as a disc rather than as\n        // a square render that happens to be empty in the corners.\n        ctx.strokeStyle =\n          styles.getPropertyValue(\"--uikit-border\").trim() ||\n          \"rgba(148,163,184,0.35)\"\n        ctx.lineWidth = 1\n        ctx.beginPath()\n        ctx.arc(\n          originX + plate / 2,\n          originY + plate / 2,\n          plate / 2 - 0.5,\n          0,\n          Math.PI * 2\n        )\n        ctx.stroke()\n      }\n    }\n\n    if (paused) {\n      // Paused still has to look like something: draw one frame and stop, so\n      // the plate shows its current pattern rather than an empty slab.\n      render()\n      const staticObserver =\n        typeof ResizeObserver !== \"undefined\"\n          ? new ResizeObserver(render)\n          : null\n      staticObserver?.observe(canvas)\n      if (canvas.parentElement) staticObserver?.observe(canvas.parentElement)\n      return () => {\n        observer?.disconnect()\n        staticObserver?.disconnect()\n      }\n    }\n\n    const frame = () => {\n      raf = requestAnimationFrame(frame)\n      render()\n    }\n    raf = requestAnimationFrame(frame)\n\n    return () => {\n      cancelAnimationFrame(raf)\n      observer?.disconnect()\n    }\n  }, [paused, displacement, variant])\n\n  const modeLabel =\n    modes ?? (frequencies.length > 0 ? frequencyToModes(frequencies[0]) : null)\n\n  if (variant === \"nodal\")\n    return (\n      <ChladniNodalView\n        frequencies={frequencies}\n        modes={modes}\n        variant={variant}\n        plateShape={plateShape}\n        particleCount={particleCount}\n        a={a}\n        b={b}\n        paused={paused}\n        color={color}\n        size={size}\n        height={height}\n        className={className}\n        style={style}\n        renderer={renderer}\n        gpuSession={gpuSession}\n        time={time}\n        aria-label={ariaLabel}\n      />\n    )\n\n  return (\n    <canvas\n      ref={canvasRef}\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      role=\"img\"\n      aria-label={\n        ariaLabel ??\n        (modeLabel\n          ? `Chladni plate, modes ${modeLabel.m} by ${modeLabel.n}`\n          : \"Chladni plate\")\n      }\n      style={{\n        display: \"block\",\n        width: size,\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/chladni-plate.tsx"
    },
    {
      "path": "components/ui/chladni-nodal-view.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/chladni-plate\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 */\nimport { useEffect, useRef } from \"react\"\nimport { useWebGpuVisualizationSession } from \"@/components/ui/stereo-monitor\"\nimport { frequencyToModes } from \"maruaudio/ui/shared/chladni-formulas\"\nimport { createChladniNodalRenderer } from \"maruaudio/ui/shared/chladni-nodal-renderer\"\nimport type { ChladniPlateProps } from \"@/components/ui/chladni-plate\"\n\n/** Analytic nodal particles. One GPU dispatch; no CPU particle loop on that path. */\nexport function ChladniNodalView(props: ChladniPlateProps) {\n  const canvas = useRef<HTMLCanvasElement>(null)\n  const latest = useRef(props)\n  latest.current = props\n  const own = useWebGpuVisualizationSession(\n    props.gpuSession === undefined && props.renderer !== \"canvas2d\"\n  )\n  const session =\n    props.renderer === \"canvas2d\" ? null : (props.gpuSession ?? own)\n  const phaseRef = useRef(0)\n  const draw = useRef<(() => void) | null>(null)\n  useEffect(() => {\n    const target = canvas.current\n    if (!target) return\n    const renderer = createChladniNodalRenderer(target, session)\n    const motion = matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const paint = () => {\n      if (document.hidden) return\n      const p = latest.current,\n        box = target.getBoundingClientRect()\n      if (!box.width || !box.height) return\n      const ratio = Math.min(\n        2,\n        window.devicePixelRatio || 1,\n        Math.sqrt(1_000_000 / (box.width * box.height))\n      )\n      const width = Math.floor(box.width * ratio),\n        height = Math.floor(box.height * ratio)\n      if (target.width !== width || target.height !== height)\n        renderer.resize(width, height)\n      const mode = p.modes ?? frequencyToModes(p.frequencies?.[0] ?? 440)\n      if (p.time !== undefined) phaseRef.current = p.time * 0.17\n      else if (!p.paused) phaseRef.current = (performance.now() / 1000) * 0.17\n      const phase = motion.matches ? 0 : phaseRef.current\n      renderer.draw({\n        m: Math.max(1, Math.min(32, mode.m)),\n        n: Math.max(1, Math.min(32, mode.n)),\n        a: p.a ?? 1,\n        b: p.b ?? 1,\n        phase,\n        circular: p.plateShape === \"circular\",\n        count: p.particleCount ?? 8192,\n        radius: Math.max(0.7, Math.min(width, height) / 500),\n        color: p.color ?? \"#e9d58d\",\n      })\n    }\n    draw.current = paint\n    const observer = new ResizeObserver(paint)\n    observer.observe(target)\n    let raf = 0,\n      last = 0\n    const tick = (now: number) => {\n      if (now - last >= 33) {\n        last = now\n        paint()\n      }\n      raf = requestAnimationFrame(tick)\n    }\n    const visibility = () => {\n      cancelAnimationFrame(raf)\n      paint()\n      if (\n        !document.hidden &&\n        !latest.current.paused &&\n        !motion.matches &&\n        latest.current.time === undefined\n      )\n        raf = requestAnimationFrame(tick)\n    }\n    document.addEventListener(\"visibilitychange\", visibility)\n    motion.addEventListener(\"change\", visibility)\n    visibility()\n    return () => {\n      cancelAnimationFrame(raf)\n      observer.disconnect()\n      document.removeEventListener(\"visibilitychange\", visibility)\n      motion.removeEventListener(\"change\", visibility)\n      draw.current = null\n      renderer.dispose()\n    }\n  }, [session, props.paused, props.time === undefined])\n  useEffect(() => {\n    draw.current?.()\n  }, [props])\n  return (\n    <canvas\n      ref={canvas}\n      className={props.className}\n      role=\"img\"\n      aria-label={props[\"aria-label\"] ?? \"Chladni nodal particles\"}\n      style={{\n        display: \"block\",\n        width: props.size ?? \"100%\",\n        height: props.height ?? 260,\n        ...props.style,\n      }}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/chladni-nodal-view.tsx"
    }
  ]
}
