'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 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 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 ── */