# Plot

> Curves and draggable points, for equalizers, filters and compressors.

```tsx
"use client";

import {
  type BiquadType,
  bandGainForHandle,
  bandHandleDb,
  biquadResponse,
  type EqBand,
  eqResponse,
  formatDb,
  formatHz,
} from "@audio-ui/core/analysis";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { Button } from "@/components/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Knob } from "@/components/audio-ui/knob";
import {
  Plot,
  PlotArea,
  PlotCursor,
  PlotCurve,
  PlotGraph,
  PlotGrid,
  PlotHandle,
  PlotTraceArea,
  useValueAtPointer,
} from "@/components/audio-ui/plot";
import { useNoiseThroughEq } from "@/hooks/use-noise-through-eq";
import { useSpectrum } from "@/hooks/use-spectrum";

type Band = EqBand & { id: number };

const BANDS: Band[] = [
  { frequency: 90, gainDb: 3, id: 1, type: "lowshelf" },
  { frequency: 1200, gainDb: -4, id: 2, q: 1.2, type: "peak" },
  { frequency: 7000, gainDb: 5, id: 3, type: "highshelf" },
];
const MAX_BANDS = 8;
/** How far a press may move and still count as a click, in pixels. */
const CLICK_TOLERANCE = 4;
const NEW_BAND_Q = 1;
const MIN_HZ = 20;
const MAX_HZ = 20_000;
const MAX_DB = 18;
const MIN_Q = 0.3;
const MAX_Q = 12;
/** How far one unit of wheel delta widens or narrows a band, as an exponent. */
const WHEEL_Q_SENSITIVITY = 0.002;
/** The scale the spectrum is drawn on, in dB of level: its own, behind the gain axis. */
const SPECTRUM_MIN_DB = -100;
const SPECTRUM_MAX_DB = -10;
const DB_TICKS = [-12, 0, 12];
/** What a band can be, from the bottom of the spectrum up. */
const BAND_TYPES: { label: string; value: BiquadType }[] = [
  { label: "Low cut", value: "lowcut" },
  { label: "Low shelf", value: "lowshelf" },
  { label: "Bell", value: "peak" },
  { label: "Notch", value: "notch" },
  { label: "High shelf", value: "highshelf" },
  { label: "High cut", value: "highcut" },
];
/** The width a band has when it was never given one. */
const DEFAULT_Q = Math.SQRT1_2;

const formatDbMark = (db: number) => (db > 0 ? `+${db}` : String(db));
const describePointer = (point: { x: number; y: number }) =>
  `${formatHz(point.x)} · ${formatDb(point.y)}`;
const describeValue = (value: number, axis: "x" | "y") =>
  axis === "x" ? formatHz(value) : formatDb(value);
const clampGain = (gainDb: number) =>
  Math.min(MAX_DB, Math.max(-MAX_DB, gainDb));

interface BandHandleProps {
  active: boolean;
  bands: readonly EqBand[];
  editing: boolean;
  index: number;
  onChange: (index: number, patch: Partial<EqBand>) => void;
  onEdit: (index: number | null) => void;
  onRemove: (index: number) => void;
  onSelect: (index: number) => void;
}

interface BandAdderProps {
  bands: readonly EqBand[];
  onAdd: (frequency: number, gainDb: number) => void;
}

/** The plot's empty space: double-click it to add a band there, so its handle lands under the pointer. */
function BandAdder({ bands, onAdd }: BandAdderProps) {
  const valueAt = useValueAtPointer();
  const handleDoubleClick = useCallback(
    (event: React.MouseEvent) => {
      const point = valueAt(event.clientX, event.clientY);
      if (!point) {
        return;
      }
      // The gain that puts a new bell's handle at the pointer, on top of what is already there.
      const gainDb = point.y - eqResponse(bands)(point.x);
      onAdd(point.x, clampGain(gainDb));
    },
    [bands, onAdd, valueAt]
  );
  // A mouse gesture with a keyboard equivalent: the handles' own Enter-free
  // path is the keyboard add below the plot.
  return (
    // biome-ignore lint/a11y/noStaticElementInteractions: a pointer shortcut; adding a band has its own button.
    <div className="absolute inset-0" onDoubleClick={handleDoubleClick} />
  );
}

interface BandEditorProps {
  band: EqBand;
  index: number;
  onChange: (index: number, patch: Partial<EqBand>) => void;
}

const formatQ = (q: number) => `Q ${q.toFixed(1)}`;

/** What a band is and how wide: its type, and its Q on a logarithmic knob. */
function BandEditor({ band, index, onChange }: BandEditorProps) {
  const handleTypeChange = useCallback(
    (type: BiquadType | null) => {
      if (type) {
        onChange(index, { type });
      }
    },
    [index, onChange]
  );
  const handleWidthChange = useCallback(
    (q: number) => onChange(index, { q }),
    [index, onChange]
  );

  return (
    <div className="flex items-center justify-between gap-4">
      <div className="flex flex-col gap-2">
        <span className="font-medium text-xs">Band {index + 1}</span>
        <Select
          items={BAND_TYPES}
          onValueChange={handleTypeChange}
          value={band.type}
        >
          <SelectTrigger
            aria-label={`Band ${index + 1} type`}
            className="w-28"
            size="sm"
          >
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectGroup>
              {BAND_TYPES.map((item) => (
                <SelectItem key={item.value} value={item.value}>
                  {item.label}
                </SelectItem>
              ))}
            </SelectGroup>
          </SelectContent>
        </Select>
      </div>
      <Knob
        aria-label={`Band ${index + 1} width`}
        className="mb-5"
        formatValue={formatQ}
        max={MAX_Q}
        min={MIN_Q}
        onValueChange={handleWidthChange}
        scale="log"
        size="sm"
        step={0.01}
        value={band.q ?? DEFAULT_Q}
        valueLabel="always"
      />
    </div>
  );
}

/**
 * One band's handle. It sits on the equalizer's curve, so it follows the
 * pointer one to one; the wheel widens or narrows the band, and a
 * double-click flattens it.
 */
function BandHandle({
  active,
  bands,
  editing,
  index,
  onChange,
  onEdit,
  onRemove,
  onSelect,
}: BandHandleProps) {
  const ref = useRef<HTMLDivElement>(null);
  const band = bands[index];
  const q = band?.q;

  // Attached natively: React listens to wheel passively, so it could not
  // stop the page from scrolling while the wheel widens the band.
  useEffect(() => {
    const element = ref.current;
    if (!element) {
      return;
    }
    const handleWheel = (event: WheelEvent) => {
      event.preventDefault();
      onSelect(index);
      const next = (q ?? 1) * Math.exp(-event.deltaY * WHEEL_Q_SENSITIVITY);
      onChange(index, { q: Math.min(MAX_Q, Math.max(MIN_Q, next)) });
    };
    element.addEventListener("wheel", handleWheel, { passive: false });
    return () => element.removeEventListener("wheel", handleWheel);
  }, [index, onChange, onSelect, q]);

  const handleChange = useCallback(
    ({ x, y }: { x: number; y: number }) => {
      onSelect(index);
      onChange(index, {
        frequency: x,
        gainDb: clampGain(bandGainForHandle(bands, index, x, y)),
      });
    },
    [bands, index, onChange, onSelect]
  );
  const reset = useCallback(
    () => onChange(index, { gainDb: 0 }),
    [index, onChange]
  );
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      if (event.key === "Delete" || event.key === "Backspace") {
        event.preventDefault();
        onRemove(index);
      } else if (event.key === "Enter") {
        event.preventDefault();
        onSelect(index);
        onEdit(index);
      }
    },
    [index, onEdit, onRemove, onSelect]
  );
  // A press that stays where it started is a click, not a drag: it opens the band's settings.
  const pressRef = useRef<{ x: number; y: number } | null>(null);
  const handlePointerDown = useCallback((event: React.PointerEvent) => {
    pressRef.current = { x: event.clientX, y: event.clientY };
  }, []);
  const handlePointerUp = useCallback(
    (event: React.PointerEvent) => {
      const start = pressRef.current;
      pressRef.current = null;
      if (
        start &&
        Math.hypot(event.clientX - start.x, event.clientY - start.y) <
          CLICK_TOLERANCE
      ) {
        onSelect(index);
        onEdit(index);
      }
    },
    [index, onEdit, onSelect]
  );
  // A right-click, or a long press on a touch screen, opens it too.
  const handleContextMenu = useCallback(
    (event: React.MouseEvent) => {
      event.preventDefault();
      onSelect(index);
      onEdit(index);
    },
    [index, onEdit, onSelect]
  );
  const handleOpenChange = useCallback(
    (open: boolean) => {
      onEdit(open ? index : null);
      if (!open) {
        // The popover took the focus: hand it back to the handle.
        ref.current?.querySelector("input")?.focus();
      }
    },
    [index, onEdit]
  );

  return (
    <Popover onOpenChange={handleOpenChange} open={editing}>
      <PlotHandle
        aria-label={`Band ${index + 1}`}
        data-active={active || undefined}
        getAriaValueText={describeValue}
        onContextMenu={handleContextMenu}
        onDoubleClick={reset}
        onKeyDown={handleKeyDown}
        onPointerDown={handlePointerDown}
        onPointerUp={handlePointerUp}
        onValueChange={handleChange}
        ref={ref}
        value={{
          x: band?.frequency ?? MIN_HZ,
          y: bandHandleDb(bands, index),
        }}
      >
        <PopoverTrigger
          nativeButton={false}
          render={
            <span
              aria-hidden="true"
              className="pointer-events-none absolute inset-0"
            />
          }
        />
      </PlotHandle>
      <PopoverContent
        align="center"
        className="w-60 p-3"
        side="top"
        sideOffset={12}
      >
        {band ? (
          <BandEditor band={band} index={index} onChange={onChange} />
        ) : null}
      </PopoverContent>
    </Popover>
  );
}

/**
 * An equalizer. Each band's handle sits on the curve: drag it to set the
 * band's frequency and gain, scroll over it to widen or narrow the band,
 * double-click to flatten it. The selected band's own curve is drawn in
 * the foreground, the others faintly.
 */
export function PlotDemo() {
  const [bands, setBands] = useState<Band[]>(BANDS);
  const nextId = useRef(BANDS.length + 1);
  const [selectedRaw, setSelected] = useState(1);
  const [editing, setEditing] = useState<number | null>(null);
  const selected = Math.min(selectedRaw, Math.max(0, bands.length - 1));
  const response = useMemo(() => eqResponse(bands), [bands]);
  const bandCurves = useMemo(
    () => bands.map((item) => biquadResponse(item)),
    [bands]
  );
  const addBand = useCallback((frequency: number, gainDb: number) => {
    setBands((current) => {
      if (current.length >= MAX_BANDS) {
        return current;
      }
      nextId.current += 1;
      setSelected(current.length);
      return [
        ...current,
        {
          frequency,
          gainDb,
          id: nextId.current,
          q: NEW_BAND_Q,
          type: "peak",
        },
      ];
    });
  }, []);
  const removeBand = useCallback((index: number) => {
    setBands((current) => current.filter((_, at) => at !== index));
    setSelected((current) =>
      Math.max(0, current > index ? current - 1 : current)
    );
  }, []);
  const changeBand = useCallback((index: number, patch: Partial<EqBand>) => {
    setBands((current) =>
      current.map((other, at) =>
        at === index ? { ...other, ...patch } : other
      )
    );
  }, []);
  const band = bands[selected];
  const { analyser, playing, toggle } = useNoiseThroughEq(bands);
  const spectrum = useSpectrum(analyser, { maxHz: MAX_HZ, minHz: MIN_HZ });

  return (
    <div className="flex w-full flex-col gap-2">
      <Plot
        className="h-40"
        maxX={MAX_HZ}
        maxY={MAX_DB}
        minX={MIN_HZ}
        minY={-MAX_DB}
        scaleX="log"
      >
        <PlotCursor format={describePointer} />
        <PlotGrid
          x={{ count: 6 }}
          y={{ format: formatDbMark, ticks: DB_TICKS }}
        />
        <BandAdder bands={bands} onAdd={addBand} />
        <PlotGraph>
          <PlotTraceArea
            fillTo={SPECTRUM_MIN_DB}
            max={SPECTRUM_MAX_DB}
            min={SPECTRUM_MIN_DB}
            source={spectrum}
          />
          {bandCurves.map((curve, index) => (
            <PlotCurve
              className={
                index === selected
                  ? "stroke-primary/60 [stroke-dasharray:3_3]"
                  : "stroke-foreground/10"
              }
              fn={curve}
              // The bands never come or go, so their order is their identity.
              // biome-ignore lint/suspicious/noArrayIndexKey: fixed list.
              key={index}
            />
          ))}
          <PlotArea fillTo={0} fn={response} />
          <PlotCurve fn={response} />
        </PlotGraph>
        {bands.map((item, index) => (
          <BandHandle
            active={index === selected}
            bands={bands}
            editing={index === editing}
            index={index}
            key={item.id}
            onChange={changeBand}
            onEdit={setEditing}
            onRemove={removeBand}
            onSelect={setSelected}
          />
        ))}
      </Plot>
      <div className="flex items-center justify-between gap-2">
        <Button
          aria-label={
            playing ? "Stop the noise" : "Play pink noise through the equalizer"
          }
          onClick={toggle}
          size="icon-sm"
          variant="outline"
        >
          {playing ? (
            <IconPlaceholder
              hugeicons="PauseIcon"
              lucide="PauseIcon"
              phosphor="PauseIcon"
              remixicon="RiPauseLine"
              tabler="IconPlayerPause"
            />
          ) : (
            <IconPlaceholder
              hugeicons="PlayIcon"
              lucide="PlayIcon"
              phosphor="PlayIcon"
              remixicon="RiPlayLine"
              tabler="IconPlayerPlay"
            />
          )}
        </Button>
        <output className="font-mono text-muted-foreground text-xs">
          Band {selected + 1}: {formatHz(band?.frequency ?? 0)},{" "}
          {formatDb(band?.gainDb ?? 0)}
          {band?.q === undefined ? "" : `, Q ${band.q.toFixed(1)}`}
        </output>
      </div>
    </div>
  );
}
```

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/plot
```

It installs the element alone. For the equalizer above, with its pink noise and spectrum, ready to run: `npx shadcn@latest add @audio-ui/plot-demo`.

**Manual**

**1. Install the following dependencies:**

```bash
npm install @audio-ui/react
```

**2. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/plot.tsx"
"use client";

import { Plot as PlotPrimitive } from "@audio-ui/react";
import { cn } from "@/lib/utils";

/** The frame: give it a width and a height, draw in it with the parts below. */
function Plot({ className, ...props }: PlotPrimitive.Root.Props) {
  return (
    <PlotPrimitive.Root
      className={cn(
        "rounded-xl border bg-card relative h-48 w-full touch-none select-none data-disabled:opacity-50",
        className
      )}
      data-slot="plot"
      {...props}
    />
  );
}

function PlotGraph({ className, ...props }: PlotPrimitive.Graph.Props) {
  return (
    <PlotPrimitive.Graph
      className={cn(
        "text-primary overflow-hidden! rounded-[inherit]",
        className
      )}
      data-slot="plot-graph"
      {...props}
    />
  );
}

/** A curve: `y = fn(x)` across the plot. */
function PlotCurve({ className, ...props }: PlotPrimitive.Curve.Props) {
  return (
    <PlotPrimitive.Curve
      className={cn(
        "stroke-primary stroke-[1.5px] fill-none [stroke-linecap:round] [stroke-linejoin:round]",
        className
      )}
      data-slot="plot-curve"
      {...props}
    />
  );
}

/** The same, filled down to `fillTo`: draw it under a `PlotCurve`. */
function PlotArea({ className, ...props }: PlotPrimitive.Curve.Props) {
  return (
    <PlotPrimitive.Curve
      className={cn("fill-primary/15 stroke-none", className)}
      data-slot="plot-area"
      {...props}
    />
  );
}

/** A live line from a `source`, such as a spectrum: it follows the signal without re-rendering. */
function PlotTrace({ className, ...props }: PlotPrimitive.Trace.Props) {
  return (
    <PlotPrimitive.Trace
      className={cn(
        "stroke-primary/40 stroke-1 fill-none [stroke-linecap:round] [stroke-linejoin:round]",
        className
      )}
      data-slot="plot-trace"
      {...props}
    />
  );
}

/** The same, filled down to `fillTo`. */
function PlotTraceArea({ className, ...props }: PlotPrimitive.Trace.Props) {
  return (
    <PlotPrimitive.Trace
      className={cn("fill-primary/10 stroke-none", className)}
      data-slot="plot-trace-area"
      {...props}
    />
  );
}

/** Where the signal is right now: the input level on a compressor's curve. */
function PlotDot({ className, ...props }: PlotPrimitive.Dot.Props) {
  return (
    <PlotPrimitive.Dot
      className={cn("rounded-full bg-primary ring-4 ring-primary/25 size-2.5 rounded-full", className)}
      data-slot="plot-dot"
      {...props}
    />
  );
}

/** A rule at a value: 0 dB, a cutoff, a threshold. */
function PlotLine({ className, ...props }: PlotPrimitive.Line.Props) {
  return (
    <PlotPrimitive.Line
      className={cn("stroke-border", className)}
      data-slot="plot-line"
      {...props}
    />
  );
}

type PlotCursorProps = Omit<PlotPrimitive.Cursor.Props, "children"> & {
  /** Turns the value under the pointer, in the axes' units, into the text shown in the corner. */
  format: (value: { x: number; y: number }) => string;
};

/** Crosshair lines and a readout of what is under the pointer, for mouse and pen. */
function PlotCursor({ className, format, ...props }: PlotCursorProps) {
  return (
    <PlotPrimitive.Cursor
      className={cn("group/cursor", className)}
      data-slot="plot-cursor"
      {...props}
    >
      <div
        className="bg-foreground/25 absolute inset-y-0 w-px opacity-0 group-data-[active]/cursor:opacity-100"
        style={{ left: "var(--plot-cursor-x)" }}
      />
      <div
        className="bg-foreground/25 absolute inset-x-0 h-px opacity-0 group-data-[active]/cursor:opacity-100"
        style={{ top: "var(--plot-cursor-y)" }}
      />
      <PlotPrimitive.CursorReadout
        className="rounded-sm bg-card/80 px-1 font-mono text-[10px] text-muted-foreground tabular-nums opacity-0 group-data-[active]/cursor:opacity-100"
        format={format}
        style={{ left: "auto", right: "0.5rem", top: "0.375rem" }}
      />
    </PlotPrimitive.Cursor>
  );
}

/** Grid lines and labels at round values it works out from the axes: pass `x` and `y` to choose which axes, and `ticks` or `format` to choose the values and their text. */
function PlotGrid({ className, ...props }: PlotPrimitive.Grid.Props) {
  return (
    <PlotPrimitive.Grid
      className={cn(
        "text-muted-foreground rounded-[inherit] text-[10px] text-muted-foreground tabular-nums leading-none",
        // The lines at the edges (0 dB, the dry sound) follow the rounded corners of the frame.
        "[&>svg]:overflow-hidden! [&>svg]:rounded-[inherit]",
        "[&_[data-part=line]]:stroke-border [&_[data-part=line][data-zero]]:stroke-foreground/30",
        // A halo in the surface colour keeps a label readable over the curve without hiding any of it.
        "[&_[data-part=label]]:z-1 [&_[data-part=label]]:px-0.5 [&_[data-part=label]]:[text-shadow:0_0_2px_var(--card),0_0_2px_var(--card),0_0_4px_var(--card)]",
        "[&_[data-part=label][data-axis=x]]:-translate-x-1/2 [&_[data-part=label][data-axis=x]]:bottom-1.5",
        "[&_[data-part=label][data-axis=y]]:-translate-y-1/2 [&_[data-part=label][data-axis=y]]:left-2",
        className
      )}
      data-slot="plot-grid"
      {...props}
    />
  );
}

/** A label at a value on an axis: "1k" under a frequency, "+12" by a gain. */
function PlotMark({ axis, className, ...props }: PlotPrimitive.Mark.Props) {
  return (
    <PlotPrimitive.Mark
      axis={axis}
      className={cn(
        "bg-card/80 px-0.5 text-muted-foreground text-[10px] tabular-nums leading-none",
        axis === "x"
          ? "bottom-1.5 -translate-x-1/2"
          : "left-2 -translate-y-1/2",
        className
      )}
      data-slot="plot-mark"
      {...props}
    />
  );
}

/** A shaded stretch of one axis. */
function PlotBand({ className, ...props }: PlotPrimitive.Band.Props) {
  return (
    <PlotPrimitive.Band
      className={cn("fill-foreground/5", className)}
      data-slot="plot-band"
      {...props}
    />
  );
}

/** A point to drag: a band, a cutoff, a threshold. Arrow keys move it too. */
function PlotHandle({ className, ...props }: PlotPrimitive.Handle.Props) {
  return (
    <PlotPrimitive.Handle
      className={cn(
        "rounded-full border-2 border-primary bg-background shadow-xs data-active:bg-primary transition-[box-shadow] duration-150 ease-out hover:ring-4 hover:ring-ring/30 focus-within:ring-4 focus-within:ring-ring/30 data-dragging:ring-4 data-dragging:ring-ring/30 motion-reduce:transition-none size-4 cursor-grab outline-none after:absolute after:-inset-2 pointer-coarse:after:-inset-3.5 data-dragging:cursor-grabbing data-disabled:pointer-events-none",
        className
      )}
      data-slot="plot-handle"
      {...props}
    />
  );
}

/** Turns a pointer position into a value on the plot, for adding a point where someone double-clicks. */
const { useValueAtPointer } = PlotPrimitive;

export {
  Plot,
  PlotArea,
  PlotBand,
  PlotCursor,
  PlotCurve,
  PlotDot,
  PlotGraph,
  PlotGrid,
  PlotHandle,
  PlotLine,
  PlotMark,
  PlotTrace,
  PlotTraceArea,
  useValueAtPointer,
};
```

