/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
cymbalsim / src / ui / form.ts
232 lines · 8.2 KBCodeBlameHistory
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 1// A parameter form for SimParams: groups of numeric fields in convenient
2// units, checkboxes, and selects, each group a folding <details> with an
3// optional folded "Notes" paragraph that the host fills in. The values are
4// kept in SI; the field specs say how they are displayed. No styling is done
5// here beyond class names (.pform, .pform-group, .pform-row, .pform-notes).
7import { type SimParams } from "../params.ts";
9export interface FieldSpec {
10 key: keyof SimParams;
11 label: string;
12 unit?: string; // shown after the input
13 scale?: number; // displayed value = SI value / scale (e.g. mm: 1e-3, g: 1e-3); default 1
14 step?: number; // input step in displayed units
15 min?: number; // in displayed units
16 max?: number; // in displayed units
17 live?: boolean; // may be changed while a run is in progress (host decides what to do)
18 kind?: "number" | "checkbox" | "select";
19 options?: { value: number | boolean; label: string }[]; // for selects
22export interface GroupSpec {
23 title: string;
24 fields: FieldSpec[];
25 /** Reserve a folded "Notes" paragraph under the group, filled by setNotes. */
26 notes?: boolean;
29export type ParamValue = number | boolean;
30export type ParamChange = (key: keyof SimParams, value: ParamValue, live: boolean) => void;
32type Values = Record<keyof SimParams, ParamValue>;
34interface Field {
35 spec: FieldSpec;
36 kind: "number" | "checkbox" | "select";
37 scale: number;
38 el: HTMLInputElement | HTMLSelectElement;
41/** A compact decimal rendering of a displayed value. */
42export function formatNumber(v: number): string {
43 if (!isFinite(v)) return "";
44 if (v === 0) return "0";
45 const a = Math.abs(v);
46 if (a >= 1e6 || a < 1e-4) return v.toExponential(3).replace(/\.?0+e/, "e");
47 return String(+v.toPrecision(7));
50export class ParamForm {
51 private readonly values: Values;
52 private readonly onChange: ParamChange;
53 private readonly fields = new Map<keyof SimParams, Field>();
54 private readonly notes = new Map<string, HTMLElement>();
56 constructor(container: HTMLElement, groups: GroupSpec[], initial: SimParams, onChange: ParamChange) {
57 this.values = { ...initial } as Values;
58 this.onChange = onChange;
59 container.classList.add("pform");
60 for (const g of groups) container.appendChild(this.buildGroup(g));
61 }
63 /** The current values in SI (a copy). */
64 get(): SimParams {
65 return { ...this.values } as unknown as SimParams;
66 }
68 /** Updates a value from the host and rewrites its input; onChange is not called. Unknown keys are ignored. */
69 set(key: keyof SimParams, value: ParamValue): void {
70 if (!(key in this.values)) return;
71 this.values[key] = value;
72 const f = this.fields.get(key);
73 if (f) this.render(f);
74 }
76 /** Fills the Notes paragraph of the group with that title (a group built with notes: true). */
77 setNotes(groupTitle: string, html: string): void {
78 const p = this.notes.get(groupTitle);
79 if (p) p.innerHTML = html;
80 }
82 /** Disables the fields that are not marked live while a run is in progress. */
83 setLocked(locked: boolean): void {
84 for (const f of this.fields.values()) if (!f.spec.live) f.el.disabled = locked;
85 }
87 /** Applies URL query parameters: any SimParams key given in SI units (booleans as 0/1/true/false); returns the keys applied. */
88 applyUrl(params: URLSearchParams): (keyof SimParams)[] {
89 const applied: (keyof SimParams)[] = [];
90 for (const [k, raw] of params) {
91 if (!(k in this.values)) continue;
92 const key = k as keyof SimParams;
93 const v = raw.trim().toLowerCase();
94 if (typeof this.values[key] === "boolean") {
95 if (v === "1" || v === "true") this.values[key] = true;
96 else if (v === "0" || v === "false") this.values[key] = false;
97 else continue;
98 } else {
99 const num = Number(v);
100 if (v === "" || !isFinite(num)) continue;
101 this.values[key] = num;
102 }
103 applied.push(key);
104 const f = this.fields.get(key);
105 if (f) this.render(f);
106 }
107 return applied;
108 }
110 private buildGroup(g: GroupSpec): HTMLElement {
111 const det = document.createElement("details");
112 det.className = "pform-group";
113 det.open = true;
114 const sum = document.createElement("summary");
115 sum.textContent = g.title;
116 det.appendChild(sum);
117 const rows = document.createElement("div");
118 rows.className = "pform-rows";
119 for (const spec of g.fields) rows.appendChild(this.buildRow(spec));
120 det.appendChild(rows);
121 if (g.notes) {
122 const fold = document.createElement("details");
123 fold.className = "pform-notes";
124 const s = document.createElement("summary");
125 s.textContent = "Notes";
126 const p = document.createElement("p");
127 fold.append(s, p);
128 det.appendChild(fold);
129 this.notes.set(g.title, p);
130 }
131 return det;
132 }
134 private buildRow(spec: FieldSpec): HTMLElement {
135 const kind = spec.kind ?? (spec.options ? "select" : typeof this.values[spec.key] === "boolean" ? "checkbox" : "number");
136 const row = document.createElement("div");
137 row.className = `pform-row pform-row-${kind}`;
138 const id = `pform-${String(spec.key)}`;
139 const label = document.createElement("label");
140 label.textContent = spec.label;
141 label.htmlFor = id;
143 let el: HTMLInputElement | HTMLSelectElement;
144 if (kind === "select") {
145 const sel = document.createElement("select");
146 (spec.options ?? []).forEach((opt, i) => {
147 const o = document.createElement("option");
148 o.value = String(i);
149 o.textContent = opt.label;
150 sel.appendChild(o);
151 });
152 el = sel;
153 } else {
154 const inp = document.createElement("input");
155 if (kind === "checkbox") {
156 inp.type = "checkbox";
157 } else {
158 inp.type = "number";
159 inp.step = spec.step !== undefined ? String(spec.step) : "any";
160 if (spec.min !== undefined) inp.min = String(spec.min);
161 if (spec.max !== undefined) inp.max = String(spec.max);
162 inp.inputMode = "decimal";
163 }
164 el = inp;
165 }
166 el.id = id;
167 if (spec.live) el.classList.add("pform-live");
168 const field: Field = { spec, kind, scale: spec.scale ?? 1, el };
169 this.fields.set(spec.key, field);
170 this.render(field);
172 el.addEventListener("change", () => this.commit(field));
173 if (kind === "number") {
174 (el as HTMLInputElement).addEventListener("keydown", (e: KeyboardEvent) => {
175 if (e.key === "Enter") {
176 e.preventDefault();
177 this.commit(field);
178 }
179 });
180 }
182 const control = document.createElement("div");
183 control.className = "pform-control";
184 control.appendChild(el);
185 if (spec.unit && kind !== "checkbox") {
186 const unit = document.createElement("span");
187 unit.className = "pform-unit";
188 unit.textContent = spec.unit;
189 control.appendChild(unit);
190 }
191 row.append(label, control);
192 return row;
193 }
195 /** Writes the stored value into the field's control. */
196 private render(f: Field): void {
197 const v = this.values[f.spec.key];
198 if (f.kind === "select") {
199 const i = (f.spec.options ?? []).findIndex((o) => o.value === v);
200 (f.el as HTMLSelectElement).value = i >= 0 ? String(i) : "";
201 } else if (f.kind === "checkbox") {
202 (f.el as HTMLInputElement).checked = v === true;
203 } else {
204 (f.el as HTMLInputElement).value = formatNumber((v as number) / f.scale);
205 }
206 }
208 /** Reads the control, stores the value (in SI) when valid, or restores the previous value, and tells the host. */
209 private commit(f: Field): void {
210 const key = f.spec.key;
211 const prev = this.values[key];
212 let next: ParamValue;
213 if (f.kind === "select") {
214 const opt = (f.spec.options ?? [])[Number((f.el as HTMLSelectElement).value)];
215 if (!opt) { this.render(f); return; }
216 next = opt.value;
217 } else if (f.kind === "checkbox") {
218 next = (f.el as HTMLInputElement).checked;
219 } else {
220 const inp = f.el as HTMLInputElement;
221 let d = inp.value.trim() === "" ? NaN : Number(inp.value);
222 if (!isFinite(d)) { this.render(f); return; }
223 if (f.spec.min !== undefined) d = Math.max(f.spec.min, d);
224 if (f.spec.max !== undefined) d = Math.min(f.spec.max, d);
225 next = d * f.scale;
226 }
227 if (next === prev) { this.render(f); return; }
228 this.values[key] = next;
229 this.render(f);
230 this.onChange(key, next, !!f.spec.live);
231 }
↑↓ move↵openescclose