'use strict'; /** * ReactionSim — Chemical reaction kinetics simulation. * Particle-based A + B C (and variants) with Arrhenius kinetics. * Renders: glowing molecules, flash effects on reaction, * live concentration graph, energy profile diagram. */ class ReactionSim { 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}] this._nextId = 0; // Parameters this.N = 28; // initial molecules per reactive species this.T = 1.2; // temperature 0.2–4.0 this.Ea = 2.0; // activation energy 0.5–5.0 this.mode = 'forward'; // 'forward' | 'reversible' | 'chain' this.reactionOn = true; // Runtime stats this._steps = 0; this._totalReactions = 0; this._recentReactions = 0; this._rate = 0; // reactions per step (ema) this._raf = null; this._dpr = 1; this.onUpdate = null; // Spatial grid this._grid = new Map(); this._GRID_C = 22; // cell size (> max particle diameter) } /* ────────────────────────── Lifecycle ────────────────────────── */ fit() { const dpr = window.devicePixelRatio || 1; this._dpr = dpr; const w = this.canvas.offsetWidth; const h = this.canvas.offsetHeight; this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.W = w; this.H = h; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.reset(); } reset() { const { W, H } = this; if (!W || !H) return; this.particles = []; this.flashes = []; this._history = []; this._steps = 0; this._totalReactions = 0; this._recentReactions = 0; this._rate = 0; this._nextId = 0; // Spawn N of A and N of B this._spawnType('A', this.N); this._spawnType('B', this.N); this._recordHistory(); } _spawnType(type, count) { const { W, H } = this; const r = this._radius(type); const margin = 12; let placed = 0, attempts = 0; while (placed < count && attempts < count * 60) { attempts++; const x = margin + r + Math.random() * (W - 2 * r - margin * 2); const y = margin + r + Math.random() * (H - 2 * r - margin * 2); let overlap = false; for (const p of this.particles) { const dx = p.x - x, dy = p.y - y; if (dx * dx + dy * dy < (p.r + r + 1) ** 2) { overlap = true; break; } } if (overlap) continue; const ang = Math.random() * Math.PI * 2; const spd = this._baseSpeed(type) * (0.6 + Math.random() * 0.8); this.particles.push({ x, y, vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd, r, type, id: this._nextId++ }); placed++; } } start() { if (this._raf) return; this._lastTs = performance.now(); const loop = (ts) => { this._raf = requestAnimationFrame(loop); const dt = Math.min((ts - this._lastTs) / 1000, 0.05); this._lastTs = ts; if (window.LabFX) LabFX.particles.update(dt); for (let i = 0; i < 3; i++) this._step(); this.draw(); }; this._raf = requestAnimationFrame(loop); } stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } /* ────────────────────────── Parameters ────────────────────────── */ setN(n) { this.N = Math.max(5, Math.min(80, n)); this.reset(); } setT(t) { const ratio = Math.max(0.1, t) / Math.max(0.1, this.T); this.T = Math.max(0.2, Math.min(4.0, t)); const scale = Math.sqrt(ratio); for (const p of this.particles) { p.vx *= scale; p.vy *= scale; } } setEa(ea) { this.Ea = Math.max(0.5, Math.min(5.0, ea)); } setMode(mode) { this.mode = mode; } toggleReaction() { this.reactionOn = !this.reactionOn; } preset(name) { this.reactionOn = true; const presets = { simple: { N: 28, T: 1.2, Ea: 1.8, mode: 'forward' }, reversible: { N: 22, T: 1.5, Ea: 1.5, mode: 'reversible' }, hot: { N: 25, T: 2.8, Ea: 2.0, mode: 'forward' }, cold: { N: 25, T: 0.4, Ea: 1.5, mode: 'forward' }, chain: { N: 18, T: 1.8, Ea: 0.9, mode: 'chain' }, }; Object.assign(this, presets[name] || {}); this.reset(); } info() { let nA = 0, nB = 0, nC = 0; for (const p of this.particles) { if (p.type === 'A') nA++; else if (p.type === 'B') nB++; else nC++; } return { nA, nB, nC, total: this.particles.length, reactions: this._totalReactions, rate: this._rate }; } /* ────────────────────────── Helpers ────────────────────────── */ _radius(type) { return type === 'C' ? 7 : 5; } _baseSpeed(type) { return (type === 'C' ? 0.55 : 1.0) * this.T * 3.2; } _color(type) { return { A: '#06D6E0', B: '#EF476F', C: '#FFD166' }[type] || '#aaa'; } /* ────────────────────────── Physics ────────────────────────── */ _buildGrid() { this._grid.clear(); const cs = this._GRID_C; for (const p of this.particles) { const key = `${Math.floor(p.x / cs)},${Math.floor(p.y / cs)}`; if (!this._grid.has(key)) this._grid.set(key, []); this._grid.get(key).push(p); } } _neighbors(p) { const cs = this._GRID_C; const gx = Math.floor(p.x / cs), gy = Math.floor(p.y / cs); const out = []; for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) { const cell = this._grid.get(`${gx + dx},${gy + dy}`); if (cell) for (const q of cell) if (q !== p) out.push(q); } return out; } _step() { const { W, H } = this; const dt = 0.55; // Move + wall bounce 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 > W - p.r) { p.x = W - 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); } } this._buildGrid(); const toRemove = new Set(); const toAdd = []; // Pairwise: collision detection, reaction check, elastic bounce for (const p of this.particles) { if (toRemove.has(p.id)) continue; for (const q of this._neighbors(p)) { if (q.id <= p.id || toRemove.has(q.id)) continue; const dx = q.x - p.x, dy = q.y - p.y; const dist2 = dx * dx + dy * dy; const minD = p.r + q.r; if (dist2 >= minD * minD) continue; const dist = Math.sqrt(dist2); // Try chemical reaction if (this.reactionOn && this._tryReact(p, q, dx, dy, dist, toRemove, toAdd)) continue; // Elastic collision const nx = dx / dist, ny = dy / dist; const dvx = p.vx - q.vx, dvy = p.vy - q.vy; const dot = dvx * nx + dvy * ny; if (dot >= 0) { // Just separate overlapping particles that are already moving apart const ov = (minD - dist) * 0.5; p.x -= nx * ov; p.y -= ny * ov; q.x += nx * ov; q.y += ny * ov; continue; } const m1 = p.r * p.r, m2 = q.r * q.r; const imp = (2 * dot) / (m1 + m2); p.vx -= imp * m2 * nx; p.vy -= imp * m2 * ny; q.vx += imp * m1 * nx; q.vy += imp * m1 * ny; const ov = (minD - dist) * 0.5; p.x -= nx * ov; p.y -= ny * ov; q.x += nx * ov; q.y += ny * ov; } } // Spontaneous decomposition C A + B (reversible mode) if (this.mode === 'reversible') { const prob = 0.00022 * this.T * Math.exp(-this.Ea * 0.38 / this.T); for (const p of this.particles) { if (p.type !== 'C' || toRemove.has(p.id)) continue; if (Math.random() < prob) { toRemove.add(p.id); const ang = Math.random() * Math.PI * 2; const spd = this._baseSpeed('A'); const mk = id => ({ x: p.x + Math.cos(ang + id * Math.PI) * 5, y: p.y + Math.sin(ang + id * Math.PI) * 5, vx: Math.cos(ang + id * Math.PI) * spd * (0.7 + Math.random() * 0.6), vy: Math.sin(ang + id * Math.PI) * spd * (0.7 + Math.random() * 0.6), r: 5, type: id === 0 ? 'A' : 'B', id: this._nextId++ }); toAdd.push(mk(0), mk(1)); this.flashes.push({ x: p.x, y: p.y, t: 0, maxT: 14, color: '100,160,255' }); } } } // Apply changes if (toRemove.size) this.particles = this.particles.filter(p => !toRemove.has(p.id)); for (const p of toAdd) this.particles.push(p); // Age flashes this.flashes = this.flashes.filter(f => ++f.t < f.maxT); this._steps++; if (this._steps % 30 === 0) { this._rate = this._recentReactions / 30; this._recentReactions = 0; } if (this._steps % 20 === 0) { this._recordHistory(); if (this.onUpdate) this.onUpdate(this.info()); } } _tryReact(p, q, dx, dy, dist, toRemove, toAdd) { const isAB = (p.type === 'A' && q.type === 'B') || (p.type === 'B' && q.type === 'A'); if (!isAB) return false; // Arrhenius factor: k ∝ exp(-Ea / T) if (Math.random() > Math.exp(-this.Ea / this.T) * 0.38) return false; const m1 = p.r * p.r, m2 = q.r * q.r, mt = m1 + m2; const cx = (p.x * m1 + q.x * m2) / mt; const cy = (p.y * m1 + q.y * m2) / mt; const pvx = (p.vx * m1 + q.vx * m2) / mt; const pvy = (p.vy * m1 + q.vy * m2) / mt; toRemove.add(p.id); toRemove.add(q.id); if (this.mode === 'chain') { // Chain: A + B 2 C (two fast products — cascade reaction) const spd = Math.sqrt(pvx * pvx + pvy * pvy) * 1.35 + this._baseSpeed('C') * 0.7; const ang = Math.atan2(pvy || 0.001, pvx || 0.001); for (let s = 0; s < 2; s++) { const sign = s === 0 ? 1 : -1; toAdd.push({ x: cx + Math.cos(ang) * sign * 5, y: cy + Math.sin(ang) * sign * 5, vx: Math.cos(ang) * sign * spd, vy: Math.sin(ang) * sign * spd, r: 6, type: 'C', id: this._nextId++ }); } this.flashes.push({ x: cx, y: cy, t: 0, maxT: 28, color: '255,140,30' }); } else { // Forward / reversible: A + B 1 C const cSpd = Math.sqrt(pvx * pvx + pvy * pvy) * 0.62 + this._baseSpeed('C') * 0.28; const ang = Math.atan2(pvy || 0.001, pvx || 0.001); toAdd.push({ x: cx, y: cy, vx: Math.cos(ang) * cSpd, vy: Math.sin(ang) * cSpd, r: 7, type: 'C', id: this._nextId++ }); this.flashes.push({ x: cx, y: cy, t: 0, maxT: 22, color: '255,200,50' }); } this._totalReactions++; this._recentReactions++; // LabFX: flash spark + throttled tick sound at collision point if (window.LabFX) { const now = performance.now(); if (!this._fxLastTick || now - this._fxLastTick > 200) { this._fxLastTick = now; LabFX.sound.play('tick', { volume: 0.1 }); } LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 3, color: '#FFD166', speed: 45, spread: 3.14, angle: 0, gravity: 0, life: 200, shape: 'spark', glow: true }); } return true; } _recordHistory() { let nA = 0, nB = 0, nC = 0; for (const p of this.particles) { if (p.type === 'A') nA++; else if (p.type === 'B') nB++; else nC++; } this._history.push({ step: this._steps, nA, nB, nC }); if (this._history.length > 260) this._history.shift(); } /* ────────────────────────── Rendering ────────────────────────── */ draw() { const { ctx, W, H } = this; if (!W || !H) return; // ── Background ── ctx.fillStyle = '#080818'; ctx.fillRect(0, 0, W, H); // ── Subtle dot grid ── ctx.fillStyle = 'rgba(255,255,255,0.033)'; for (let x = 35; x < W; x += 35) for (let y = 35; y < H; y += 35) { ctx.beginPath(); ctx.arc(x, y, 1, 0, Math.PI * 2); ctx.fill(); } // ── Reaction flashes ── for (const f of this.flashes) { const prog = f.t / f.maxT; const radius = prog * 48 + 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.6})`); g.addColorStop(0.4, `rgba(${f.color},${alpha * 0.5})`); 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); // ── Overlays ── this._drawLegend(ctx); this._drawConcentrationGraph(ctx); this._drawEnergyDiagram(ctx); // ── Empty state ── if (this.particles.length === 0) { ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Все молекулы прореагировали — нажмите Сброс', W / 2, H / 2); } 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); glow.addColorStop(0, col + '50'); glow.addColorStop(1, col + '00'); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(x, y, r * 3, 0, Math.PI * 2); ctx.fill(); // Body (radial gradient for depth) const body = ctx.createRadialGradient(x - r * 0.28, y - r * 0.28, r * 0.05, x, y, r); body.addColorStop(0, col + 'ff'); body.addColorStop(0.65, col + 'cc'); body.addColorStop(1, col + '88'); ctx.fillStyle = body; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); // Specular highlight ctx.fillStyle = 'rgba(255,255,255,0.42)'; ctx.beginPath(); ctx.arc(x - r * 0.27, y - r * 0.27, r * 0.3, 0, Math.PI * 2); ctx.fill(); // Type label ctx.fillStyle = 'rgba(0,0,0,0.72)'; ctx.font = `bold ${Math.round(r * 1.15)}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(p.type, x, y + 0.5); ctx.textBaseline = 'alphabetic'; } _drawConcentrationGraph(ctx) { if (this._history.length < 2) return; const { W, H } = this; const gW = 198, gH = 118; const gX = W - gW - 10, gY = H - gH - 10; // Panel ctx.fillStyle = 'rgba(5,5,20,0.88)'; ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; this._rrect(ctx, gX, gY, gW, gH, 7); ctx.fill(); ctx.stroke(); // Title ctx.fillStyle = 'rgba(255,255,255,0.42)'; ctx.font = '9px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('Концентрация молекул', gX + 7, gY + 12); const pad = { l: 8, r: 6, t: 18, b: 24 }; const px = gX + pad.l, py = gY + pad.t; const pw = gW - pad.l - pad.r, ph = gH - pad.t - pad.b; const maxN = this.N * 2.3; const n = this._history.length; // Grid lines ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 0.5; for (let i = 0; i <= 4; i++) { const yl = py + ph * (1 - i / 4); ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke(); } // Data lines const lines = [ { key: 'nA', color: '#06D6E0', label: 'A — реагент' }, { key: 'nB', color: '#EF476F', label: 'B — реагент' }, { key: 'nC', color: '#FFD166', label: 'C — продукт' }, ]; 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 + current values const last = this._history[this._history.length - 1]; lines.forEach(({ color, label }, i) => { const lx = gX + 8 + i * 58; ctx.fillStyle = color; ctx.fillRect(lx, gY + gH - 16, 11, 2.5); ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = '8px sans-serif'; ctx.textAlign = 'left'; ctx.fillText(label.split(' ')[0], lx + 13, gY + gH - 12); }); 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}`, gX + gW - 6, gY + gH - 12); } _drawEnergyDiagram(ctx) { const { W } = this; const dW = 158, dH = 100; const dX = W - dW - 10, dY = 10; ctx.fillStyle = 'rgba(5,5,20,0.88)'; ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; this._rrect(ctx, dX, dY, dW, dH, 7); ctx.fill(); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.42)'; ctx.font = '9px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('Профиль энергии', dX + 7, dY + 12); const pad = { l: 22, r: 10, t: 18, b: 20 }; const ex = dX + pad.l, ey_bot = dY + dH - pad.b; const ew = dW - pad.l - pad.r, eh = dH - pad.t - pad.b; const rE = 0.15; const tE = 0.85; const pE = Math.max(0.04, rE + this._diagDeltaH()); const toY = e => ey_bot - e * eh; // Smooth reaction path ctx.beginPath(); ctx.strokeStyle = 'rgba(255,200,60,0.78)'; ctx.lineWidth = 2; ctx.moveTo(ex, toY(rE)); ctx.lineTo(ex + ew * 0.15, toY(rE)); ctx.bezierCurveTo( ex + ew * 0.32, toY(rE), ex + ew * 0.40, toY(tE), ex + ew * 0.50, toY(tE) ); ctx.bezierCurveTo( ex + ew * 0.60, toY(tE), ex + ew * 0.68, toY(pE), ex + ew * 0.85, toY(pE) ); ctx.lineTo(ex + ew, toY(pE)); ctx.stroke(); // Horizontal dashes at levels ctx.setLineDash([2, 3]); ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 0.75; [rE, pE].forEach(e => { ctx.beginPath(); ctx.moveTo(ex, toY(e)); ctx.lineTo(ex + ew, toY(e)); ctx.stroke(); }); ctx.setLineDash([]); // Ea bracket (left side) ctx.strokeStyle = 'rgba(255,255,255,0.28)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(ex - 3, toY(rE)); ctx.lineTo(ex - 8, toY(rE)); ctx.moveTo(ex - 3, toY(tE)); ctx.lineTo(ex - 8, toY(tE)); ctx.moveTo(ex - 7, toY(rE)); ctx.lineTo(ex - 7, toY(tE)); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.font = '8px sans-serif'; ctx.textAlign = 'right'; ctx.fillText('Ea', ex - 9, toY((rE + tE) / 2) + 3); // Labels ctx.fillStyle = '#06D6E0cc'; ctx.font = '8px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('A+B', ex, toY(rE) - 4); ctx.fillStyle = '#FFD166cc'; ctx.textAlign = 'right'; ctx.fillText('C', ex + ew, toY(pE) - 4); // Mode label at bottom const modeTxt = { forward: '→ A + B → C', reversible: '⇌ A + B ⇌ C', chain: 'цепная реакция' }[this.mode] || ''; ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = '8px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(modeTxt, dX + dW / 2, dY + dH - 6); } _diagDeltaH() { // Visual ΔH for energy diagram: exothermic by default return -(0.10 + this.Ea * 0.045); } _drawLegend(ctx) { const items = [ { color: '#06D6E0', label: 'A — реагент' }, { color: '#EF476F', label: 'B — реагент' }, { color: '#FFD166', label: 'C — продукт' }, ]; const lX = 10, lY = 10, lW = 120, lH = 14 * items.length + 14; ctx.fillStyle = 'rgba(5,5,20,0.78)'; ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; this._rrect(ctx, lX, lY, lW, lH, 6); ctx.fill(); ctx.stroke(); items.forEach(({ color, label }, i) => { const iy = lY + 14 + i * 14; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(lX + 12, iy, 4.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.52)'; ctx.font = '9px sans-serif'; ctx.textAlign = 'left'; ctx.fillText(label, lX + 22, iy + 3.5); }); } _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(); } } /* ─── lab UI init ─────────────────────────────────── */ function _openChemistry(mode) { document.getElementById('sim-topbar-title').textContent = 'Химические реакции'; _simShow('sim-chemistry'); _simShow('ctrl-chemistry'); if (mode) _chemMode = mode; requestAnimationFrame(() => requestAnimationFrame(() => { chemMode(_chemMode); })); } function chemMode(mode, btn) { _chemMode = mode; const MODES = ['kinetics', 'flask', 'redox', 'ionex']; const CANVASES = { kinetics: 'reactions-canvas', flask: 'flask-canvas', redox: 'redox-canvas', ionex: 'ionexchange-canvas' }; // toggle mode buttons document.querySelectorAll('.chem-mode').forEach(b => b.classList.remove('active')); const mb = document.getElementById('chem-mode-' + mode); if (mb) mb.classList.add('active'); // toggle panels MODES.forEach(m => { const p = document.getElementById('chem-panel-' + m); if (p) p.style.display = m === mode ? '' : 'none'; }); // toggle canvases Object.entries(CANVASES).forEach(([m, cid]) => { document.getElementById(cid).style.display = m === mode ? 'block' : 'none'; }); // toggle topbar tool groups const modeToCtrl = { kinetics:'kin', flask:'flask', redox:'redox', ionex:'ionex' }; ['kin', 'flask', 'redox', 'ionex'].forEach(k => { const el = document.getElementById('ctrl-chem-' + k); if (el) el.style.display = k === modeToCtrl[mode] ? 'contents' : 'none'; }); // stop all sims if (reacSim) reacSim.stop(); if (flaskSim) flaskSim.stop(); if (rdxSim) rdxSim.stop(); if (ioxSim) ioxSim.stop(); // start the active one if (mode === 'kinetics') { const c = document.getElementById('reactions-canvas'); if (!reacSim) { reacSim = new ReactionSim(c); reacSim.onUpdate = _reacUpdateUI; } reacSim.fit(); reacSim.start(); _reacUpdateUI(reacSim.info()); } else if (mode === 'flask') { const c = document.getElementById('flask-canvas'); if (!flaskSim) { flaskSim = new FlaskSim(c); flaskSim.onUpdate = _flaskUpdateUI; } flaskSim.fit(); flaskSim.start(); _flaskUpdateUI(flaskSim.info()); } else if (mode === 'redox') { const c = document.getElementById('redox-canvas'); if (!rdxSim) { rdxSim = new RedoxSim(c); rdxSim.onUpdate = _redoxUpdateUI; } rdxSim.fit(); rdxSim.draw(); _redoxUpdateUI(rdxSim.info()); } else if (mode === 'ionex') { const c = document.getElementById('ionexchange-canvas'); if (!ioxSim) { ioxSim = new IonExSim(c); ioxSim.onUpdate = _ionexUpdateUI; } ioxSim.fit(); ioxSim.draw(); _ionexUpdateUI(ioxSim.info()); } } function chemReset() { if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 }); if (_chemMode === 'kinetics' && reacSim) reacSim.reset(); if (_chemMode === 'flask' && flaskSim) flaskSim.reset(); if (_chemMode === 'redox') redoxReset(); if (_chemMode === 'ionex') ionexReset(); } // _openReactions is now handled by _openChemistry + chemMode function reacNChange() { const v = +document.getElementById('sl-reacN').value; document.getElementById('reac-N-val').textContent = v; if (reacSim) reacSim.setN(v); } function reacTChange() { const raw = +document.getElementById('sl-reacT').value; const t = (raw / 10).toFixed(1); document.getElementById('reac-T-val').textContent = t; if (reacSim) reacSim.setT(+t); } function reacEaChange() { const raw = +document.getElementById('sl-reacEa').value; const ea = (raw / 10).toFixed(1); document.getElementById('reac-Ea-val').textContent = ea; if (reacSim) reacSim.setEa(+ea); } function reacMode(mode, el) { if (window.LabFX) LabFX.sound.play('click'); if (reacSim) reacSim.setMode(mode); document.querySelectorAll('.reac-mode-btn').forEach(b => b.classList.remove('active')); if (el) el.classList.add('active'); } function reacPreset(name) { if (!reacSim) return; reacSim.preset(name); // Sync sliders and mode buttons document.getElementById('sl-reacN').value = reacSim.N; document.getElementById('reac-N-val').textContent = reacSim.N; document.getElementById('sl-reacT').value = Math.round(reacSim.T * 10); document.getElementById('reac-T-val').textContent = reacSim.T.toFixed(1); document.getElementById('sl-reacEa').value = Math.round(reacSim.Ea * 10); document.getElementById('reac-Ea-val').textContent = reacSim.Ea.toFixed(1); document.querySelectorAll('.reac-mode-btn').forEach(b => b.classList.remove('active')); const mBtn = document.getElementById('rmode-' + reacSim.mode); if (mBtn) mBtn.classList.add('active'); _reacUpdateUI(reacSim.info()); } function reacTogglePause() { if (!reacSim) return; reacSim.toggleReaction(); const btn = document.getElementById('reac-pause-btn'); btn.innerHTML = reacSim.reactionOn ? ' Пауза' : ' Реакции'; } function _reacUpdateUI(info) { if (!info) return; document.getElementById('chbar-l1').textContent = 'A молекул'; document.getElementById('chbar-v1').textContent = info.nA; document.getElementById('chbar-l2').textContent = 'B молекул'; document.getElementById('chbar-v2').textContent = info.nB; document.getElementById('chbar-l3').textContent = 'C продукт'; document.getElementById('chbar-v3').textContent = info.nC; document.getElementById('chbar-l4').textContent = 'Реакций'; document.getElementById('chbar-v4').textContent = info.reactions; document.getElementById('chbar-l5').textContent = 'Скорость'; document.getElementById('chbar-v5').textContent = info.rate > 0 ? (info.rate * 30).toFixed(1) + '/с' : '—'; } // _openFlask is now handled by _openChemistry('flask') function flaskMetal(type, el) { if (flaskSim) { flaskSim.setMetal(type); flaskSim.reset(); } document.querySelectorAll('.flask-metal-btn').forEach(b => b.classList.remove('active')); if (el) el.classList.add('active'); } function flaskAcid(type, el) { if (flaskSim) flaskSim.setAcid(type); document.querySelectorAll('.flask-acid-btn').forEach(b => b.classList.remove('active')); if (el) el.classList.add('active'); } function flaskConcChange() { const v = +document.getElementById('sl-flask-conc').value; document.getElementById('flask-conc-val').textContent = v + '%'; if (flaskSim) flaskSim.setConc(v / 100); } function flaskTempChange() { const v = +document.getElementById('sl-flask-temp').value; document.getElementById('flask-temp-val').textContent = v + '°C'; if (flaskSim) flaskSim.setEnvTemp(v); } function flaskToggleFlame() { if (!flaskSim) return; flaskSim.toggleFlame(); const active = flaskSim._flameOn; document.getElementById('flask-flame-btn').style.opacity = active ? '1' : '0.5'; document.getElementById('flask-flame-panel').style.opacity = active ? '1' : '0.5'; document.getElementById('flask-flame-panel').style.background = active ? 'rgba(239,71,111,0.22)' : ''; } function flaskTogglePause() { if (!flaskSim) return; flaskSim.togglePause(); document.getElementById('flask-pause-btn').innerHTML = flaskSim._paused ? '' : ''; } function _flaskUpdateUI(info) { if (!info) return; document.getElementById('chbar-l1').textContent = 'Металл'; document.getElementById('chbar-v1').textContent = info.metal; document.getElementById('chbar-l2').textContent = 'Масса'; document.getElementById('chbar-v2').textContent = info.mass + ' г'; document.getElementById('chbar-l3').textContent = 'T (°C)'; document.getElementById('chbar-v3').textContent = info.temp + '°C'; document.getElementById('chbar-l4').textContent = 'pH'; document.getElementById('chbar-v4').textContent = info.pH; document.getElementById('chbar-l5').textContent = 'H₂ (%)'; document.getElementById('chbar-v5').textContent = info.h2pct + '%'; } // _openRedox is now handled by _openChemistry('redox') function redoxRxn(id, el) { document.querySelectorAll('.redox-rxn-btn').forEach(b => b.classList.remove('active')); if (el) el.classList.add('active'); if (rdxSim) { rdxSim.setReaction(id); } } function redoxStart() { if (rdxSim) rdxSim.start(); } function redoxReset() { if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 }); if (rdxSim) rdxSim.reset(); } function _redoxUpdateUI(info) { if (!info) return; const phaseMap = { idle: 'ожидание', mixing: 'смешивание', reacting: 'реакция', done: 'завершена' }; document.getElementById('chbar-l1').textContent = 'Реакция'; document.getElementById('chbar-v1').textContent = info.rxn || '—'; document.getElementById('chbar-l2').textContent = 'Фаза'; document.getElementById('chbar-v2').textContent = phaseMap[info.phase] || info.phase; document.getElementById('chbar-l3').textContent = 'Прогресс'; document.getElementById('chbar-v3').textContent = info.phase === 'done' ? '100%' : info.prog + '%'; document.getElementById('chbar-l4').textContent = 'Электронов'; document.getElementById('chbar-v4').textContent = info.e + ' e⁻'; document.getElementById('chbar-l5').textContent = 'Тип'; document.getElementById('chbar-v5').innerHTML = info.phase === 'done' ? '' : '—'; } // _openIonExchange is now handled by _openChemistry('ionex') function ionexRxn(id, el) { document.querySelectorAll('.ionex-rxn-btn').forEach(b => b.classList.remove('active')); if (el) el.classList.add('active'); if (ioxSim) { ioxSim.setReaction(id); } } function ionexStart() { if (ioxSim) ioxSim.start(); } function ionexReset() { if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 }); if (ioxSim) ioxSim.reset(); } function _ionexUpdateUI(info) { if (!info) return; const phaseMap = { idle: 'ожидание', mixing: 'смешивание', pairing: 'реакция', done: 'завершена' }; const rxn = IonExSim.RXN[ioxSim.rxnId]; document.getElementById('chbar-l1').textContent = 'Реакция'; document.getElementById('chbar-v1').textContent = info.rxn || '—'; document.getElementById('chbar-l2').textContent = 'Фаза'; document.getElementById('chbar-v2').textContent = phaseMap[info.phase] || info.phase; document.getElementById('chbar-l3').textContent = 'Прогресс'; document.getElementById('chbar-v3').textContent = info.phase === 'done' ? '100%' : info.prog + '%'; document.getElementById('chbar-l4').textContent = 'Осадок'; document.getElementById('chbar-v4').textContent = info.precip > 0 ? info.precip + ' ч.' : '—'; document.getElementById('chbar-l5').textContent = 'Продукт'; document.getElementById('chbar-v5').textContent = rxn ? (rxn.sign || '—') : '—'; } /* ════════════════════════════════ ЗАКОНЫ НЬЮТОНА ════════════════════════════════ */ /* ══════════════════════════════ DYNAMICS (unified Newton + Sandbox) ══════════════════════════════ */