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:
- Checkbox, Select, Slider, and Folder
- Graph and Canvas
- Button for reset and share actions
- Persistence and URL sharing
- The intermediate/final distinction, to gate an expensive lookup-table rebuild
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 bothpersistPanelandsyncPanelToURLto work. threshold's subscriber only rebuilds the lookup table whendetails.final && !details.initial— it reacts to every drag update for live preview, but reserves the expensive rebuild for a completed, non-initial change.reset.onPresscallsreset()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.