'use strict'; /* ==================================================================== RedoxSim — Окислительно-восстановительные реакции ==================================================================== */ class RedoxSim { /* ── Данные реакций ──────────────────────────────────────────────── */ static RXN = { fe_cu: { name: 'Fe + CuSO₄', reducer: { f: 'Fe', name: 'Железо', color: '#A0856A', ox: 0 }, oxidizer: { f: 'Cu²⁺', name: 'Ион меди', color: '#29B6F6', ox: 2 }, prod_r: { f: 'Fe²⁺', color: '#66BB6A', ox: 2 }, prod_o: { f: 'Cu', color: '#C87840', ox: 0, solid: true }, e: 2, half_r: 'Fe⁰ – 2e⁻ Fe²⁺ окисление', half_o: 'Cu²⁺ + 2e⁻ Cu⁰ восстановление', eq_ion: 'Fe + Cu²⁺ Fe²⁺ + Cu', eq_mol: 'Fe + CuSO₄ FeSO₄ + Cu', sol_a: '#1565C040', sol_b: '#2E7D3230', precip: true, pcolor: '#C87840', pname: 'медь Cu', }, zn_hcl: { name: 'Zn + HCl', reducer: { f: 'Zn', name: 'Цинк', color: '#90A4AE', ox: 0 }, oxidizer: { f: 'H⁺', name: 'Ион H⁺', color: '#EF5350', ox: 1 }, prod_r: { f: 'Zn²⁺', color: '#80CBC4', ox: 2 }, prod_o: { f: 'H₂', color: '#EEEEEE', ox: 0, gas: true }, e: 2, half_r: 'Zn⁰ – 2e⁻ Zn²⁺ окисление', half_o: '2H⁺ + 2e⁻ H₂ восстановление', eq_ion: 'Zn + 2H⁺ Zn²⁺ + H₂', eq_mol: 'Zn + 2HCl ZnCl₂ + H₂', sol_a: '#EF525228', sol_b: '#E0F2F118', gas: true, gcolor: '#CFD8DC', gname: 'водород H₂', }, cl2_ki: { name: 'Cl₂ + KI', reducer: { f: 'I⁻', name: 'Иодид-ион', color: '#CE93D8', ox: -1 }, oxidizer: { f: 'Cl₂', name: 'Хлор', color: '#D4E157', ox: 0 }, prod_r: { f: 'I₂', color: '#6A1B9A', ox: 0, solid: true }, prod_o: { f: 'Cl⁻', color: '#AED581', ox: -1 }, e: 1, half_r: '2I⁻ – 2e⁻ I₂ окисление', half_o: 'Cl₂ + 2e⁻ 2Cl⁻ восстановление', eq_ion: 'Cl₂ + 2I⁻ I₂ + 2Cl⁻', eq_mol: 'Cl₂ + 2KI I₂ + 2KCl', sol_a: '#7B1FA230', sol_b: '#F9A82520', precip: true, pcolor: '#6A1B9A', pname: 'йод I₂', }, kmno4: { name: 'KMnO₄ + FeSO₄', reducer: { f: 'Fe²⁺', name: 'Ион Fe²⁺', color: '#66BB6A', ox: 2 }, oxidizer: { f: 'MnO₄⁻', name: 'Перманганат', color: '#AB47BC', ox: 7 }, prod_r: { f: 'Fe³⁺', color: '#FFA726', ox: 3 }, prod_o: { f: 'Mn²⁺', color: '#FFF9C4', ox: 2 }, e: 5, half_r: 'Fe²⁺ – e⁻ Fe³⁺ (×5) окисление', half_o: 'MnO₄⁻+8H⁺+5e⁻Mn²⁺+4H₂O восстановление', eq_ion: 'MnO₄⁻ + 5Fe²⁺ + 8H⁺ Mn²⁺ + 5Fe³⁺ + 4H₂O', eq_mol: '2KMnO₄ + 10FeSO₄ + 8H₂SO₄ 2MnSO₄ + 5Fe₂(SO₄)₃ + K₂SO₄ + 8H₂O', sol_a: '#7B1FA250', sol_b: '#F9A82515', colorChange: true, newSolColor: '#FFF9C428', newName: 'бесцветный MnSO₄', }, cu_fecl3: { name: 'Cu + FeCl₃', reducer: { f: 'Cu', name: 'Медь', color: '#C87840', ox: 0 }, oxidizer: { f: 'Fe³⁺', name: 'Ион Fe³⁺', color: '#FFA726', ox: 3 }, prod_r: { f: 'Cu²⁺', color: '#29B6F6', ox: 2 }, prod_o: { f: 'Fe²⁺', color: '#66BB6A', ox: 2 }, e: 1, half_r: 'Cu⁰ – 2e⁻ Cu²⁺ окисление', half_o: '2Fe³⁺ + 2e⁻ 2Fe²⁺ восстановление', eq_ion: 'Cu + 2Fe³⁺ Cu²⁺ + 2Fe²⁺', eq_mol: 'Cu + 2FeCl₃ CuCl₂ + 2FeCl₂', sol_a: '#E6510018', sol_b: '#1565C018', colorChange: true, newSolColor: '#1565C030', newName: 'синий CuCl₂', }, }; constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.rxnId = 'fe_cu'; this._raf = null; this._last = 0; this._t = 0; this._phase = 'idle'; // idle | mixing | reacting | done this._prog = 0; this._colorT = 0; this._stepIdx = 0; this._stepTimer = 0; this._eParts = []; this._rParts = []; this._oParts = []; this._precip = []; this._gas = []; /* edu-tooltip + product labels */ this._eduTooltipAge = -1; this._eduTooltipLines = []; this._prodLabelAge = -1; this._prodLabelText = ''; this._prodLabelType = 'precip'; this.W = 0; this.H = 0; this.onUpdate = null; this.fit(); this._initParts(); } fit() { const dpr = window.devicePixelRatio || 1; const W = this.canvas.offsetWidth || 600; const H = this.canvas.offsetHeight || 400; this.canvas.width = Math.round(W * dpr); this.canvas.height = Math.round(H * dpr); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = W; this.H = H; this._initParts(); } setReaction(id) { if (!RedoxSim.RXN[id]) return; this.rxnId = id; this.reset(); } reset() { this._phase = 'idle'; this._prog = 0; this._colorT = 0; this._stepIdx = 0; this._stepTimer = 0; this._eParts = []; this._precip = []; this._gas = []; this._initParts(); this.draw(); } _initParts() { const { W, H } = this; const N = 16; this._rParts = Array.from({ length: N }, () => ({ x: W * 0.22 + (Math.random() - 0.5) * W * 0.22, y: H * 0.42 + (Math.random() - 0.5) * H * 0.34, vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6, r: 11 + Math.random() * 4, phase: Math.random() * Math.PI * 2, trans: false, flashT: 0, })); this._oParts = Array.from({ length: N }, () => ({ x: W * 0.78 + (Math.random() - 0.5) * W * 0.22, y: H * 0.42 + (Math.random() - 0.5) * H * 0.34, vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6, r: 11 + Math.random() * 4, phase: Math.random() * Math.PI * 2, trans: false, flashT: 0, })); } start() { if (this._phase !== 'idle') this.reset(); this._phase = 'mixing'; this._prog = 0; if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.8 }); if (this._raf) return; this._last = performance.now(); const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; this._raf = requestAnimationFrame(loop); } stop() { cancelAnimationFrame(this._raf); this._raf = null; } /* ── Физика ─────────────────────────────────────────────────────── */ _tick(t) { const dt = Math.min((t - this._last) / 1000, 0.05); this._last = t; this._t += dt; if (window.LabFX) LabFX.particles.update(dt); const { W, H } = this; const rxn = RedoxSim.RXN[this.rxnId]; if (this._phase === 'mixing') { this._prog = Math.min(1, this._prog + dt * 0.38); const all = [...this._rParts, ...this._oParts]; all.forEach(p => { const tx = W * 0.5 + (Math.random() - 0.5) * W * 0.52; const ty = H * 0.44 + (Math.random() - 0.5) * H * 0.34; p.vx += (tx - p.x) * 0.003 * this._prog; p.vy += (ty - p.y) * 0.003 * this._prog; p.vx += (Math.random() - 0.5) * 0.5; p.vy += (Math.random() - 0.5) * 0.5; p.vx *= 0.90; p.vy *= 0.90; p.x += p.vx; p.y += p.vy; p.phase += dt * 1.5; this._clamp(p); }); if (this._prog >= 1) { this._phase = 'reacting'; this._prog = 0; } } if (this._phase === 'reacting') { this._prog = Math.min(1, this._prog + dt * 0.14); this._colorT = this._prog; this._stepTimer += dt; if (this._stepTimer > 1.6 && this._stepIdx < 3) { this._stepIdx++; this._stepTimer = 0; } const all = [...this._rParts, ...this._oParts]; all.forEach(p => { p.vx += (Math.random() - 0.5) * 0.9; p.vy += (Math.random() - 0.5) * 0.9; p.vx *= 0.87; p.vy *= 0.87; p.x += p.vx; p.y += p.vy; p.phase += dt * 2; p.flashT = Math.max(0, p.flashT - dt * 3); this._clamp(p); }); /* Transform particles proportional to progress */ const rT = Math.floor(this._prog * this._rParts.length); const oT = Math.floor(this._prog * this._oParts.length); this._rParts.forEach((p, i) => { if (i < rT && !p.trans) { p.trans = true; p.flashT = 1; } }); this._oParts.forEach((p, i) => { if (i < oT && !p.trans) { p.trans = true; p.flashT = 1; if (rxn.precip) this._precip.push({ x: p.x, y: p.y, vy: 0, r: 3 + Math.random() * 3, settled: false }); if (rxn.gas) this._gas.push({ x: p.x, y: p.y, vy: -(1.5 + Math.random()), vx: (Math.random() - 0.5) * 0.5, r: 2 + Math.random() * 3, alpha: 1 }); } }); if (Math.random() < 0.22 && this._prog > 0.05) this._spawnE(); if (this._prog >= 1) { this._phase = 'done'; this._stepIdx = 3; } } if (this._phase === 'done') { const all = [...this._rParts, ...this._oParts]; all.forEach(p => { p.vx += (Math.random() - 0.5) * 0.45; p.vy += (Math.random() - 0.5) * 0.45; p.vx *= 0.92; p.vy *= 0.92; p.x += p.vx; p.y += p.vy; p.phase += dt; this._clamp(p); }); /* trigger product label + edu-tooltip once */ if (window.ChemVisuals && this._prodLabelAge < 0) { const rxn = RedoxSim.RXN[this.rxnId]; if (rxn.precip) { this._prodLabelText = (rxn.pname || '') + ' '; this._prodLabelType = 'precip'; this._prodLabelAge = 0; } else if (rxn.gas) { this._prodLabelText = (rxn.gname || '') + ' '; this._prodLabelType = 'gas'; this._prodLabelAge = 0; } if (this._eduTooltipAge < 0 && rxn.eq_mol) { const stripSVG = s => (s || '').replace(/<[^>]+>/g, '->'); const eqClean = stripSVG(rxn.eq_ion || rxn.eq_mol).slice(0, 38); this._eduTooltipLines = [ (rxn.name || '').slice(0, 34), 'e⁻: от восстановителя к окислителю', eqClean, ].filter(Boolean).slice(0, 4); this._eduTooltipAge = 0; } } } /* advance ages */ if (this._eduTooltipAge >= 0) { this._eduTooltipAge += dt / 4.0; if (this._eduTooltipAge >= 1.0) this._eduTooltipAge = -1; } if (this._prodLabelAge >= 0) { this._prodLabelAge += dt / 3.0; if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1; } /* Electrons — quadratic bezier arc */ this._eParts = this._eParts.filter(e => e.t < 1); this._eParts.forEach(e => { e.t = Math.min(1, e.t + dt * e.spd); const u = e.t; e.x = (1-u)*(1-u)*e.x0 + 2*(1-u)*u*e.mx + u*u*e.x1; e.y = (1-u)*(1-u)*e.y0 + 2*(1-u)*u*e.my + u*u*e.y1; e.alpha = u < 0.1 ? u * 10 : u > 0.85 ? (1 - u) / 0.15 : 1; }); /* Precipitate */ this._precip.forEach(p => { if (!p.settled) { p.vy = Math.min(p.vy + 0.15, 5); p.y += p.vy; if (p.y >= H * 0.78) { p.y = H * 0.78; p.vy = 0; p.settled = true; } } }); /* Gas */ this._gas.forEach(b => { b.y += b.vy; b.x += b.vx; b.vy -= 0.01; b.alpha -= 0.005; }); this._gas = this._gas.filter(b => b.alpha > 0 && b.y > 10); this.draw(); if (this.onUpdate) this.onUpdate(this.info()); } _clamp(p) { const { W, H } = this; const bot = H * 0.78; if (p.x < p.r + 6) { p.x = p.r + 6; p.vx *= -0.5; } if (p.x > W - p.r - 6) { p.x = W - p.r - 6; p.vx *= -0.5; } if (p.y < p.r + 6) { p.y = p.r + 6; p.vy *= -0.5; } if (p.y > bot - p.r) { p.y = bot - p.r; p.vy *= -0.5; } } _spawnE() { const freeR = this._rParts.filter(p => !p.trans); const freeO = this._oParts.filter(p => !p.trans); if (!freeR.length || !freeO.length) return; const rp = freeR[Math.floor(Math.random() * freeR.length)]; const op = freeO[Math.floor(Math.random() * freeO.length)]; const mx = (rp.x + op.x) / 2; const my = Math.min(rp.y, op.y) - 45 - Math.random() * 40; this._eParts.push({ x0: rp.x, y0: rp.y, x1: op.x, y1: op.y, mx, my, x: rp.x, y: rp.y, t: 0, spd: 0.65 + Math.random() * 0.45, alpha: 0, }); // LabFX: electron transfer spark at the midpoint if (window.LabFX) { LabFX.particles.emit({ ctx: this.ctx, x: mx, y: my, count: 2, color: '#06D6E0', speed: 30, spread: 3.14, angle: 0, gravity: 0, life: 200, shape: 'spark', glow: true }); } } /* ── Рендеринг ──────────────────────────────────────────────────── */ draw() { const { ctx, W, H } = this; const rxn = RedoxSim.RXN[this.rxnId]; /* Background */ ctx.fillStyle = '#07071A'; ctx.fillRect(0, 0, W, H); /* Dot grid */ ctx.fillStyle = 'rgba(255,255,255,0.07)'; for (let x = 0; x < W; x += 28) { for (let y = 0; y < H; y += 28) { ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill(); } } /* Solution tint */ const bx = W * 0.04, bw = W * 0.92, bTop = H * 0.08, bBot = H * 0.80; if (this._phase === 'idle') { if (rxn.sol_a) { ctx.save(); ctx.fillStyle = rxn.sol_a; ctx.fillRect(bx, bTop, bw / 2, bBot - bTop); ctx.restore(); } if (rxn.sol_b) { ctx.save(); ctx.fillStyle = rxn.sol_b; ctx.fillRect(bx + bw / 2, bTop, bw / 2, bBot - bTop); ctx.restore(); } /* Dashed divider */ ctx.save(); ctx.setLineDash([6, 5]); ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(W / 2, bTop + 4); ctx.lineTo(W / 2, bBot - 4); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); /* Zone labels */ ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = 'bold 12px sans-serif'; ctx.fillText(rxn.reducer.name, W * 0.22, bTop + 8); ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.font = '10px sans-serif'; ctx.fillText('восстановитель', W * 0.22, bTop + 26); ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = 'bold 12px sans-serif'; ctx.fillText(rxn.oxidizer.name, W * 0.78, bTop + 8); ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.font = '10px sans-serif'; ctx.fillText('окислитель', W * 0.78, bTop + 26); ctx.restore(); } else if (this._colorT > 0) { if (rxn.sol_a) { ctx.save(); ctx.globalAlpha = 1 - this._colorT * 0.7; ctx.fillStyle = rxn.sol_a; ctx.fillRect(bx, bTop, bw, bBot - bTop); ctx.restore(); } if (rxn.colorChange && rxn.newSolColor) { ctx.save(); ctx.globalAlpha = this._colorT * 0.55; ctx.fillStyle = rxn.newSolColor; ctx.fillRect(bx, bTop, bw, bBot - bTop); ctx.restore(); } } /* desk */ if (window.ChemVisuals) { ChemVisuals.drawDeskBackground(ctx, W, H, H * 0.82); ChemVisuals.drawVesselShadow(ctx, W / 2, H * 0.82, W * 0.38); } this._drawBeaker(ctx, W, H); this._drawParticles(ctx, rxn); this._drawElectrons(ctx); if (rxn.precip) this._drawPrecip(ctx, rxn); if (rxn.gas) this._drawGas(ctx, rxn); this._drawPanel(ctx, W, H, rxn); if (window.LabFX) LabFX.particles.draw(this.ctx); /* animated product label */ if (window.ChemVisuals && this._prodLabelAge >= 0) { const labelY = this._prodLabelType === 'gas' ? H * 0.12 : H * 0.76; ChemVisuals.drawProductLabel(ctx, W / 2, labelY, this._prodLabelText, this._prodLabelType, this._prodLabelAge); if (this._prodLabelType === 'gas') { ChemVisuals.animateGasBubbles(ctx, W / 2, H * 0.16, rxn.gcolor || 'rgba(200,235,255,0.8)', this._t); } else { ChemVisuals.animatePrecipitateFall(ctx, W / 2, H * 0.72, rxn.pcolor || '#CCC', this._t); } } /* edu tooltip */ if (window.ChemVisuals && this._eduTooltipAge >= 0 && this._eduTooltipLines.length > 0) { ChemVisuals.drawEduTooltip(ctx, W / 2, H * 0.10, 210, this._eduTooltipLines, this._eduTooltipAge); } } _drawBeaker(ctx, W, H) { const bx = W * 0.04, by = H * 0.08, bw = W * 0.92, bh = H * 0.73; ctx.save(); ctx.strokeStyle = 'rgba(120,185,255,0.60)'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx, by + bh); ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw, by); ctx.stroke(); ctx.beginPath(); ctx.moveTo(bx - 5, by); ctx.lineTo(bx + bw + 5, by); ctx.stroke(); /* Left highlight */ const hlg = ctx.createLinearGradient(bx, by, bx + 18, by + bh); hlg.addColorStop(0, 'rgba(200,230,255,0.18)'); hlg.addColorStop(1, 'rgba(200,230,255,0.02)'); ctx.strokeStyle = hlg; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(bx + 8, by + 8); ctx.lineTo(bx + 8, by + bh - 8); ctx.stroke(); ctx.restore(); } _drawParticles(ctx, rxn) { const draw1 = (p, spec, prod) => { const s = p.trans ? prod : spec; ctx.save(); ctx.shadowColor = p.flashT > 0 ? '#FFFFFF' : s.color; ctx.shadowBlur = p.flashT > 0 ? 28 * p.flashT : 8 + Math.sin(p.phase) * 3; ctx.globalAlpha = 0.88; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fillStyle = p.flashT > 0 ? `rgba(255,255,255,${p.flashT * 0.9})` : s.color; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1; ctx.stroke(); ctx.shadowBlur = 0; ctx.globalAlpha = 1; /* Oxidation state */ const ox = p.trans ? prod.ox : spec.ox; const oxStr = ox > 0 ? `+${ox}` : ox < 0 ? `${ox}` : '0'; ctx.fillStyle = p.trans ? '#FFD166' : 'rgba(255,255,255,0.88)'; ctx.font = `bold ${Math.round(p.r * 0.78)}px monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(oxStr, p.x, p.y); ctx.restore(); }; this._rParts.forEach(p => draw1(p, rxn.reducer, rxn.prod_r)); this._oParts.forEach(p => draw1(p, rxn.oxidizer, rxn.prod_o)); } _drawElectrons(ctx) { this._eParts.forEach(e => { ctx.save(); ctx.globalAlpha = e.alpha; ctx.shadowColor = '#4FC3F7'; ctx.shadowBlur = 16; ctx.beginPath(); ctx.arc(e.x, e.y, 5.5, 0, Math.PI * 2); ctx.fillStyle = '#4FC3F7'; ctx.fill(); ctx.shadowBlur = 0; ctx.fillStyle = 'rgba(255,255,255,0.95)'; ctx.font = 'bold 7px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('e⁻', e.x, e.y); ctx.restore(); }); } _drawPrecip(ctx, rxn) { if (!this._precip.length) return; ctx.save(); this._precip.forEach(p => { ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 4; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fillStyle = rxn.pcolor; ctx.fill(); }); ctx.restore(); /* Label when settled */ const settled = this._precip.filter(p => p.settled); if (settled.length > 3) { ctx.save(); ctx.fillStyle = rxn.pcolor; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 6; ctx.fillText(`↓ ${rxn.pname}`, this.W / 2, this.H * 0.80 - 4); ctx.restore(); } } _drawGas(ctx, rxn) { this._gas.forEach(b => { ctx.save(); ctx.globalAlpha = b.alpha * 0.75; ctx.shadowColor = rxn.gcolor; ctx.shadowBlur = 5; ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.strokeStyle = rxn.gcolor; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); }); const count = this._gas.length; if (count > 2) { ctx.save(); ctx.fillStyle = rxn.gcolor; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.shadowColor = rxn.gcolor; ctx.shadowBlur = 6; ctx.fillText(`↑ ${rxn.gname}`, this.W / 2, this.H * 0.12); ctx.restore(); } } _drawPanel(ctx, W, H, rxn) { const py = H * 0.82; ctx.fillStyle = 'rgba(7,7,26,0.94)'; ctx.fillRect(0, py, W, H - py); ctx.strokeStyle = 'rgba(100,165,255,0.25)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke(); if (this._phase === 'idle') { ctx.fillStyle = '#37474F'; ctx.font = '11px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('← Нажми «Начать» для запуска реакции →', W / 2, py + (H - py) / 2); return; } const steps = [ { lbl: 'Молекулярное:', txt: rxn.eq_mol, col: '#B0BEC5' }, { lbl: 'Окисление:', txt: rxn.half_r, col: '#EF476F' }, { lbl: 'Восстановление:', txt: rxn.half_o, col: '#4CC9F0' }, { lbl: 'Ионное:', txt: rxn.eq_ion, col: '#FFD166' }, ]; const panH = H - py; const n = Math.min(this._stepIdx + 1, steps.length); for (let i = 0; i < n; i++) { const s = steps[i]; const y = py + 11 + i * (panH * 0.22); ctx.save(); if (i === this._stepIdx && this._phase !== 'done') { ctx.fillStyle = 'rgba(255,255,255,0.04)'; ctx.fillRect(8, y - 9, W - 16, 20); } ctx.fillStyle = s.col; ctx.font = 'bold 9.5px monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(s.lbl, 14, y); ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)'; ctx.font = '9.5px monospace'; ctx.fillText(s.txt, 14 + ctx.measureText(s.lbl).width + 8, y); ctx.restore(); } if (this._phase === 'done') { ctx.save(); ctx.fillStyle = '#7BF5A4'; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'top'; ctx.shadowColor = '#7BF5A4'; ctx.shadowBlur = 8; ctx.fillText('✓ Реакция завершена', W - 14, py + 3); ctx.restore(); } } info() { const rxn = RedoxSim.RXN[this.rxnId]; return { rxn: rxn.name, phase: this._phase, prog: Math.round((this._phase === 'reacting' ? this._prog : this._phase === 'done' ? 1 : 0) * 100), e: rxn.e, }; } }