'use strict'; /* ==================================================================== IonExSim — Реакции ионного обмена ==================================================================== */ class IonExSim { /* ── Данные реакций ──────────────────────────────────────────────── */ static RXN = { ba_so4: { name: 'BaCl₂ + Na₂SO₄', left: [{ f: 'Ba²⁺', color: '#4FC3F7', count: 7 }, { f: 'Cl⁻', color: '#AED581', count: 14 }], right: [{ f: 'Na⁺', color: '#FFD54F', count: 14 }, { f: 'SO₄²⁻', color: '#F48FB1', count: 7 }], reacts: ['Ba²⁺', 'SO₄²⁻'], spectators: ['Cl⁻', 'Na⁺'], product: { f: 'BaSO₄', color: '#E0E0E0' }, mol: 'BaCl₂ + Na₂SO₄ → BaSO₄↓ + 2NaCl', full_ion: 'Ba²⁺ + 2Cl⁻ + 2Na⁺ + SO₄²⁻ → BaSO₄↓ + 2Na⁺ + 2Cl⁻', net_ion: 'Ba²⁺ + SO₄²⁻ → BaSO₄↓', type: 'precip', pcolor: '#E0E0E0', pname: 'BaSO₄ — белый осадок', sign: '↓', signColor: '#E0E0E0', }, ag_cl: { name: 'AgNO₃ + NaCl', left: [{ f: 'Ag⁺', color: '#E0E0E0', count: 10 }, { f: 'NO₃⁻', color: '#FFCC02', count: 10 }], right: [{ f: 'Na⁺', color: '#FFD54F', count: 10 }, { f: 'Cl⁻', color: '#AED581', count: 10 }], reacts: ['Ag⁺', 'Cl⁻'], spectators: ['NO₃⁻', 'Na⁺'], product: { f: 'AgCl', color: '#F5F5F5' }, mol: 'AgNO₃ + NaCl → AgCl↓ + NaNO₃', full_ion: 'Ag⁺ + NO₃⁻ + Na⁺ + Cl⁻ → AgCl↓ + Na⁺ + NO₃⁻', net_ion: 'Ag⁺ + Cl⁻ → AgCl↓', type: 'precip', pcolor: '#F5F5F5', pname: 'AgCl — белый творожистый осадок', sign: '↓', signColor: '#F5F5F5', }, co3_hcl: { name: 'Na₂CO₃ + HCl', left: [{ f: 'Na⁺', color: '#FFD54F', count: 10 }, { f: 'CO₃²⁻', color: '#CE93D8', count: 5 }], right: [{ f: 'H⁺', color: '#EF5350', count: 10 }, { f: 'Cl⁻', color: '#AED581', count: 10 }], reacts: ['CO₃²⁻', 'H⁺'], spectators: ['Na⁺', 'Cl⁻'], product: { f: 'CO₂↑', color: '#B0BEC5' }, mol: 'Na₂CO₃ + 2HCl → 2NaCl + CO₂↑ + H₂O', full_ion: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2Cl⁻ → 2Na⁺ + 2Cl⁻ + CO₂↑ + H₂O', net_ion: 'CO₃²⁻ + 2H⁺ → CO₂↑ + H₂O', type: 'gas', gcolor: '#B0BEC5', gname: 'CO₂ — углекислый газ', sign: '↑', signColor: '#B0BEC5', }, pb_i: { name: 'Pb(NO₃)₂ + KI', left: [{ f: 'Pb²⁺', color: '#F48FB1', count: 6 }, { f: 'NO₃⁻', color: '#FFCC02', count: 12 }], right: [{ f: 'K⁺', color: '#80CBC4', count: 12 }, { f: 'I⁻', color: '#CE93D8', count: 12 }], reacts: ['Pb²⁺', 'I⁻'], spectators: ['NO₃⁻', 'K⁺'], product: { f: 'PbI₂', color: '#F9A825' }, mol: 'Pb(NO₃)₂ + 2KI → PbI₂↓ + 2KNO₃', full_ion: 'Pb²⁺ + 2NO₃⁻ + 2K⁺ + 2I⁻ → PbI₂↓ + 2K⁺ + 2NO₃⁻', net_ion: 'Pb²⁺ + 2I⁻ → PbI₂↓', type: 'precip', pcolor: '#F9A825', pname: 'PbI₂ — ярко-жёлтый осадок', sign: '↓', signColor: '#F9A825', }, ca_co3: { name: 'CaCl₂ + Na₂CO₃', left: [{ f: 'Ca²⁺', color: '#FF8A65', count: 8 }, { f: 'Cl⁻', color: '#AED581', count: 16 }], right: [{ f: 'Na⁺', color: '#FFD54F', count: 16 }, { f: 'CO₃²⁻', color: '#CE93D8', count: 8 }], reacts: ['Ca²⁺', 'CO₃²⁻'], spectators: ['Cl⁻', 'Na⁺'], product: { f: 'CaCO₃', color: '#F5F5F5' }, mol: 'CaCl₂ + Na₂CO₃ → CaCO₃↓ + 2NaCl', full_ion: 'Ca²⁺ + 2Cl⁻ + 2Na⁺ + CO₃²⁻ → CaCO₃↓ + 2Na⁺ + 2Cl⁻', net_ion: 'Ca²⁺ + CO₃²⁻ → CaCO₃↓', type: 'precip', pcolor: '#F5F5F5', pname: 'CaCO₃ — белый осадок (мел)', sign: '↓', signColor: '#F5F5F5', }, }; constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.rxnId = 'ba_so4'; this._raf = null; this._last = 0; this._t = 0; this._phase = 'idle'; // idle | mixing | pairing | done this._prog = 0; this._stepIdx = 0; this._stepTimer = 0; this._ions = []; this._pairs = []; this._precip = []; this._gas = []; /* edu-tooltip */ this._eduTooltipAge = -1; this._eduTooltipLines = []; /* product label */ this._prodLabelAge = -1; this._prodLabelText = ''; this._prodLabelType = 'precip'; this.W = 0; this.H = 0; this.onUpdate = null; this.fit(); this._initIons(); } 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._initIons(); } setReaction(id) { if (!IonExSim.RXN[id]) return; this.rxnId = id; this.reset(); } reset() { this._phase = 'idle'; this._prog = 0; this._stepIdx = 0; this._stepTimer = 0; this._pairs = []; this._precip = []; this._gas = []; this._initIons(); this.draw(); } _initIons() { const { W, H } = this; const rxn = IonExSim.RXN[this.rxnId]; const bTop = H * 0.10, bBot = H * 0.78; this._ions = []; /* Left beaker ions */ rxn.left.forEach(spec => { for (let i = 0; i < spec.count; i++) { this._ions.push({ x: W * 0.10 + Math.random() * W * 0.36, y: bTop + 20 + Math.random() * (bBot - bTop - 40), vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8, spec: spec.f, color: spec.color, r: 8 + Math.random() * 3, phase: Math.random() * Math.PI * 2, active: true, side: 'L', reacts: rxn.reacts.includes(spec.f), paired: false, }); } }); /* Right beaker ions */ rxn.right.forEach(spec => { for (let i = 0; i < spec.count; i++) { this._ions.push({ x: W * 0.54 + Math.random() * W * 0.36, y: bTop + 20 + Math.random() * (bBot - bTop - 40), vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8, spec: spec.f, color: spec.color, r: 8 + Math.random() * 3, phase: Math.random() * Math.PI * 2, active: true, side: 'R', reacts: rxn.reacts.includes(spec.f), paired: false, }); } }); } start() { if (this._phase !== 'idle') this.reset(); this._phase = 'mixing'; this._prog = 0; if (window.LabFX) { LabFX.sound.play('pour'); const { W, H } = this; LabFX.particles.emit({ ctx: this.ctx, x: W * 0.3, y: H * 0.4, count: 10, color: '#4CC9F0', speed: 40, spread: 2.0, angle: 0, gravity: 120, life: 700, shape: 'splash' }); LabFX.particles.emit({ ctx: this.ctx, x: W * 0.7, y: H * 0.4, count: 10, color: '#EF476F', speed: 40, spread: 2.0, angle: 0, gravity: 120, life: 700, shape: 'splash' }); } 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 = IonExSim.RXN[this.rxnId]; if (this._phase === 'mixing') { this._prog = Math.min(1, this._prog + dt * 0.32); this._ions.forEach(ion => { if (!ion.active) return; const tx = W * 0.5 + (Math.random() - 0.5) * W * 0.72; const ty = H * 0.44 + (Math.random() - 0.5) * H * 0.38; ion.vx += (tx - ion.x) * 0.003 * this._prog; ion.vy += (ty - ion.y) * 0.003 * this._prog; ion.vx += (Math.random() - 0.5) * 0.7; ion.vy += (Math.random() - 0.5) * 0.7; ion.vx *= 0.88; ion.vy *= 0.88; ion.x += ion.vx; ion.y += ion.vy; ion.phase += dt * 2; this._clampIon(ion); }); if (this._prog >= 1) { this._phase = 'pairing'; this._prog = 0; } } if (this._phase === 'pairing') { this._prog = Math.min(1, this._prog + dt * 0.16); this._stepTimer += dt; if (this._stepTimer > 1.5 && this._stepIdx < 2) { this._stepIdx++; this._stepTimer = 0; } this._ions.forEach(ion => { if (!ion.active || ion.paired) return; ion.vx += (Math.random() - 0.5) * 0.8; ion.vy += (Math.random() - 0.5) * 0.8; ion.vx *= 0.88; ion.vy *= 0.88; ion.x += ion.vx; ion.y += ion.vy; ion.phase += dt * 2; this._clampIon(ion); }); /* Pair up reactive ions */ if (Math.random() < 0.10 * (0.5 + this._prog)) this._doPair(rxn); /* Animate pairs */ this._pairs.forEach(p => { p.flashT = Math.max(0, p.flashT - dt * 2.5); if (rxn.type === 'precip') { p.vy = Math.min(p.vy + 0.15, 5); p.y += p.vy; if (p.y >= H * 0.78 && !p.settled) { p.y = H * 0.78; p.vy = 0; p.settled = true; this._precip.push({ x: p.x, y: p.y, r: p.r, id: p.id }); if (window.LabFX) { const now2 = performance.now(); if (!this._fxFizzLast || now2 - this._fxFizzLast > 800) { this._fxFizzLast = now2; LabFX.sound.play('fizz'); } LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: H * 0.78, count: 5, color: '#888888', speed: 15, spread: 1.8, angle: -Math.PI / 2, gravity: 30, life: 1200, shape: 'dust' }); } } } else if (rxn.type === 'gas') { p.vy = Math.max(p.vy - 0.08, -4); p.y += p.vy; p.alpha = Math.max(0, p.alpha - 0.004); } }); this._pairs = this._pairs.filter(p => !p.settled && (p.alpha === undefined || p.alpha > 0)); if (this._prog >= 1) { this._phase = 'done'; this._stepIdx = 2; } } if (this._phase === 'done') { this._ions.forEach(ion => { if (!ion.active || ion.paired) return; ion.vx += (Math.random() - 0.5) * 0.45; ion.vy += (Math.random() - 0.5) * 0.45; ion.vx *= 0.92; ion.vy *= 0.92; ion.x += ion.vx; ion.y += ion.vy; ion.phase += dt; this._clampIon(ion); }); /* trigger product label + edu-tooltip once on transition to done */ if (window.ChemVisuals && this._prodLabelAge < 0) { const rxn = IonExSim.RXN[this.rxnId]; if (rxn.type === 'precip') { this._prodLabelText = (rxn.pname || '') + ' '; this._prodLabelType = 'precip'; this._prodLabelAge = 0; } else if (rxn.type === 'gas') { this._prodLabelText = (rxn.gname || '') + ' '; this._prodLabelType = 'gas'; this._prodLabelAge = 0; } /* edu tooltip from reaction net_ion */ if (this._eduTooltipAge < 0) { const netIon = (rxn.net_ion || '').replace(/→/g, '->'); this._eduTooltipLines = [ 'Краткое ионное уравнение:', netIon.length > 32 ? netIon.slice(0, 32) + '...' : netIon, (rxn.pname || rxn.gname || '').slice(0, 34), ].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; } this.draw(); if (this.onUpdate) this.onUpdate(this.info()); } _clampIon(ion) { const { W, H } = this; const bTop = H * 0.10, bBot = H * 0.78; if (ion.x < ion.r + 6) { ion.x = ion.r + 6; ion.vx *= -0.5; } if (ion.x > W - ion.r - 6) { ion.x = W - ion.r - 6; ion.vx *= -0.5; } if (ion.y < bTop + ion.r) { ion.y = bTop + ion.r; ion.vy *= -0.5; } if (ion.y > bBot - ion.r) { ion.y = bBot - ion.r; ion.vy *= -0.5; } } _doPair(rxn) { const r1 = rxn.reacts[0], r2 = rxn.reacts[1]; const pool1 = this._ions.filter(i => i.active && !i.paired && i.spec === r1); const pool2 = this._ions.filter(i => i.active && !i.paired && i.spec === r2); if (!pool1.length || !pool2.length) return; const a = pool1[Math.floor(Math.random() * pool1.length)]; const b = pool2[Math.floor(Math.random() * pool2.length)]; a.paired = true; b.paired = true; a.active = false; b.active = false; this._pairs.push({ id: this._t + Math.random(), x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, vy: rxn.type === 'gas' ? -2 : 0, r: 7, flashT: 1, settled: false, alpha: 1, }); } /* ── Рендеринг ──────────────────────────────────────────────────── */ draw() { const { ctx, W, H } = this; const rxn = IonExSim.RXN[this.rxnId]; 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(); } } /* desk */ if (window.ChemVisuals) { ChemVisuals.drawDeskBackground(ctx, W, H, H * 0.80); } if (this._phase === 'idle') { this._drawTwoBeakers(ctx, W, H, rxn); } else { this._drawSingleBeaker(ctx, W, H); } this._drawIons(ctx, rxn); this._drawPairs(ctx, rxn); this._drawPrecipitate(ctx, rxn); this._drawPanel(ctx, W, H, rxn); if (window.LabFX) LabFX.particles.draw(ctx); /* animated product label */ if (window.ChemVisuals && this._prodLabelAge >= 0) { const labelY = this._prodLabelType === 'gas' ? H * 0.12 : H * 0.74; ChemVisuals.drawProductLabel(ctx, W / 2, labelY, this._prodLabelText, this._prodLabelType, this._prodLabelAge); if (this._prodLabelType === 'gas') { ChemVisuals.animateGasBubbles(ctx, W / 2, H * 0.15, '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); } } _drawTwoBeakers(ctx, W, H, rxn) { const drawB = (x, y, w, h, ions) => { ctx.save(); ctx.strokeStyle = 'rgba(120,185,255,0.55)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + h); ctx.lineTo(x + w, y + h); ctx.lineTo(x + w, y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x - 4, y); ctx.lineTo(x + w + 4, y); ctx.stroke(); /* Rim highlight */ const hlg = ctx.createLinearGradient(x, y, x + 14, y + h); hlg.addColorStop(0, 'rgba(200,230,255,0.15)'); hlg.addColorStop(1, 'rgba(200,230,255,0.02)'); ctx.strokeStyle = hlg; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(x + 7, y + 6); ctx.lineTo(x + 7, y + h - 6); ctx.stroke(); /* Formula label */ ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; const label = ions.map(s => s.f).join(', '); ctx.fillText(label, x + w / 2, y - 4); ctx.restore(); }; const bTop = H * 0.10, bH = H * 0.70; drawB(W * 0.04, bTop, W * 0.40, bH, rxn.left); drawB(W * 0.56, bTop, W * 0.40, bH, rxn.right); /* Mix arrow */ ctx.save(); const mx = W * 0.50, my = H * 0.44; ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 2; ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.beginPath(); ctx.moveTo(mx - 16, my); ctx.lineTo(mx + 16, my); ctx.stroke(); ctx.beginPath(); ctx.moveTo(mx + 10, my - 5); ctx.lineTo(mx + 16, my); ctx.lineTo(mx + 10, my + 5); ctx.fill(); ctx.restore(); } _drawSingleBeaker(ctx, W, H) { const bx = W * 0.04, by = H * 0.08, bw = W * 0.92, bh = H * 0.72; 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(); 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(); } _drawIons(ctx, rxn) { this._ions.forEach(ion => { if (!ion.active) return; const isSpec = rxn.spectators.includes(ion.spec); ctx.save(); ctx.globalAlpha = (isSpec && this._phase !== 'idle') ? 0.40 : 0.88; ctx.shadowColor = ion.color; ctx.shadowBlur = 7 + Math.sin(ion.phase) * 3; ctx.beginPath(); ctx.arc(ion.x, ion.y, ion.r, 0, Math.PI * 2); ctx.fillStyle = ion.color; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.stroke(); ctx.shadowBlur = 0; ctx.globalAlpha = 1; /* Formula label */ const fs = Math.min(Math.round(ion.r * 0.60), 9); ctx.fillStyle = 'rgba(0,0,0,0.80)'; ctx.font = `bold ${fs}px monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(ion.spec, ion.x, ion.y); ctx.restore(); }); } _drawPairs(ctx, rxn) { const pcolor = rxn.pcolor || rxn.gcolor || '#FFF'; this._pairs.forEach(p => { ctx.save(); const alpha = p.alpha !== undefined ? p.alpha : 1; ctx.globalAlpha = alpha * 0.92; ctx.shadowColor = p.flashT > 0 ? '#FFFFFF' : pcolor; ctx.shadowBlur = p.flashT > 0 ? 28 * p.flashT : 8; 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})` : pcolor; ctx.fill(); ctx.shadowBlur = 0; ctx.globalAlpha = 1; /* Product label */ ctx.fillStyle = 'rgba(0,0,0,0.80)'; ctx.font = 'bold 7px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(rxn.product.f, p.x, p.y); ctx.restore(); }); } _drawPrecipitate(ctx, rxn) { if (rxn.type !== 'precip' || !this._precip.length) return; ctx.save(); this._precip.forEach(p => { ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 3; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fillStyle = rxn.pcolor; ctx.fill(); }); ctx.restore(); if (this._precip.length > 4) { 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(); } } _drawPanel(ctx, W, H, rxn) { const py = H * 0.82; ctx.fillStyle = 'rgba(7,7,26,0.95)'; ctx.fillRect(0, py, W, H - py); ctx.strokeStyle = 'rgba(100,165,255,0.22)'; 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.mol, col: '#B0BEC5' }, { lbl: 'Полное ионное:', txt: rxn.full_ion, col: '#CE93D8' }, { lbl: 'Краткое ионное:', txt: rxn.net_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.29); 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 = rxn.signColor; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'top'; ctx.shadowColor = rxn.signColor; ctx.shadowBlur = 8; const label = rxn.type === 'precip' ? `✓ ${rxn.sign} осадок` : `✓ ${rxn.sign} газ`; ctx.fillText(label, W - 14, py + 3); ctx.restore(); } } info() { const rxn = IonExSim.RXN[this.rxnId]; return { rxn: rxn.name, phase: this._phase, prog: Math.round(this._prog * 100), precip: this._precip.length, }; } }