Param Panel

Edge Detection Tool

A complete image-processing panel combining controls, folders, a graph, a canvas histogram, persistence, and URL sharing.

The most complete example in these docs. A single panel drives a live edge-detection pipeline: a toggle, an algorithm select, a threshold slider, an "Advanced" folder with two more sliders, an FPS graph, a live histogram canvas, reset and share buttons, localStorage persistence, and URL state syncing — all read from one render loop.

It combines:

Source

import { createPanel } from "param-panel";
import { persistPanel } from "param-panel/persistence";
import { syncPanelToURL } from "param-panel/url";

const pp = createPanel({
  title: "Edge Detection",
  position: "top-right",
  density: "compact",
});

const enabled = pp.checkbox({
  id: "enabled",
  label: "Enabled",
  value: true,
});

const algorithm = pp.select({
  id: "algorithm",
  label: "Algorithm",
  value: "scharr",
  options: [
    { label: "Sobel", value: "sobel" },
    { label: "Scharr", value: "scharr" },
    { label: "Laplacian", value: "laplacian" },
  ],
});

const threshold = pp.slider({
  id: "threshold",
  label: "Threshold",
  hint: "Minimum retained edge magnitude",
  value: 128,
  min: 0,
  max: 255,
  step: 1,
});

const advanced = pp.folder({
  id: "advanced",
  label: "Advanced",
  expanded: false,
});

const blurRadius = advanced.slider({
  id: "blur-radius",
  label: "Blur radius",
  value: 3,
  min: 0,
  max: 20,
  step: 1,
});

const gain = advanced.slider({
  id: "gain",
  label: "Edge gain",
  value: 2,
  min: 0,
  max: 10,
  step: 0.1,
});

const fps = pp.graph({
  label: "FPS",
  min: 0,
  max: 120,
  history: 180,
  height: 70,
});

const histogram = pp.canvas({
  label: "Histogram",
  height: 80,
  render: "invalidated",
  draw(ctx, size) {
    ctx.clearRect(0, 0, size.width, size.height);

    const max = Math.max(...histogramBins, 1);
    const barWidth = size.width / histogramBins.length;

    for (let i = 0; i < histogramBins.length; i++) {
      const height = (histogramBins[i] / max) * size.height;
      ctx.fillRect(
        i * barWidth,
        size.height - height,
        Math.ceil(barWidth),
        height,
      );
    }
  },
});

const reset = pp.button({ label: "Reset" });
const copyLink = pp.button({ label: "Copy share link" });

enabled.subscribe((value) => {
  processor.enabled = value;
});

algorithm.subscribe((value) => {
  processor.algorithm = value;
});

threshold.subscribe((value, details) => {
  processor.threshold = value;

  // Expensive rebuild only on a completed, non-initial change.
  if (details.final && !details.initial) {
    processor.rebuildLookupTable();
  }
});

blurRadius.subscribe((value) => {
  processor.blurRadius = value;
});

gain.subscribe((value) => {
  processor.gain = value;
});

reset.onPress(() => {
  enabled.reset();
  algorithm.reset();
  threshold.reset();
  blurRadius.reset();
  gain.reset();
});

const persistence = persistPanel(pp, { key: "edge-detector" });
const urlState = syncPanelToURL(pp, { parameter: "params" });

copyLink.onPress(async () => {
  await navigator.clipboard.writeText(urlState.getURL());
});

let previousFrameTime = performance.now();

function frame(now: number) {
  const delta = now - previousFrameTime;
  previousFrameTime = now;

  processor.process({
    enabled: enabled.get(),
    algorithm: algorithm.get(),
    threshold: threshold.get(),
    blurRadius: blurRadius.get(),
    gain: gain.get(),
  });

  fps.push(1000 / delta, now);

  updateHistogramBins();
  histogram.invalidate();

  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

Notable details

  • Every editable control has a stable id, which is what makes it serializable — required for both persistPanel and syncPanelToURL to work.
  • threshold's subscriber only rebuilds the lookup table when details.final && !details.initial — it reacts to every drag update for live preview, but reserves the expensive rebuild for a completed, non-initial change.
  • reset.onPress calls reset() on every value control, restoring each to its construction value in one action.
  • The canvas stays in "invalidated" mode (the default) — histogram.invalidate() is called once per frame from the render loop instead of drawing continuously.

On this page