Files
Learn_System/frontend/js/labs/equilibrium.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

546 lines
19 KiB
JavaScript

'use strict';
/**
* EquilibriumSim — Chemical equilibrium simulation.
* A + B ⇌ C + D with Arrhenius kinetics, Le Chatelier principle.
* Left: particle animation with collisions & reactions.
* Right (30%): live concentration graph over time.
*/
class EquilibriumSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.particles = [];
this.flashes = []; // [{x, y, t, maxT, color}]
this._history = []; // [{step, nA, nB, nC, nD}]
this._nextId = 0;
/* parameters */
this.T = 300; // temperature K
this.nA = 20; // initial A count
this.nB = 20; // initial B count
this.Ea_f = 50; // forward activation energy
this.Ea_r = 55; // reverse activation energy
/* runtime */
this._steps = 0;
this._raf = null;
this._dpr = 1;
this.playing = false;
this.onUpdate = null;
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ═══════════════════════ public API ═══════════════════════ */
fit() {
const dpr = window.devicePixelRatio || 1;
this._dpr = dpr;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
this.reset();
}
getParams() { return { T: this.T, nA: this.nA, nB: this.nB, Ea_f: this.Ea_f, Ea_r: this.Ea_r }; }
setParams({ T, nA, nB, Ea_f, Ea_r } = {}) {
let needReset = false;
if (T !== undefined) this.T = Math.max(200, Math.min(500, +T));
if (Ea_f !== undefined) this.Ea_f = +Ea_f;
if (Ea_r !== undefined) this.Ea_r = +Ea_r;
if (nA !== undefined) { this.nA = Math.max(10, Math.min(40, +nA)); needReset = true; }
if (nB !== undefined) { this.nB = Math.max(10, Math.min(40, +nB)); needReset = true; }
if (needReset) this.reset();
this.draw();
this._emit();
}
preset(name) {
const presets = {
default: { T: 300, nA: 20, nB: 20, Ea_f: 50, Ea_r: 55 },
exothermic: { T: 280, nA: 20, nB: 20, Ea_f: 35, Ea_r: 65 },
endothermic: { T: 350, nA: 20, nB: 20, Ea_f: 65, Ea_r: 35 },
excess_A: { T: 300, nA: 35, nB: 15, Ea_f: 50, Ea_r: 55 },
};
const p = presets[name] || presets.default;
Object.assign(this, p);
this.reset();
}
reset() {
this.pause();
const { W, H } = this;
if (!W || !H) return;
this.particles = [];
this.flashes = [];
this._history = [];
this._steps = 0;
this._nextId = 0;
const simW = W * 0.7;
this._spawnType('A', this.nA, simW);
this._spawnType('B', this.nB, simW);
this._recordHistory();
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
info() {
let nA = 0, nB = 0, nC = 0, nD = 0;
for (const p of this.particles) {
if (p.type === 'A') nA++;
else if (p.type === 'B') nB++;
else if (p.type === 'C') nC++;
else nD++;
}
const cA = nA || 0.001, cB = nB || 0.001;
const cC = nC || 0.001, cD = nD || 0.001;
const Q = (cC * cD) / (cA * cB);
const keq = Math.exp((this.Ea_f - this.Ea_r) / (this.T * 0.05));
const direction = Q < keq * 0.95 ? '\u2192' : Q > keq * 1.05 ? '\u2190' : '\u21CC';
return { keq: +keq.toFixed(3), Q: +Q.toFixed(3), direction, nA, nB, nC, nD };
}
/* ═══════════════════════ internals ═══════════════════════ */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame((ts) => {
const dt = 0.016; // ~60fps fixed step for LabFX
if (window.LabFX) LabFX.particles.update(dt);
for (let i = 0; i < 3; i++) this._step();
this.draw();
this._tick();
});
}
_color(type) {
return { A: '#EF476F', B: '#9B5DE5', C: '#7BF5A4', D: '#FFD166' }[type] || '#aaa';
}
_radius() { return 5; }
_spawnType(type, count, maxX) {
const { H } = this;
const r = this._radius();
const margin = 10;
let placed = 0, att = 0;
while (placed < count && att < count * 60) {
att++;
const x = margin + r + Math.random() * (maxX - 2 * r - margin * 2);
const y = margin + r + Math.random() * (H - 2 * r - margin * 2);
let overlap = false;
for (const p of this.particles) {
if ((p.x - x) ** 2 + (p.y - y) ** 2 < (p.r + r + 1) ** 2) { overlap = true; break; }
}
if (overlap) continue;
const a = Math.random() * Math.PI * 2;
const spd = 1.5 + Math.random() * 1.5;
this.particles.push({ x, y, vx: Math.cos(a) * spd, vy: Math.sin(a) * spd, r, type, id: this._nextId++ });
placed++;
}
}
_step() {
const { W, H } = this;
const simW = W * 0.7;
const dt = 0.6;
/* move + walls */
for (const p of this.particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); }
if (p.x > simW - p.r) { p.x = simW - p.r; p.vx = -Math.abs(p.vx); }
if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); }
if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); }
}
/* spatial grid */
const cs = 18;
const cols = Math.ceil(simW / cs) + 1;
const grid = new Map();
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
const k = Math.floor(p.x / cs) + Math.floor(p.y / cs) * cols;
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(i);
}
const toRemove = new Set();
const toAdd = [];
/* collisions + reactions */
for (let i = 0; i < this.particles.length; i++) {
const p1 = this.particles[i];
if (toRemove.has(p1.id)) continue;
const cx = Math.floor(p1.x / cs), cy = Math.floor(p1.y / cs);
for (let dcx = -1; dcx <= 1; dcx++) for (let dcy = -1; dcy <= 1; dcy++) {
const cell = grid.get((cx + dcx) + (cy + dcy) * cols);
if (!cell) continue;
for (const j of cell) {
if (j <= i) continue;
const p2 = this.particles[j];
if (toRemove.has(p2.id)) continue;
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const dist2 = dx * dx + dy * dy;
const minD = p1.r + p2.r;
if (dist2 >= minD * minD) continue;
const dist = Math.sqrt(dist2);
/* forward: A + B <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> C + D */
const isAB = (p1.type === 'A' && p2.type === 'B') || (p1.type === 'B' && p2.type === 'A');
if (isAB) {
const kf = Math.exp(-this.Ea_f / (this.T * 0.08)) * 0.35;
if (Math.random() < kf) {
toRemove.add(p1.id); toRemove.add(p2.id);
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
const a1 = Math.random() * Math.PI * 2;
const spd = 1.2 + Math.random();
toAdd.push({ x: mx + Math.cos(a1) * 4, y: my + Math.sin(a1) * 4, vx: Math.cos(a1) * spd, vy: Math.sin(a1) * spd, r: 5, type: 'C', id: this._nextId++ });
toAdd.push({ x: mx - Math.cos(a1) * 4, y: my - Math.sin(a1) * 4, vx: -Math.cos(a1) * spd, vy: -Math.sin(a1) * spd, r: 5, type: 'D', id: this._nextId++ });
this.flashes.push({ x: mx, y: my, t: 0, maxT: 18, color: '123,245,164' });
continue;
}
}
/* reverse: C + D <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> A + B */
const isCD = (p1.type === 'C' && p2.type === 'D') || (p1.type === 'D' && p2.type === 'C');
if (isCD) {
const kr = Math.exp(-this.Ea_r / (this.T * 0.08)) * 0.35;
if (Math.random() < kr) {
toRemove.add(p1.id); toRemove.add(p2.id);
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
const a1 = Math.random() * Math.PI * 2;
const spd = 1.2 + Math.random();
toAdd.push({ x: mx + Math.cos(a1) * 4, y: my + Math.sin(a1) * 4, vx: Math.cos(a1) * spd, vy: Math.sin(a1) * spd, r: 5, type: 'A', id: this._nextId++ });
toAdd.push({ x: mx - Math.cos(a1) * 4, y: my - Math.sin(a1) * 4, vx: -Math.cos(a1) * spd, vy: -Math.sin(a1) * spd, r: 5, type: 'B', id: this._nextId++ });
this.flashes.push({ x: mx, y: my, t: 0, maxT: 18, color: '239,71,111' });
continue;
}
}
/* elastic bounce */
if (dist > 0.001) {
const nx = dx / dist, ny = dy / dist;
const dvn = (p1.vx - p2.vx) * nx + (p1.vy - p2.vy) * ny;
if (dvn > 0) {
p1.vx -= dvn * nx; p1.vy -= dvn * ny;
p2.vx += dvn * nx; p2.vy += dvn * ny;
}
const ov = (minD - dist) * 0.5;
p1.x -= nx * ov; p1.y -= ny * ov;
p2.x += nx * ov; p2.y += ny * ov;
}
}
}
}
if (toRemove.size) {
// LabFX: throttled tick sound + spark on each collision
if (window.LabFX && toRemove.size > 0) {
const now = performance.now();
if (!this._lastFxTick || now - this._lastFxTick > 200) {
this._lastFxTick = now;
LabFX.sound.play('tick', { volume: 0.1 });
}
for (const id of toRemove) {
const hit = this.particles.find(p => p.id === id);
if (hit) {
LabFX.particles.emit({ ctx: this.ctx, x: hit.x, y: hit.y, count: 2,
color: '#FFD166', speed: 30, spread: 3.14, angle: 0,
gravity: 0, life: 250, shape: 'spark', glow: true });
}
}
}
this.particles = this.particles.filter(p => !toRemove.has(p.id));
}
for (const p of toAdd) this.particles.push(p);
this.flashes = this.flashes.filter(f => ++f.t < f.maxT);
this._steps++;
if (this._steps % 20 === 0) {
this._recordHistory();
this._emit();
}
}
_recordHistory() {
let nA = 0, nB = 0, nC = 0, nD = 0;
for (const p of this.particles) {
if (p.type === 'A') nA++;
else if (p.type === 'B') nB++;
else if (p.type === 'C') nC++;
else nD++;
}
this._history.push({ step: this._steps, nA, nB, nC, nD });
if (this._history.length > 300) this._history.shift();
}
/* ═══════════════════════ rendering ═══════════════════════ */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
const simW = W * 0.7;
/* background */
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
/* dot grid */
ctx.fillStyle = 'rgba(255,255,255,0.025)';
for (let x = 30; x < simW; x += 30)
for (let y = 30; y < H; y += 30) {
ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill();
}
/* divider */
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(simW - 1, 0, 2, H);
/* flashes */
for (const f of this.flashes) {
const prog = f.t / f.maxT;
const radius = prog * 38 + 4;
const alpha = (1 - prog) * 0.55;
const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, radius);
g.addColorStop(0, `rgba(${f.color},${alpha * 1.5})`);
g.addColorStop(0.4, `rgba(${f.color},${alpha * 0.4})`);
g.addColorStop(1, `rgba(${f.color},0)`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(f.x, f.y, radius, 0, Math.PI * 2); ctx.fill();
}
/* particles */
for (const p of this.particles) this._drawParticle(ctx, p);
/* right panel: concentration graph */
this._drawGraph(ctx, simW, W, H);
/* stats overlay */
this._drawStats(ctx);
/* equation label */
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.font = "bold 11px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center';
ctx.fillText('A + B \u21CC C + D', simW / 2, H - 12);
if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawParticle(ctx, p) {
const col = this._color(p.type);
const { x, y, r } = p;
/* outer glow */
const glow = ctx.createRadialGradient(x, y, 0, x, y, r * 3.2);
glow.addColorStop(0, col + '44');
glow.addColorStop(1, col + '00');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(x, y, r * 3.2, 0, Math.PI * 2); ctx.fill();
/* body gradient */
const body = ctx.createRadialGradient(x - r * 0.25, y - r * 0.25, r * 0.05, x, y, r);
body.addColorStop(0, col + 'ff');
body.addColorStop(0.6, col + 'cc');
body.addColorStop(1, col + '88');
ctx.fillStyle = body;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
/* specular */
ctx.fillStyle = 'rgba(255,255,255,0.38)';
ctx.beginPath(); ctx.arc(x - r * 0.28, y - r * 0.28, r * 0.28, 0, Math.PI * 2); ctx.fill();
/* label */
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.font = `bold ${Math.round(r * 1.1)}px sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(p.type, x, y + 0.5);
ctx.textBaseline = 'alphabetic';
}
_drawGraph(ctx, x0, W, H) {
const gW = W - x0, pad = { l: 36, r: 10, t: 32, b: 28 };
const px = x0 + pad.l, py = pad.t;
const pw = gW - pad.l - pad.r;
const ph = H - pad.t - pad.b;
/* panel bg */
ctx.fillStyle = 'rgba(5,5,20,0.85)';
ctx.fillRect(x0, 0, gW, H);
/* title */
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left';
ctx.fillText('\u041A\u043E\u043D\u0446\u0435\u043D\u0442\u0440\u0430\u0446\u0438\u044F', x0 + 10, 16);
/* grid */
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const yl = py + ph * (i / 4);
ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke();
}
/* y-axis labels */
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right';
const maxN = Math.max(this.nA, this.nB) * 1.2 + 2;
for (let i = 0; i <= 4; i++) {
const v = Math.round(maxN * (4 - i) / 4);
ctx.fillText(v, px - 4, py + ph * (i / 4) + 3);
}
if (this._history.length < 2) return;
const n = this._history.length;
const lines = [
{ key: 'nA', color: '#EF476F', label: 'A' },
{ key: 'nB', color: '#9B5DE5', label: 'B' },
{ key: 'nC', color: '#7BF5A4', label: 'C' },
{ key: 'nD', color: '#FFD166', label: 'D' },
];
for (const { key, color } of lines) {
ctx.beginPath();
ctx.strokeStyle = color; ctx.lineWidth = 1.6;
for (let i = 0; i < n; i++) {
const lx = px + (i / Math.max(n - 1, 1)) * pw;
const ly = py + ph - Math.min(this._history[i][key] / maxN, 1) * ph;
i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly);
}
ctx.stroke();
}
/* legend */
lines.forEach(({ color, label }, i) => {
const lx = x0 + 10 + i * 38;
const ly = H - 14;
ctx.fillStyle = color;
ctx.fillRect(lx, ly, 10, 2.5);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left';
ctx.fillText(label, lx + 13, ly + 3);
});
/* current values */
const last = this._history[n - 1];
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = "8px monospace";
ctx.textAlign = 'right';
ctx.fillText(`A:${last.nA} B:${last.nB} C:${last.nC} D:${last.nD}`, x0 + gW - 8, H - 14);
}
_drawStats(ctx) {
const info = this.info();
const px = 10, py = 10, pw = 160, ph = 82;
ctx.fillStyle = 'rgba(5,5,20,0.82)';
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
const lh = 16;
ctx.fillStyle = '#7BF5A4';
ctx.fillText(`K\u2091\u2071 = ${info.keq}`, px + 10, py + 8);
ctx.fillStyle = '#FFD166';
ctx.fillText(`Q = ${info.Q}`, px + 10, py + 8 + lh);
ctx.fillStyle = '#06D6E0';
ctx.fillText(`\u041D\u0430\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435: ${info.direction}`, px + 10, py + 8 + lh * 2);
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.fillText(`T = ${this.T} K`, px + 10, py + 8 + lh * 3);
}
/* ═══════════════════════ utility ═══════════════════════ */
_rrect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
}
if (typeof module !== 'undefined') module.exports = EquilibriumSim;
/* ─── lab UI init ─────────────────────────────────── */
function _openEquilibrium() {
document.getElementById('sim-topbar-title').textContent = 'Химическое равновесие';
_simShow('sim-equilibrium');
_registerSimState('equilibrium', () => eqSim?.getParams(), st => eqSim?.setParams(st));
if (_embedMode) _startStateEmit('equilibrium');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!eqSim) {
eqSim = new EquilibriumSim(document.getElementById('equilibrium-canvas'));
eqSim.onUpdate = _eqUpdateUI;
}
eqSim.fit();
eqSim.reset();
eqSim.play();
}));
}
function eqParam(name, val) {
const v = parseFloat(val);
const ids = { T: 'eq-T-val', Ea_f: 'eq-Eaf-val', Ea_r: 'eq-Ear-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = v;
if (name === 'T' && window.LabFX) LabFX.sound.play('whoosh', { pitch: v / 300, volume: 0.3 });
if (eqSim) eqSim.setParams({ [name]: v });
}
function eqPreset(name) {
if (window.LabFX) LabFX.sound.play('pour', { volume: 0.3 });
if (eqSim) { eqSim.preset(name); eqSim.play(); }
const defs = { default: [300,50,55], exothermic: [280,35,65], endothermic: [350,65,35], excess_A: [300,50,55] };
const d = defs[name] || defs.default;
document.getElementById('sl-eq-T').value = d[0]; document.getElementById('eq-T-val').textContent = d[0];
document.getElementById('sl-eq-Eaf').value = d[1]; document.getElementById('eq-Eaf-val').textContent = d[1];
document.getElementById('sl-eq-Ear').value = d[2]; document.getElementById('eq-Ear-val').textContent = d[2];
}
function _eqUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('eqbar-v1', info.keq);
v('eqbar-v2', info.Q);
v('eqbar-v3', info.direction);
v('eqbar-v4', info.nA + '|' + info.nB + '|' + info.nC + '|' + info.nD);
}
/* ── thin lens ── */