1// A form generated from a parameter spec, reusable across instruments.
3export interface ParamSpec<P> {
4 key: keyof P & string;
5 label: string;
6 kind: 'number' | 'int' | 'select' | 'text';
7 unit?: string;
8 scale?: number; // displayed value = stored value * scale
9 options?: [string, string][]; // [value, label]
10 help?: string;
11 min?: number;
12 show?: (p: P) => boolean;
13}
15export interface Section<P> {
16 title: string;
17 params: ParamSpec<P>[];
18}
20export interface Controls<P> {
21 get(): P;
22 set(p: P): void;
23}
25export function renderControls<P extends object>(
26 root: HTMLElement,
27 sections: Section<P>[],
28 initial: P,
29 onChange: (p: P) => void,
30): Controls<P> {
31 let current = { ...initial };
32 const inputs = new Map<string, { spec: ParamSpec<P>; el: HTMLInputElement | HTMLSelectElement; row: HTMLElement }>();
33 root.replaceChildren();
34 for (const sec of sections) {
35 const fs = document.createElement('fieldset');
36 const lg = document.createElement('legend');
37 lg.textContent = sec.title;
38 fs.append(lg);
39 for (const spec of sec.params) {
40 const row = document.createElement('label');
41 row.className = 'ctl';
42 const name = document.createElement('span');
43 name.className = 'ctl-name';
44 name.textContent = spec.label;
45 if (spec.help) row.title = spec.help;
46 let el: HTMLInputElement | HTMLSelectElement;
47 if (spec.kind === 'select') {
48 el = document.createElement('select');
49 for (const [v, l] of spec.options ?? []) {
50 const o = document.createElement('option');
51 o.value = v;
52 o.textContent = l;
53 el.append(o);
54 }
55 } else {
56 el = document.createElement('input');
57 if (spec.kind === 'text') el.type = 'text';
58 else {
59 el.type = 'number';
60 el.step = spec.kind === 'int' ? '1' : 'any';
61 if (spec.min !== undefined) el.min = String(spec.min);
62 }
63 }
64 el.addEventListener('change', () => {
65 current = read();
66 refreshVisibility();
67 onChange(current);
68 });
69 row.append(name, el);
70 if (spec.unit) {
71 const u = document.createElement('span');
72 u.className = 'ctl-unit';
73 u.textContent = spec.unit;
74 row.append(u);
75 }
76 fs.append(row);
77 inputs.set(spec.key, { spec, el, row });
78 }
79 root.append(fs);
80 }
82 const fmt = (v: number) => {
83 if (v === 0) return '0';
84 const a = Math.abs(v);
85 return a >= 1e5 || a < 1e-3 ? v.toExponential() : String(Number(v.toPrecision(10)));
86 };
88 function write(p: P) {
89 for (const { spec, el } of inputs.values()) {
90 const v = (p as Record<string, unknown>)[spec.key];
91 el.value = typeof v === 'number' ? fmt(v * (spec.scale ?? 1)) : String(v);
92 }
93 }
95 function read(): P {
96 const p = { ...current } as Record<string, unknown>;
97 for (const { spec, el } of inputs.values()) {
98 if (spec.kind === 'select' || spec.kind === 'text') {
99 const v = el.value;
100 p[spec.key] = spec.kind === 'select' && typeof p[spec.key] === 'number' ? Number(v) : v;
101 } else {
102 let v = Number(el.value) / (spec.scale ?? 1);
103 if (spec.kind === 'int') v = Math.round(v);
104 if (Number.isFinite(v)) p[spec.key] = v;
105 }
106 }
107 return p as P;
108 }
110 function refreshVisibility() {
111 for (const { spec, row } of inputs.values()) row.hidden = spec.show ? !spec.show(current) : false;
112 }
114 write(current);
115 refreshVisibility();
116 return {
117 get: () => (current = read()),
118 set(p: P) {
119 current = { ...p };
120 write(current);
121 refreshVisibility();
122 onChange(current);
123 },
124 };
125}