// The mode table: one row per mode with its frequency, decay time, heuristic (m, n) label and, // during or after a run, its energy in dB below the loudest mode. Clicking a row selects the // mode. The labels are filled in lazily in idle chunks; the energy column is refreshed at a few // hertz, not per frame. import { classifyMode } from "../modal/classify.ts"; import type { ModesData } from "../modes.ts"; export class ModeList { private readonly body: HTMLTableSectionElement; private rows: HTMLTableRowElement[] = []; private energyCells: HTMLTableCellElement[] = []; private labelCells: HTMLTableCellElement[] = []; private modes: ModesData | null = null; private labelTimer = 0; selected = -1; driven = -1; onSelect: ((mode: number) => void) | null = null; constructor(container: HTMLElement) { const table = document.createElement("table"); table.innerHTML = "#f [Hz]T60 [s](m, n)E [dB]"; this.body = document.createElement("tbody"); table.appendChild(this.body); container.appendChild(table); } setModes(modes: ModesData | null, freq: Float64Array | null, sigma: Float64Array | null) { clearTimeout(this.labelTimer); this.modes = modes; this.body.replaceChildren(); this.rows = []; this.energyCells = []; this.labelCells = []; if (!modes || !freq || !sigma) return; const frag = document.createDocumentFragment(); for (let m = 0; m < modes.nModes; m++) { const tr = document.createElement("tr"); const cells = [String(m), freq[m] < 1000 ? freq[m].toFixed(1) : freq[m].toFixed(0), (6.907755 / sigma[m]).toFixed(2), "", ""]; for (const c of cells) { const td = document.createElement("td"); td.textContent = c; tr.appendChild(td); } tr.onclick = () => this.onSelect?.(m); this.rows.push(tr); this.labelCells.push(tr.children[3] as HTMLTableCellElement); this.energyCells.push(tr.children[4] as HTMLTableCellElement); frag.appendChild(tr); } this.body.appendChild(frag); this.fillLabels(0); this.applyMarks(); } /** Refresh the frequency and T60 columns after a live material or damping change. */ setDerived(freq: Float64Array, sigma: Float64Array) { for (let m = 0; m < this.rows.length; m++) { const c = this.rows[m].children; (c[1] as HTMLElement).textContent = freq[m] < 1000 ? freq[m].toFixed(1) : freq[m].toFixed(0); (c[2] as HTMLElement).textContent = (6.907755 / sigma[m]).toFixed(2); } } private fillLabels(from: number) { const modes = this.modes; if (!modes) return; const t0 = performance.now(); let m = from; while (m < modes.nModes && performance.now() - t0 < 12) { const { diameters, circles } = classifyMode(modes, m); this.labelCells[m].textContent = `(${diameters}, ${circles})`; m++; } if (m < modes.nModes) this.labelTimer = window.setTimeout(() => this.fillLabels(m), 30); } setEnergies(E: Float32Array | null, maxE: number) { for (let m = 0; m < this.energyCells.length; m++) { const cell = this.energyCells[m]; if (!E || !(maxE > 0)) { if (cell.textContent) cell.textContent = ""; continue; } const dB = 10 * Math.log10(Math.max(E[m], 1e-300) / maxE); cell.textContent = dB > -120 ? dB.toFixed(0) : ""; } } select(mode: number) { this.selected = mode; this.applyMarks(); const row = this.rows[mode]; if (row) row.scrollIntoView({ block: "nearest" }); } setDriven(mode: number) { this.driven = mode; this.applyMarks(); } private applyMarks() { for (let m = 0; m < this.rows.length; m++) { this.rows[m].classList.toggle("selected", m === this.selected); this.rows[m].classList.toggle("driven", m === this.driven); } } }