// Controls generated from a scene's param schema.
//
// Nothing here knows about any specific scene. That is the point: a new scene
// gets a full editing UI for free, which is what makes "auto with override"
// affordable across a library of thirty. Adding a hand-written control for a
// scene would be a bug, not a feature.
const FORMATTERS = {
float: (v) => (typeof v === 'number' ? v.toFixed(2) : '—'),
int: (v) => String(v),
bool: (v) => (v ? 'on' : 'off'),
vec2: (v) => (Array.isArray(v) ? `${v[0].toFixed(2)}, ${v[1].toFixed(2)}` : '—'),
};
export class ParamPanel {
/**
* @param {HTMLElement} container
* @param {(name: string, value: any) => void} onChange
*/
constructor(container, onChange) {
this.container = container;
this.onChange = onChange;
this.controls = new Map();
this.module = null;
}
/** Rebuild for a scene module and its current values. */
build(module, values) {
this.container.innerHTML = '';
this.controls.clear();
this.module = module;
if (!module) return;
const heading = document.createElement('div');
heading.className = 'pp-heading';
heading.innerHTML = `${module.name}
${module.family}`;
this.container.appendChild(heading);
for (const [name, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') continue; // edited in the look tab
const control = this._buildControl(name, def, values[name]);
if (control) {
this.container.appendChild(control.element);
this.controls.set(name, control);
}
}
const reactive = module.reactive || {};
if (Object.keys(reactive).length) {
const note = document.createElement('div');
note.className = 'pp-reactive';
note.innerHTML = '
audio-driven
' +
Object.entries(reactive)
.map(([p, r]) => `${p}` +
`${r.feature}` +
`${r.amount > 0 ? '+' : ''}${r.amount}
`)
.join('');
this.container.appendChild(note);
}
}
_buildControl(name, def, value) {
const element = document.createElement('div');
element.className = 'pp-row';
const label = document.createElement('label');
label.className = 'pp-label';
label.textContent = name;
const readout = document.createElement('span');
readout.className = 'pp-value';
let input;
if (def.type === 'bool') {
input = document.createElement('input');
input.type = 'checkbox';
input.checked = !!value;
input.addEventListener('change', () => {
readout.textContent = FORMATTERS.bool(input.checked);
this.onChange(name, input.checked);
});
} else {
const [lo, hi] = def.range || [0, 1];
input = document.createElement('input');
input.type = 'range';
input.min = String(lo);
input.max = String(hi);
input.step = def.type === 'int' ? '1' : String((hi - lo) / 200);
input.value = String(def.type === 'vec2' ? (value ? value[0] : lo) : value ?? lo);
input.addEventListener('input', () => {
const v = def.type === 'int' ? Math.round(+input.value) : +input.value;
const out = def.type === 'vec2' ? [v, v] : v;
readout.textContent = (FORMATTERS[def.type] || FORMATTERS.float)(out);
this.onChange(name, out);
});
}
input.className = 'pp-input';
readout.textContent = (FORMATTERS[def.type] || FORMATTERS.float)(value);
element.append(label, input, readout);
return { element, input, readout, def };
}
/** Push new values into the controls without emitting change events. */
update(values) {
for (const [name, control] of this.controls) {
const v = values[name];
if (v === undefined) continue;
if (control.def.type === 'bool') control.input.checked = !!v;
else control.input.value = String(control.def.type === 'vec2' ? v[0] : v);
control.readout.textContent =
(FORMATTERS[control.def.type] || FORMATTERS.float)(v);
}
}
/** Names of every control currently rendered. Used by the Phase 2 gate. */
controlNames() {
return [...this.controls.keys()];
}
}