**3. Update the import paths to match your project setup.**

## Anatomy

Import the primitive and assemble its parts.

```tsx
import { Plot } from "@audio-ui/react";

<Plot.Root>
  <Plot.Grid />
  <Plot.Graph>
    <Plot.Curve />
    <Plot.Trace />
    <Plot.Line />
    <Plot.Band />
  </Plot.Graph>
  <Plot.Mark />
  <Plot.Dot />
  <Plot.Cursor>
    <Plot.CursorReadout />
  </Plot.Cursor>
  <Plot.Handle />
</Plot.Root>
```

## Usage

```tsx
import {
  Plot,
  PlotArea,
  PlotCurve,
  PlotGraph,
  PlotGrid,
  PlotHandle,
} from "@/components/audio-ui/plot";
import { eqResponse } from "@audio-ui/core/analysis";

<Plot minX={20} maxX={20000} minY={-18} maxY={18} scaleX="log">
  <PlotGrid x={{ count: 6 }} y={{ ticks: [-12, 0, 12] }} />
  <PlotGraph>
    <PlotArea fillTo={0} fn={response} />
    <PlotCurve fn={response} />
  </PlotGraph>
  <PlotHandle
    aria-label="Band 1"
    onValueChange={({ x, y }) => setBand({ frequency: x, gainDb: y })}
    value={{ x: band.frequency, y: band.gainDb }}
  />
</Plot>;
```

