// A parameter form for SimParams: groups of numeric fields in convenient // units, checkboxes, and selects, each group a folding
with an // optional folded "Notes" paragraph that the host fills in. The values are // kept in SI; the field specs say how they are displayed. No styling is done // here beyond class names (.pform, .pform-group, .pform-row, .pform-notes). import { type SimParams } from "../params.ts"; export interface FieldSpec { key: keyof SimParams; label: string; unit?: string; // shown after the input scale?: number; // displayed value = SI value / scale (e.g. mm: 1e-3, g: 1e-3); default 1 step?: number; // input step in displayed units min?: number; // in displayed units max?: number; // in displayed units live?: boolean; // may be changed while a run is in progress (host decides what to do) kind?: "number" | "checkbox" | "select"; options?: { value: number | boolean; label: string }[]; // for selects } export interface GroupSpec { title: string; fields: FieldSpec[]; /** Reserve a folded "Notes" paragraph under the group, filled by setNotes. */ notes?: boolean; } export type ParamValue = number | boolean; export type ParamChange = (key: keyof SimParams, value: ParamValue, live: boolean) => void; type Values = Record; interface Field { spec: FieldSpec; kind: "number" | "checkbox" | "select"; scale: number; el: HTMLInputElement | HTMLSelectElement; } /** A compact decimal rendering of a displayed value. */ export function formatNumber(v: number): string { if (!isFinite(v)) return ""; if (v === 0) return "0"; const a = Math.abs(v); if (a >= 1e6 || a < 1e-4) return v.toExponential(3).replace(/\.?0+e/, "e"); return String(+v.toPrecision(7)); } export class ParamForm { private readonly values: Values; private readonly onChange: ParamChange; private readonly fields = new Map(); private readonly notes = new Map(); constructor(container: HTMLElement, groups: GroupSpec[], initial: SimParams, onChange: ParamChange) { this.values = { ...initial } as Values; this.onChange = onChange; container.classList.add("pform"); for (const g of groups) container.appendChild(this.buildGroup(g)); } /** The current values in SI (a copy). */ get(): SimParams { return { ...this.values } as unknown as SimParams; } /** Updates a value from the host and rewrites its input; onChange is not called. Unknown keys are ignored. */ set(key: keyof SimParams, value: ParamValue): void { if (!(key in this.values)) return; this.values[key] = value; const f = this.fields.get(key); if (f) this.render(f); } /** Fills the Notes paragraph of the group with that title (a group built with notes: true). */ setNotes(groupTitle: string, html: string): void { const p = this.notes.get(groupTitle); if (p) p.innerHTML = html; } /** Disables the fields that are not marked live while a run is in progress. */ setLocked(locked: boolean): void { for (const f of this.fields.values()) if (!f.spec.live) f.el.disabled = locked; } /** Applies URL query parameters: any SimParams key given in SI units (booleans as 0/1/true/false); returns the keys applied. */ applyUrl(params: URLSearchParams): (keyof SimParams)[] { const applied: (keyof SimParams)[] = []; for (const [k, raw] of params) { if (!(k in this.values)) continue; const key = k as keyof SimParams; const v = raw.trim().toLowerCase(); if (typeof this.values[key] === "boolean") { if (v === "1" || v === "true") this.values[key] = true; else if (v === "0" || v === "false") this.values[key] = false; else continue; } else { const num = Number(v); if (v === "" || !isFinite(num)) continue; this.values[key] = num; } applied.push(key); const f = this.fields.get(key); if (f) this.render(f); } return applied; } private buildGroup(g: GroupSpec): HTMLElement { const det = document.createElement("details"); det.className = "pform-group"; det.open = true; const sum = document.createElement("summary"); sum.textContent = g.title; det.appendChild(sum); const rows = document.createElement("div"); rows.className = "pform-rows"; for (const spec of g.fields) rows.appendChild(this.buildRow(spec)); det.appendChild(rows); if (g.notes) { const fold = document.createElement("details"); fold.className = "pform-notes"; const s = document.createElement("summary"); s.textContent = "Notes"; const p = document.createElement("p"); fold.append(s, p); det.appendChild(fold); this.notes.set(g.title, p); } return det; } private buildRow(spec: FieldSpec): HTMLElement { const kind = spec.kind ?? (spec.options ? "select" : typeof this.values[spec.key] === "boolean" ? "checkbox" : "number"); const row = document.createElement("div"); row.className = `pform-row pform-row-${kind}`; const id = `pform-${String(spec.key)}`; const label = document.createElement("label"); label.textContent = spec.label; label.htmlFor = id; let el: HTMLInputElement | HTMLSelectElement; if (kind === "select") { const sel = document.createElement("select"); (spec.options ?? []).forEach((opt, i) => { const o = document.createElement("option"); o.value = String(i); o.textContent = opt.label; sel.appendChild(o); }); el = sel; } else { const inp = document.createElement("input"); if (kind === "checkbox") { inp.type = "checkbox"; } else { inp.type = "number"; inp.step = spec.step !== undefined ? String(spec.step) : "any"; if (spec.min !== undefined) inp.min = String(spec.min); if (spec.max !== undefined) inp.max = String(spec.max); inp.inputMode = "decimal"; } el = inp; } el.id = id; if (spec.live) el.classList.add("pform-live"); const field: Field = { spec, kind, scale: spec.scale ?? 1, el }; this.fields.set(spec.key, field); this.render(field); el.addEventListener("change", () => this.commit(field)); if (kind === "number") { (el as HTMLInputElement).addEventListener("keydown", (e: KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); this.commit(field); } }); } const control = document.createElement("div"); control.className = "pform-control"; control.appendChild(el); if (spec.unit && kind !== "checkbox") { const unit = document.createElement("span"); unit.className = "pform-unit"; unit.textContent = spec.unit; control.appendChild(unit); } row.append(label, control); return row; } /** Writes the stored value into the field's control. */ private render(f: Field): void { const v = this.values[f.spec.key]; if (f.kind === "select") { const i = (f.spec.options ?? []).findIndex((o) => o.value === v); (f.el as HTMLSelectElement).value = i >= 0 ? String(i) : ""; } else if (f.kind === "checkbox") { (f.el as HTMLInputElement).checked = v === true; } else { (f.el as HTMLInputElement).value = formatNumber((v as number) / f.scale); } } /** Reads the control, stores the value (in SI) when valid, or restores the previous value, and tells the host. */ private commit(f: Field): void { const key = f.spec.key; const prev = this.values[key]; let next: ParamValue; if (f.kind === "select") { const opt = (f.spec.options ?? [])[Number((f.el as HTMLSelectElement).value)]; if (!opt) { this.render(f); return; } next = opt.value; } else if (f.kind === "checkbox") { next = (f.el as HTMLInputElement).checked; } else { const inp = f.el as HTMLInputElement; let d = inp.value.trim() === "" ? NaN : Number(inp.value); if (!isFinite(d)) { this.render(f); return; } if (f.spec.min !== undefined) d = Math.max(f.spec.min, d); if (f.spec.max !== undefined) d = Math.min(f.spec.max, d); next = d * f.scale; } if (next === prev) { this.render(f); return; } this.values[key] = next; this.render(f); this.onChange(key, next, !!f.spec.live); } }