// 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); }, }; }