`fn` is `y = fn(x)` in the axes' units. `@audio-ui/core/analysis` gives you the functions for the usual curves: `eqResponse`, `biquadResponse` and `compressorOutput` (and `webAudioCompressorOutput`, the curve of the browser's own compressor node), and for keeping a band's handle on the curve, `bandHandleDb` (where it sits) and `bandGainForHandle` (the gain that puts it where the pointer is).

## Handles

A handle holds a native `<input type="range">` per axis, so it works from the keyboard: the arrow keys move it by a hundredth of the axis, a tenth with <kbd>Shift</kbd>, and <kbd>Home</kbd> / <kbd>End</kbd> go to the ends. Dragging moves it from where it is rather than jumping to the pointer, and four times finer with <kbd>Shift</kbd>. A double click, an <kbd>Alt</kbd> + click or <kbd>Delete</kbd> return it to `defaultValue`, or the middle of the plot (`reason: "reset"`). `axis="x"` or `axis="y"` holds it to one axis.

## Live signals

`PlotTrace` draws a list of y values from a `source` straight in the DOM, with no React render per frame: the spectrum of the sound behind an equalizer, on a scale of its own (`min`, `max`). `PlotDot` is a point that follows a live `{ x, y }`, such as the input level riding a compressor's curve. The `use-spectrum` hook turns an `AnalyserNode` into the source a trace wants, resampled onto a logarithmic frequency axis, and the default example plays pink noise through the equalizer so you hear what you draw.

