// A form generated from a parameter spec, reusable across instruments.
export interface ParamSpec
{
key: keyof P & string;
label: string;
kind: 'number' | 'int' | 'select' | 'text';
unit?: string;
scale?: number; // displayed value = stored value * scale
options?: [string, string][]; // [value, label]
help?: string;
min?: number;
show?: (p: P) => boolean;
}
export interface Section
{
title: string;
params: ParamSpec
[];
}
export interface Controls
{
get(): P;
set(p: P): void;
}
export function renderControls
(
root: HTMLElement,
sections: Section
[],
initial: P,
onChange: (p: P) => void,
): Controls
{
let current = { ...initial };
const inputs = new Map; el: HTMLInputElement | HTMLSelectElement; row: HTMLElement }>();
root.replaceChildren();
for (const sec of sections) {
const fs = document.createElement('fieldset');
const lg = document.createElement('legend');
lg.textContent = sec.title;
fs.append(lg);
for (const spec of sec.params) {
const row = document.createElement('label');
row.className = 'ctl';
const name = document.createElement('span');
name.className = 'ctl-name';
name.textContent = spec.label;
if (spec.help) row.title = spec.help;
let el: HTMLInputElement | HTMLSelectElement;
if (spec.kind === 'select') {
el = document.createElement('select');
for (const [v, l] of spec.options ?? []) {
const o = document.createElement('option');
o.value = v;
o.textContent = l;
el.append(o);
}
} else {
el = document.createElement('input');
if (spec.kind === 'text') el.type = 'text';
else {
el.type = 'number';
el.step = spec.kind === 'int' ? '1' : 'any';
if (spec.min !== undefined) el.min = String(spec.min);
}
}
el.addEventListener('change', () => {
current = read();
refreshVisibility();
onChange(current);
});
row.append(name, el);
if (spec.unit) {
const u = document.createElement('span');
u.className = 'ctl-unit';
u.textContent = spec.unit;
row.append(u);
}
fs.append(row);
inputs.set(spec.key, { spec, el, row });
}
root.append(fs);
}
const fmt = (v: number) => {
if (v === 0) return '0';
const a = Math.abs(v);
return a >= 1e5 || a < 1e-3 ? v.toExponential() : String(Number(v.toPrecision(10)));
};
function write(p: P) {
for (const { spec, el } of inputs.values()) {
const v = (p as Record)[spec.key];
el.value = typeof v === 'number' ? fmt(v * (spec.scale ?? 1)) : String(v);
}
}
function read(): P {
const p = { ...current } as Record;
for (const { spec, el } of inputs.values()) {
if (spec.kind === 'select' || spec.kind === 'text') {
const v = el.value;
p[spec.key] = spec.kind === 'select' && typeof p[spec.key] === 'number' ? Number(v) : v;
} else {
let v = Number(el.value) / (spec.scale ?? 1);
if (spec.kind === 'int') v = Math.round(v);
if (Number.isFinite(v)) p[spec.key] = v;
}
}
return p as P;
}
function refreshVisibility() {
for (const { spec, row } of inputs.values()) row.hidden = spec.show ? !spec.show(current) : false;
}
write(current);
refreshVisibility();
return {
get: () => (current = read()),
set(p: P) {
current = { ...p };
write(current);
refreshVisibility();
onChange(current);
},
};
}