@nakednous/ui
@nakednous/ui
Parameter binding panels, animation transport controls, and 6-DOF helm profile editors — zero dependencies, pure vanilla DOM.
Installation
npm install @nakednous/ui
import { createPanel } from '@nakednous/ui'
Architecture
@nakednous/ui is the DOM layer of a three-package stack. It knows nothing about renderers or p5 — it mounts into any HTMLElement.
application
│
▼
p5.tree.js ← bridge: wires tree + ui into p5.js v2
│
├── @nakednous/ui ← this package: param panels, transport controls
│
└── @nakednous/tree ← math, spaces, animation, visibility
The target contract is minimal: a plain function (name, value) => ... or an object with .set(name, value). Nothing renderer-specific. Shader wiring (setUniform) is handled by the p5.tree bridge, not here.
createPanel
The single public export. Dispatches on the first argument:
// track (has .play) → transport panel
createPanel(track, opt)
// helm (has .feed) → helm profile panel
createPanel(helm, opt)
// plain schema object → parameter binding panel
createPanel(schema, opt)
Dispatch order is .play ⇒ track, else .feed ⇒ helm, else schema. Schema objects are plain config bags — none will ever have .play or .feed.
Parameter binding panel
Binds named schema keys to DOM controls. Each binding maps a parameter name to a control, and the control to a target sink.
import { createPanel } from '@nakednous/ui'
const panel = createPanel({
speed: { min: 0, max: 0.05, value: 0.012, step: 0.001 },
shininess: { min: 1, max: 200, value: 80, step: 1, type: 'int' },
showGrid: { value: true },
tint: { value: '#ff8844' },
fxOrder: { type: 'select', options: [
{ label: 'noise → dof', value: '1' },
{ label: 'dof → noise', value: '2' }
], value: '1' }
}, { x: 10, y: 10, width: 160, labels: true, title: 'Scene', color: 'white' })
// call every frame
panel.tick()
Type inference
| Schema value | Control |
|---|---|
number |
slider |
boolean |
checkbox |
| CSS color string | color picker |
| array length 2–4 | vec2/3/4 sliders |
options array |
dropdown |
onClick function |
button |
Override with { type: 'int', ... }.
Target
// plain function — called (name, value) each tick for dirty bindings
createPanel(schema, { target: (name, value) => myObj[name] = value })
// object with .set
createPanel(schema, { target: myObject }) // myObject.set(name, value)
// omitted — read manually
panel.speed.value()
panel.speed.set(0.02)
panel.speed.reset()
panel.speed.visible = false
Avoid direct DOM access. Attaching listeners to internal elements (ui.on.el.firstChild.addEventListener(...)) couples your code to the panel's internal structure. Use target instead:
// fragile — do not use
ui.on.el.firstChild.addEventListener('change', () => { showGrid = ui.on.value() })
// correct
createPanel({ on: { value: true } }, {
target: (name, value) => { showGrid = value }
})
// target can also call any function and ignore its arguments
createPanel({ on: { value: false } }, {
target: () => syncFxPanels()
})
Tick model
tick() pushes dirty bindings to target at most once per binding per frame. The first tick always pushes all bindings to initialise target state. Multiple interactions within a single frame collapse to one push at tick() time — correct for rendering sinks (shaders, scene params).
Per-binding API
panel.speed.value() // current value
panel.speed.set(0.02) // set programmatically — marks dirty, pushed on next tick
panel.speed.reset() // restore initial value — marks dirty
panel.speed.visible = false
panel.speed.el // raw HTMLElement(s)
Panel API
panel.el // HTMLElement container
panel.visible // get/set boolean — whole panel
panel.collapsed // get/set boolean — body visibility (requires collapsible + title)
panel.each(fn) // iterate bindings: fn(name, binding)
panel.elts() // flat array of all bound DOM elements
panel.reset() // reset all bindings
panel.parent(el) // re-mount into a new HTMLElement
panel.tick() // push dirty bindings — call once per frame
panel.dispose() // remove from DOM
Layout options
| Option | Default | Description |
|---|---|---|
target |
— | Value sink: (name,val)=>... or {set}. |
x |
0 |
Container left (px). |
y |
0 |
Container top (px). |
width |
120 |
Default slider/select width (px). |
offset |
6 |
Vertical gap between rows (px). |
labels |
false |
Show per-binding labels. |
title |
— | Bold title row. |
collapsible |
false |
Title row becomes a collapse toggle. |
collapsed |
false |
Start collapsed (implies collapsible). |
color |
— | Container text color. |
hidden |
false |
Start hidden. |
parent |
document.body |
Mount target (HTMLElement). |
Transport panel
Controls playback of any track-compatible target. Duck-typed: the target needs play, stop, seek, time, and playing.
import { createPanel } from '@nakednous/ui'
const ui = createPanel(track, {
x: 10, y: 10, width: 170,
loop: false, rate: 1,
seek: true, props: true, info: true,
color: 'white'
})
// call every frame
ui.tick()
Target contract
| Member | Required | Description |
|---|---|---|
play(opts?) |
✓ | Start or update playback. |
stop() |
✓ | Stop playback. |
seek(t) |
✓ | Set normalised position [0, 1]. |
time() |
✓ | Returns normalised position [0, 1]. |
playing |
✓ | Boolean — true while playing. |
loop |
✓ | Boolean — read at panel creation time. |
bounce |
✓ | Boolean — read at panel creation time. |
rate |
✓ | Number — read at panel creation time. |
_onPlay |
✓ | Lib-space hook — assigned by this panel. |
_onEnd |
✓ | Lib-space hook — assigned by this panel. |
_onStop |
✓ | Lib-space hook — assigned by this panel. |
add(depth?) |
optional | Add a keyframe. Enables + (unless add: false). |
reset() |
optional | Clear all keyframes. Enables ↺ (unless reset: false). |
info() |
optional | Returns { keyframes, segments, ... }. |
Transport model
The Play/Pause button is the sole control that starts or stops playback. The rate slider adjusts speed without starting or stopping. The seek slider scrubs position without affecting playing. The loop and bounce checkboxes change looping behaviour without starting playback.
Loop modes — loop and bounce are fully independent:
| loop | bounce | behaviour |
|---|---|---|
| ☐ | ☐ | play once — stop at end |
| ☐ | repeat — wrap back to start | |
| bounce forever at boundaries | ||
| ☐ | bounce once — flip at far boundary, stop at origin |
Both checkboxes are always visible and independent of each other.
State initialisation
rate is seeded once at creation from the live track state (falling back to opt.rate) and is fully UI-owned thereafter — the panel never reads rate back from the track. loop and bounce are seeded the same way and additionally polled from the track every tick() while playing, so external play() calls are always reflected:
// play before createPanel — panel opens with correct state
track.play({ loop: true })
createPanel(track, ...) // loop checkbox checked ✓
// createPanel before play — polled on next tick while playing
createPanel(track, ...)
track.play({ bounce: true }) // bounce checkbox checked ✓
Layout (top → bottom)
Title row — optional, becomes collapse toggle when collapsible=true
[ + ] [ ▶/⏸ ] [ ↺ ] — add / play-pause / reset
depth: ────────────── — placement depth (0 = near, 1 = far)
seek: ────────────── — scrub position [0, 1]
rate: ────────────── — signed speed (negative reverses)
loop: [ ☐ ] bounce: [ ☐ ] — independent checkboxes, always visible
t: 0.412 seg 1/3 kf 4 — info readout
Transport options
| Option | Default | Description |
|---|---|---|
seek |
true |
Show seek slider. |
props |
true |
Show rate slider + loop controls. |
info |
false |
Show time/keyframe readout. |
play |
true |
Show play/stop button. false suppresses it. |
add |
true |
Show + when the target exposes add(). false suppresses it (and the depth slider). |
reset |
true |
Show ↺ when the target exposes reset(). false suppresses it. |
rate |
target.rate |
Initial rate (seeded once; UI-owned after creation). |
loop |
target.loop |
Initial loop state (seeded from live track; polled while playing). |
bounce |
target.bounce |
Initial bounce state (seeded from live track; polled while playing). |
depth |
0.5 |
Initial add-pose depth [0..1]. |
title |
— | Optional title row. |
collapsible |
false |
Title row becomes a collapse toggle. |
collapsed |
false |
Start collapsed (implies collapsible). |
x |
0 |
Container left (px). |
y |
0 |
Container top (px). |
width |
120 |
Slider width (px). |
rateWidth |
width |
Rate slider width override (px). |
depthWidth |
width |
Depth slider width override (px). |
color |
— | Container text color. |
hidden |
false |
Start hidden. |
parent |
document.body |
Mount target (HTMLElement). |
Panel API
ui.el // HTMLElement container
ui.visible // get/set boolean
ui.collapsed // get/set boolean (requires collapsible + title)
ui.parent(el) // re-mount into a new HTMLElement
ui.tick() // sync seek slider, play button, enabled state — call every frame
ui.dispose() // remove DOM and clear lib-space hooks
Helm panel
Edits a PoseHelm's 6-DOF profile and reflects its live activity. Duck-typed: the target needs .feed and a .profile with the six { sign, sens, lane } channels. Like the transport panel it is push and pull — each tick() writes any edited field to the profile and reads helm.activity() back to drive a per-DOF meter.
import { createPanel } from '@nakednous/ui'
const ui = createPanel(helm, {
frame: true, // pose helms only — adds the EYE / WORLD / SELF selector
inline: true, // flow in document order instead of an absolute float
onChange: () => regenerate(), // fired after any user edit
width: 132, color: 'white'
})
// call every frame
ui.tick()
Controls
One signed slider per DOF spans −max … +max with 0 at centre: distance from centre is sens, the side is sign, and dragging through 0 flips the sign — one control, no separate sign toggle. At 0 the DOF is muted and its lane cycle-button disables (a muted DOF routes no channel). Each row carries a thin bipolar activity meter, filled from centre by tick() from helm.activity(), normalised against helm.fullScale. A global deadzone slider sits below the six rows; when helm.filter is set, two conditioning sliders — minCutoff and beta — appear beside it.
frame
{ frame: true } adds an EYE / WORLD / SELF selector that writes helm.from. A camera helm and a pose helm are the same object structurally, so the selector is opt-in — omitted (the default), no frame row appears and helm.from keeps its EYE default. A mat4 frame is a live matrix, not a panel choice, and stays code-set.
Conditioning
When the helm carries an input filter (helm.filter = oneEuro(...)), the panel grows minCutoff and beta sliders next to the deadzone — the 1€ tuning surface. They write straight to helm.filter.minCutoff / .beta; hidden while no filter is attached, revealed and seeded from the live filter when one is. The filter and the deadzone are independent: the filter smooths jitter, the deadzone zeroes rest bias.
onChange / inline
onChange() fires after any user edit (slider, lane, deadzone, frame), so a sketch can react — regenerate a profile-out block, mark state dirty — without polling each frame. Writes that bypass the panel (a device calibration sweep setting lane directly) do not fire it; call onChange yourself there. inline: true flows the panel in normal document order instead of the default absolute float, for mounting it as a section inside an existing layout via parent; x / y are ignored when inline.
Layout options
| Option | Default | Description |
|---|---|---|
frame |
false |
Show the EYE / WORLD / SELF selector (pose helms only). |
onChange |
— | Called after any user edit of the profile. |
inline |
false |
Flow in document order instead of an absolute float. |
x / y |
0 |
Container position (px) — ignored when inline. |
width |
130 |
Signed-slider width (px). |
color |
— | Container text color (meters inherit it). |
title |
— | Optional bold title row. |
hidden |
false |
Start hidden. |
parent |
document.body |
Mount target (HTMLElement). |
Panel API
ui.el // HTMLElement container
ui.visible // get/set boolean
ui.parent(el) // re-mount into a new HTMLElement
ui.tick() // write edits, drive activity meters — call every frame
ui.dispose() // remove from DOM
Collapsible panels
Any panel with title set can be made collapsible. Clicking anywhere on the title row toggles the content area.
// explicit
createPanel(schema, { title: 'Noise', collapsible: true })
// start collapsed — implies collapsible
createPanel(track, { title: 'Camera path', collapsed: true })
Programmatic control:
panel.collapsed = true
panel.collapsed = false
License
AGPL-3.0-only
JP Charalambos