```tsx
const spectrum = useSpectrum(analyser, { minHz: 20, maxHz: 20000 });

<PlotGraph>
  <PlotTraceArea fillTo={-100} min={-100} max={-10} source={spectrum} />
  <PlotCurve fn={response} />
</PlotGraph>;
```

The hook's options are in [Analysis](/docs/engine/analysis#spectrum).

## Examples

### Filter

One handle on the resonance peak sets the cutoff and how much it rings.

```tsx
"use client";

import { biquadResponse, formatHz } from "@audio-ui/core/analysis";
import { useCallback, useMemo, useState } from "react";
import {
  Plot,
  PlotArea,
  PlotBand,
  PlotCursor,
  PlotCurve,
  PlotGraph,
  PlotGrid,
  PlotHandle,
  PlotLine,
} from "@/components/audio-ui/plot";

const MIN_HZ = 20;
const MAX_HZ = 20_000;
const MIN_DB = -36;
const MAX_DB = 24;
const BASE_Q = Math.SQRT1_2;
const formatDbMark = (db: number) => (db > 0 ? `+${db}` : String(db));
const DB_TICKS = [-24, 0, 12];
const AUDIBLE_HZ: readonly [number, number] = [100, 10_000];

const describePointer = (point: { x: number; y: number }) =>
  `${formatHz(point.x)} · ${point.y.toFixed(1)} dB`;
const getAriaLabel = (axis: "x" | "y") =>
  axis === "x" ? "Cutoff" : "Resonance";
const describeValue = (value: number, axis: "x" | "y") =>
  axis === "x" ? formatHz(value) : `${value.toFixed(1)} dB`;

/** A low-pass filter: the handle sits on the resonance peak, so dragging it sets the cutoff and how much it rings. */
export function PlotFilterDemo() {
  const [filter, setFilter] = useState({ cutoff: 1800, resonanceDb: 8 });
  const changeFilter = useCallback(
    ({ x, y }: { x: number; y: number }) =>
      setFilter({ cutoff: x, resonanceDb: Math.max(0, y) }),
    []
  );
  const response = useMemo(
    () =>
      biquadResponse({
        frequency: filter.cutoff,
        q: BASE_Q * 10 ** (filter.resonanceDb / 20),
        type: "highcut",
      }),
    [filter]
  );

  return (
    <div className="flex w-full flex-col gap-2">
      <Plot
        className="h-40"
        maxX={MAX_HZ}
        maxY={MAX_DB}
        minX={MIN_HZ}
        minY={MIN_DB}
        scaleX="log"
      >
        <PlotGrid
          x={{ count: 6 }}
          y={{ format: formatDbMark, ticks: DB_TICKS }}
        />
        <PlotGraph>
          <PlotBand axis="x" from={AUDIBLE_HZ[0]} to={AUDIBLE_HZ[1]} />
          <PlotLine axis="x" value={filter.cutoff} />
          <PlotArea fillTo={MIN_DB} fn={response} />
          <PlotCurve fn={response} />
        </PlotGraph>
        <PlotCursor format={describePointer} />
        <PlotHandle
          aria-label="Cutoff and resonance"
          getAriaLabel={getAriaLabel}
          getAriaValueText={describeValue}
          onValueChange={changeFilter}
          value={{ x: filter.cutoff, y: filter.resonanceDb }}
        />
      </Plot>
      <output className="text-center font-mono text-muted-foreground text-xs">
        Cutoff {formatHz(filter.cutoff)}, resonance{" "}
        {filter.resonanceDb.toFixed(1)} dB
      </output>
    </div>
  );
}
```

### Compressor

One handle sets the threshold, the other the ratio.

```tsx
"use client";

import { webAudioCompressorOutput } from "@audio-ui/core/analysis";
import { useCallback, useMemo, useState } from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { Button } from "@/components/ui/button";
import {
  Plot,
  PlotCursor,
  PlotCurve,
  PlotDot,
  PlotGraph,
  PlotGrid,
  PlotHandle,
  PlotLine,
} from "@/components/audio-ui/plot";
import { usePulsesThroughCompressor } from "@/hooks/use-pulses-through-compressor";

const MIN_DB = -60;
const MAX_DB = 0;
/** Headroom past 0 dB, so the handle on the loudest level is not cut by the frame. */
const PLOT_MAX_DB = 6;
const MAX_RATIO = 20;
const BISECTION_STEPS = 24;
const DB_TICKS = [-48, -24, 0];
const describePointer = (point: { x: number; y: number }) =>
  `in ${point.x.toFixed(0)} dB`;
const UNITY = (inputDb: number) => inputDb;

/** The curve of the browser's own compressor node, with no knee, as the pulses are played through it. */
const curveOf = (settings: { ratio: number; thresholdDb: number }) =>
  webAudioCompressorOutput({ ...settings, kneeDb: 0 });

/**
 * The ratio that puts the curve at `outputDb` for the loudest input. The node
 * adds a make-up gain that depends on the ratio, so the level it ends at is
 * not a plain function to invert: it falls as the ratio rises, so bisect.
 */
function ratioFor(thresholdDb: number, outputDb: number) {
  let low = 1;
  let high = MAX_RATIO;
  for (let step = 0; step < BISECTION_STEPS; step += 1) {
    const middle = (low + high) / 2;
    if (curveOf({ ratio: middle, thresholdDb })(MAX_DB) > outputDb) {
      low = middle;
    } else {
      high = middle;
    }
  }
  return (low + high) / 2;
}

/**
 * A compressor's transfer curve, the one of the node that plays: one handle
 * sets the threshold, the other how hard the loudest level is held down (the
 * ratio). Below the threshold the line sits above the dashed unity line by the
 * make-up gain the node adds on its own.
 */
export function PlotCompressorDemo() {
  const [compressor, setCompressor] = useState({ ratio: 4, thresholdDb: -24 });
  const output = useMemo(() => curveOf(compressor), [compressor]);
  const loudest = output(MAX_DB);
  const makeupDb = output(MIN_DB) - MIN_DB;
  const { level, playing, toggle } = usePulsesThroughCompressor(compressor);
  // The input level, riding the curve: where the signal is right now.
  const dot = useMemo(
    () => ({
      getSnapshot: () => {
        const x = level.getSnapshot();
        return { x, y: output(x) };
      },
      subscribe: level.subscribe,
    }),
    [level, output]
  );
  const changeThreshold = useCallback(
    ({ x }: { x: number }) =>
      setCompressor((current) => ({ ...current, thresholdDb: x })),
    []
  );
  const changeRatio = useCallback(
    ({ y }: { y: number }) =>
      setCompressor((current) => ({
        ...current,
        ratio: ratioFor(current.thresholdDb, y),
      })),
    []
  );

  return (
    <div className="flex w-full flex-col gap-2">
      <Plot
        className="h-40"
        maxX={PLOT_MAX_DB}
        maxY={PLOT_MAX_DB}
        minX={MIN_DB}
        minY={MIN_DB}
      >
        <PlotGrid x={{ ticks: DB_TICKS }} y={{ ticks: DB_TICKS.slice(0, 2) }} />
        <PlotGraph>
          <PlotCurve
            className="stroke-border [stroke-dasharray:4_4]"
            fn={UNITY}
          />
          <PlotLine
            axis="x"
            className="stroke-foreground/40"
            value={compressor.thresholdDb}
          />
          <PlotCurve fn={output} />
        </PlotGraph>
        <PlotCursor format={describePointer} />
        <PlotDot source={dot} />
        <PlotHandle
          aria-label="Threshold"
          axis="x"
          onValueChange={changeThreshold}
          value={{
            x: compressor.thresholdDb,
            y: output(compressor.thresholdDb),
          }}
        />
        <PlotHandle
          aria-label="Ratio"
          axis="y"
          onValueChange={changeRatio}
          value={{ x: MAX_DB, y: loudest }}
        />
      </Plot>
      <div className="flex items-center justify-between gap-2">
        <Button
          aria-label={
            playing
              ? "Stop the signal"
              : "Play a pulsing signal through the compressor"
          }
          onClick={toggle}
          size="icon-sm"
          variant="outline"
        >
          {playing ? (
            <IconPlaceholder
              hugeicons="PauseIcon"
              lucide="PauseIcon"
              phosphor="PauseIcon"
              remixicon="RiPauseLine"
              tabler="IconPlayerPause"
            />
          ) : (
            <IconPlaceholder
              hugeicons="PlayIcon"
              lucide="PlayIcon"
              phosphor="PlayIcon"
              remixicon="RiPlayLine"
              tabler="IconPlayerPlay"
            />
          )}
        </Button>
        <output className="font-mono text-muted-foreground text-xs">
          Threshold {compressor.thresholdDb.toFixed(0)} dB,{" "}
          {compressor.ratio.toFixed(1)}:1, make-up +{makeupDb.toFixed(1)} dB
        </output>
      </div>
    </div>
  );
}
```

## API reference

### Root

Groups the parts of a plot and owns its two axes: the range of each and how values spread along it, evenly or logarithmically, the way frequency is drawn. Everything placed on the plot (curves, lines, bands, handles) is placed in the axes' units. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `disabled` | `boolean` |  |  |
| `maxX` | `number` | `1` |  |
| `maxY` | `number` | `1` |  |
| `minX` | `number` | `0` |  |
| `minY` | `number` | `0` |  |
| `scaleX` | `Scale` | `"linear"` | How x values spread across: `"log"` for frequency, or your own `{ toPosition, toValue }`. |
| `scaleY` | `Scale` | `"linear"` | How y values spread up. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### Band

A stretch of the plot between two values on one axis, across the whole of the other: the frequencies a sweep can reach, the levels over a threshold. Sits in `Plot.Graph`. Renders a `<rect>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `axis` | `"x" \| "y"` |  | The axis `from` and `to` are values on. |
| `from` | `number` |  |  |
| `to` | `number` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### Dot

A point on the plot that nothing drags: where the signal is right now, such as the input level on a compressor's curve. Give it a `source` and it moves straight in the DOM without re-rendering React, or a `value` for a still point. Hidden from assistive technology and from the pointer. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `DotSource` |  | A live point, followed without re-rendering. Takes precedence over `value`. |
| `value` | `Point` |  | A still point, in the axes' units. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### Cursor

Follows the pointer over the plot, for crosshair lines and a readout of what is under it. It covers the plot, lets pointer events through, and sets `--plot-cursor-x` and `--plot-cursor-y` (0% to 100% across and down) and `data-active` while the pointer is over the plot with a mouse or a pen, all in the DOM without re-rendering React. Put lines in it that use the two variables, and a `Plot.CursorReadout`. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### CursorReadout

The value under the pointer, as text: `format` turns it into what to show, and the text is written straight into the DOM as the pointer moves. It sits where the pointer is; set its offset and its look with your own classes. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `format` | `(value: Point) => string` |  | Turns the value under the pointer, in the axes' units, into the text shown. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### Grid

Grid lines and their labels, drawn from the axes: round values along each axis you give, so you do not place each line and label by hand. It covers the plot, is hidden from assistive technology and from the pointer, and marks its parts for styling: `data-part="line"` and `data-part="label"`, each with `data-axis`, and `data-zero` on the line at zero. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `x` | `GridAxis` |  | Grid lines across, at values on the x axis. Leave it out for none. |
| `y` | `GridAxis` |  | Grid lines along, at values on the y axis. Leave it out for none. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### Handle

A point on the plot that can be moved: an equalizer band at its frequency and gain, a filter's cutoff and resonance, a compressor's threshold. Its value is in the axes' units, and `axis` can hold it to one of them. Dragging moves it from where it is, never jumping to the pointer, four times finer with Shift. A double click, an Alt + click or the Delete key return it to `defaultValue`, or the middle of the plot. It holds a native `<input type="range">` per axis it moves along, carrying the accessible names: the first takes focus, and the arrow keys move it by a hundredth of the axis, a tenth with Shift. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `axis` | `"both" \| Axis` | `"both"` | The axes the handle moves along; the other keeps the value given. |
| `defaultValue` | `Value` |  | Uncontrolled starting value. Defaults to the middle of the plot. |
| `disabled` | `boolean` |  |  |
| `getAriaLabel` | `(axis: Axis) => string` |  | Names each axis input. Defaults to `aria-label`, followed by "X" / "Y" when the handle moves along both. |
| `getAriaValueText` | `(value: number, axis: Axis) => string` |  | Returns an axis input's `aria-valuetext`, from its value. Defaults to the number. |
| `onValueChange` | `(value: Value, eventDetails: ChangeEventDetails) => void` |  | Fires while the handle is moved: drag, keyboard and input changes. |
| `onValueCommitted` | `(value: Value, eventDetails: CommitEventDetails) => void` |  | Fires when a gesture that moved the handle ends, and after each key or input change. |
| `value` | `Value` |  | The handle's position, in the axes' units. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging the handle. |

### Graph

The drawing surface: an `<svg>` covering the plot, stretched to its size, that `Plot.Curve`, `Plot.Line` and `Plot.Band` draw in. Strokes keep their width however the plot is stretched. Hidden from assistive technology and from the pointer. Renders a `<svg>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### Trace

A live line across the plot: the list of y values a `source` holds, such as the spectrum of the sound, spread evenly across the x axis. It is drawn straight in the DOM each time the source changes, without re-rendering React, so it can follow audio frame by frame. With `fillTo`, it closes down to that y value to be filled as an area. Sits in `Plot.Graph`. Renders a `<path>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `fillTo` | `number` |  | Closes the line to this y value, so it can be filled as an area. |
| `max` | `number` |  | Top of the scale the values are on. Defaults to the plot's y axis. |
| `min` | `number` |  | Bottom of the scale the values are on. Defaults to the plot's y axis. |
| `source` | `TraceSource` |  | The live y values; the snapshot may be one array updated in place. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### Curve

A curve across the whole plot: `y = fn(x)` in the axes' units, such as an equalizer's response in dB at each frequency. With `fillTo`, it closes down to that y value, to be filled as an area. Sits in `Plot.Graph`. Renders a `<path>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `fillTo` | `number` |  | Closes the curve to this y value, so it can be filled as an area. |
| `fn` | `(x: number) => number` |  | The curve: y, in the y axis' units, at each x. |
| `samples` | `number` | `256` | How many points it starts from, evenly along the x axis. It adds more where the curve bends tighter than that shows, such as a corner or a knee. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### Line

A straight line across the plot at a value: vertical at an x value, such as a filter's cutoff, or horizontal at a y value, such as 0 dB. Sits in `Plot.Graph`. Renders a `<line>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `axis` | `"x" \| "y"` |  | `"x"` draws a vertical line at an x value, `"y"` a horizontal one at a y value. |
| `value` | `number` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### Mark

A label at a value on one axis, such as "1k" under a frequency or "+12" by a gain: placed at the value along the axis and left to you to set apart from the plot's edge. Hidden from assistive technology, which reads the handles' own values. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `axis` | `"x" \| "y"` |  | The axis `value` is on: `"x"` places it across, `"y"` down. |
| `value` | `number` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |

### Registry component

`PlotCursor` takes a `format` function, `(value) => string`, for its readout; `PlotTraceArea` is a `Trace` drawn as a filled area. The rest take the primitive's props.
