'use strict'; /* ═══════════════════════════════════════════════════════════════════════ StoichSim — «Стехиометрия» Визуальный интерактивный калькулятор стехиометрии с анимацией. ═══════════════════════════════════════════════════════════════════════ */ class StoichSim { /* ── Рецепты реакций ─────────────────────────────────────────────── */ static RECIPES = [ { name: 'Zn + 2HCl → ZnCl₂ + H₂↑', label: 'Zn + HCl', reactants: [ { sym: 'Zn', coef: 1, M: 65.38, phase: 's', color: '#9BB8CC' }, { sym: 'HCl', coef: 2, M: 36.46, phase: 'aq', color: '#78D278' }, ], products: [ { sym: 'ZnCl₂', coef: 1, M: 136.28, phase: 'aq', color: '#4CC9F0' }, { sym: 'H₂', coef: 1, M: 2.016, phase: 'g', color: '#FFD166' }, ], }, { name: '2H₂ + O₂ → 2H₂O', label: 'H₂ + O₂', reactants: [ { sym: 'H₂', coef: 2, M: 2.016, phase: 'g', color: '#FFD166' }, { sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' }, ], products: [ { sym: 'H₂O', coef: 2, M: 18.015, phase: 'l', color: '#6EB4D7' }, ], }, { name: 'CH₄ + 2O₂ → CO₂ + 2H₂O', label: 'Горение метана', reactants: [ { sym: 'CH₄', coef: 1, M: 16.043, phase: 'g', color: '#FFD166' }, { sym: 'O₂', coef: 2, M: 31.998, phase: 'g', color: '#EF476F' }, ], products: [ { sym: 'CO₂', coef: 1, M: 44.01, phase: 'g', color: '#9B5DE5' }, { sym: 'H₂O', coef: 2, M: 18.015, phase: 'g', color: '#6EB4D7' }, ], }, { name: 'N₂ + 3H₂ → 2NH₃', label: 'Синтез аммиака', reactants: [ { sym: 'N₂', coef: 1, M: 28.014, phase: 'g', color: '#9B5DE5' }, { sym: 'H₂', coef: 3, M: 2.016, phase: 'g', color: '#FFD166' }, ], products: [ { sym: 'NH₃', coef: 2, M: 17.031, phase: 'g', color: '#06D6E0' }, ], }, { name: '2Al + 3CuSO₄ → Al₂(SO₄)₃ + 3Cu', label: 'Al + CuSO₄', reactants: [ { sym: 'Al', coef: 2, M: 26.982, phase: 's', color: '#D6D6D6' }, { sym: 'CuSO₄',coef: 3, M: 159.60, phase: 'aq', color: '#4CC9F0' }, ], products: [ { sym: 'Al₂(SO₄)₃', coef: 1, M: 342.15, phase: 'aq', color: '#B8D4F0' }, { sym: 'Cu', coef: 3, M: 63.546, phase: 's', color: '#C87840' }, ], }, { name: '2Mg + O₂ → 2MgO', label: 'Горение магния', reactants: [ { sym: 'Mg', coef: 2, M: 24.305, phase: 's', color: '#E8E8E8' }, { sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' }, ], products: [ { sym: 'MgO', coef: 2, M: 40.304, phase: 's', color: '#FFFFFF' }, ], }, { name: 'CaCO₃ → CaO + CO₂↑', label: 'Разложение мела', reactants: [ { sym: 'CaCO₃', coef: 1, M: 100.086, phase: 's', color: '#F0F0F0' }, ], products: [ { sym: 'CaO', coef: 1, M: 56.077, phase: 's', color: '#D4C4A0' }, { sym: 'CO₂', coef: 1, M: 44.01, phase: 'g', color: '#9B5DE5' }, ], }, { name: 'HCl + NaOH → NaCl + H₂O', label: 'Нейтрализация', reactants: [ { sym: 'HCl', coef: 1, M: 36.46, phase: 'aq', color: '#78D278' }, { sym: 'NaOH', coef: 1, M: 40.0, phase: 'aq', color: '#7BF5A4' }, ], products: [ { sym: 'NaCl', coef: 1, M: 58.44, phase: 'aq', color: '#FFFFFF' }, { sym: 'H₂O', coef: 1, M: 18.015, phase: 'l', color: '#6EB4D7' }, ], }, { name: '2KMnO₄ → K₂MnO₄ + MnO₂ + O₂↑', label: 'Разложение KMnO₄', reactants: [ { sym: 'KMnO₄', coef: 2, M: 158.034, phase: 's', color: '#9B59B6' }, ], products: [ { sym: 'K₂MnO₄', coef: 1, M: 197.132, phase: 's', color: '#27AE60' }, { sym: 'MnO₂', coef: 1, M: 86.937, phase: 's', color: '#1A1A2E' }, { sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' }, ], }, { name: 'C₂H₅OH + 3O₂ → 2CO₂ + 3H₂O', label: 'Горение спирта', reactants: [ { sym: 'C₂H₅OH', coef: 1, M: 46.068, phase: 'l', color: '#FFD166' }, { sym: 'O₂', coef: 3, M: 31.998, phase: 'g', color: '#EF476F' }, ], products: [ { sym: 'CO₂', coef: 2, M: 44.01, phase: 'g', color: '#9B5DE5' }, { sym: 'H₂O', coef: 3, M: 18.015, phase: 'g', color: '#6EB4D7' }, ], }, ]; /* ── Конструктор ─────────────────────────────────────────────────── */ constructor(container) { this._container = container; this._recipeIdx = 0; this._amounts = []; // граммы для каждого реагента this._inputMode = []; // 'mass' | 'mol' | 'vol' для каждого реагента this._animState = 'idle'; // idle | reacting | done this._animT = 0; this._raf = null; this._computed = null; // результаты последнего расчёта this._init(); this._setRecipe(0); } /* ── Построение DOM ─────────────────────────────────────────────── */ _init() { const c = this._container; c.innerHTML = ''; // ── Wrapper layout ── c.style.cssText = 'display:flex;flex-direction:column;height:100%;overflow:hidden;background:#0D0D1A;'; // ── Equation bar ── this._eqBar = _stEl('div', { style: 'flex:0 0 auto;padding:10px 16px 6px;background:rgba(255,255,255,0.04);border-bottom:1px solid rgba(255,255,255,0.08);', }); c.appendChild(this._eqBar); // ── Main area ── const main = _stEl('div', { style: 'flex:1 1 auto;display:flex;min-height:0;overflow:hidden;' }); c.appendChild(main); // Left panel (reagent inputs) this._leftPanel = _stEl('div', { style: 'flex:0 0 220px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:10px 10px;border-right:1px solid rgba(255,255,255,0.07);', }); main.appendChild(this._leftPanel); // Center canvas area const centerWrap = _stEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;align-items:stretch;min-width:0;', }); this._canvas = document.createElement('canvas'); this._canvas.style.cssText = 'flex:1 1 auto;width:100%;height:100%;display:block;'; centerWrap.appendChild(this._canvas); main.appendChild(centerWrap); // Right panel (step-by-step) this._rightPanel = _stEl('div', { style: 'flex:0 0 240px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:10px 10px;border-left:1px solid rgba(255,255,255,0.07);', }); main.appendChild(this._rightPanel); // ── Bottom HUD ── this._hud = _stEl('div', { style: 'flex:0 0 auto;display:flex;gap:12px;flex-wrap:wrap;align-items:center;padding:8px 16px;background:rgba(0,0,0,0.3);border-top:1px solid rgba(255,255,255,0.07);font-size:.76rem;', }); c.appendChild(this._hud); // Canvas context this._ctx = this._canvas.getContext('2d'); // ResizeObserver if (window.ResizeObserver) { this._ro = new ResizeObserver(() => { this._fitCanvas(); this._draw(); }); this._ro.observe(this._canvas); } } /* ── Выбрать рецепт ─────────────────────────────────────────────── */ _setRecipe(idx) { if (this._recipeIdx !== undefined && this._recipeIdx !== idx && window.LabFX) { LabFX.sound.play('click', { pitch: 1.3 }); } this._recipeIdx = idx; const r = StoichSim.RECIPES[idx]; // Инициализация количеств (начальные значения = 1 г / 1 моль за реагент) this._amounts = r.reactants.map(re => re.M); // 1 моль в граммах this._inputMode = r.reactants.map(() => 'mass'); this._animState = 'idle'; this._animT = 0; this._rebuildLeft(); this._rebuildEquation(); this._compute(); this._rebuildRight(); this._fitCanvas(); this._draw(); } /* ── Уравнение реакции ──────────────────────────────────────────── */ _rebuildEquation() { const r = StoichSim.RECIPES[this._recipeIdx]; const eb = this._eqBar; eb.innerHTML = ''; // Реакции selector const selWrap = _stEl('div', { style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;' }); const sel = document.createElement('select'); sel.style.cssText = 'background:#1a1a2e;color:#fff;border:1px solid rgba(255,255,255,0.15);border-radius:6px;padding:4px 8px;font-size:.78rem;font-family:Manrope,sans-serif;cursor:pointer;'; StoichSim.RECIPES.forEach((rc, i) => { const opt = document.createElement('option'); opt.value = i; opt.textContent = rc.label; if (i === this._recipeIdx) opt.selected = true; sel.appendChild(opt); }); sel.addEventListener('change', () => { this._setRecipe(+sel.value); }); selWrap.appendChild(sel); // Equation display const eqText = _stEl('div', { style: 'font-size:.88rem;color:rgba(255,255,255,0.9);flex:1;min-width:0;word-break:break-word;', textContent: r.name, }); selWrap.appendChild(eqText); // React button const btn = _stEl('button', { style: 'margin-left:auto;padding:5px 14px;border-radius:6px;background:linear-gradient(135deg,#9B5DE5,#4CC9F0);color:#fff;font-size:.75rem;font-weight:700;border:none;cursor:pointer;white-space:nowrap;', textContent: 'Реагировать', }); btn.addEventListener('click', () => this._startAnim()); selWrap.appendChild(btn); eb.appendChild(selWrap); // Quantity badges if (this._computed) this._rebuildBadges(eb, r); } _rebuildBadges(eb, r) { const comp = this._computed; const badgesRow = _stEl('div', { style: 'display:flex;gap:16px;flex-wrap:wrap;margin-top:6px;' }); const all = [ ...r.reactants.map((s, i) => ({ s, q: comp.reactantQ[i], isReactant: true, idx: i })), ...r.products.map((s, i) => ({ s, q: comp.productQ[i], isReactant: false, idx: i })), ]; all.forEach(({ s, q, isReactant, idx }) => { const wrap = _stEl('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:2px;' }); const coefSpan = _stEl('span', { style: `font-size:.72rem;color:rgba(255,255,255,0.5);`, textContent: (s.coef > 1 ? s.coef : '') + s.sym, }); wrap.appendChild(coefSpan); const mBadge = _stEl('span', { style: `font-size:.7rem;padding:2px 6px;border-radius:4px;background:rgba(255,255,255,0.08);color:#FFD166;font-weight:600;`, textContent: q.m.toFixed(2) + ' г', }); wrap.appendChild(mBadge); const nBadge = _stEl('span', { style: `font-size:.68rem;color:rgba(255,255,255,0.5);`, textContent: q.n.toFixed(4) + ' моль', }); wrap.appendChild(nBadge); if (s.phase === 'g') { const vBadge = _stEl('span', { style: `font-size:.68rem;color:var(--cyan,#4CC9F0);`, textContent: q.v.toFixed(3) + ' л', }); wrap.appendChild(vBadge); } // Highlight limiting reagent if (isReactant && this._computed.limitIdx === idx) { wrap.style.outline = '2px solid #EF476F'; wrap.style.borderRadius = '6px'; wrap.style.padding = '2px 4px'; } badgesRow.appendChild(wrap); // Arrow between reactants and products if (isReactant && idx === r.reactants.length - 1) { badgesRow.appendChild(_stEl('div', { style: 'font-size:1rem;align-self:center;color:rgba(255,255,255,0.4);', textContent: '→', })); } }); eb.appendChild(badgesRow); } /* ── Левая панель: inputs ───────────────────────────────────────── */ _rebuildLeft() { const lp = this._leftPanel; lp.innerHTML = ''; const r = StoichSim.RECIPES[this._recipeIdx]; const title = _stEl('div', { style: 'font-size:.72rem;font-weight:700;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;', textContent: 'Реагенты', }); lp.appendChild(title); r.reactants.forEach((re, i) => { const block = _stEl('div', { style: 'margin-bottom:14px;padding:8px;background:rgba(255,255,255,0.04);border-radius:8px;', }); // Name const nameRow = _stEl('div', { style: `font-size:.8rem;font-weight:700;color:${re.color};margin-bottom:6px;`, textContent: re.sym, }); block.appendChild(nameRow); // Mode toggle const modeRow = _stEl('div', { style: 'display:flex;gap:3px;margin-bottom:7px;' }); const modes = [['mass', 'г'], ['mol', 'моль'], ...(re.phase === 'g' ? [['vol', 'л']] : [])]; modes.forEach(([m, label]) => { const btn = _stEl('button', { style: `flex:1;padding:2px 0;border-radius:4px;font-size:.65rem;border:1px solid rgba(255,255,255,0.15);cursor:pointer;font-family:Manrope,sans-serif;transition:background .15s;`, textContent: label, }); btn.style.background = this._inputMode[i] === m ? 'rgba(155,93,229,0.4)' : 'rgba(255,255,255,0.05)'; btn.style.color = this._inputMode[i] === m ? '#fff' : 'rgba(255,255,255,0.6)'; btn.addEventListener('click', () => { this._inputMode[i] = m; this._rebuildLeft(); this._compute(); this._updateAll(); }); modeRow.appendChild(btn); }); block.appendChild(modeRow); // Slider + value const mode = this._inputMode[i]; let sliderMin, sliderMax, sliderStep, sliderVal, unit; if (mode === 'mass') { sliderMin = +(re.M * 0.1).toFixed(2); sliderMax = +(re.M * 10).toFixed(0); sliderStep = +(re.M * 0.01).toFixed(2); sliderVal = +this._amounts[i].toFixed(4); unit = 'г'; } else if (mode === 'mol') { sliderMin = 0.01; sliderMax = 10; sliderStep = 0.01; sliderVal = +(this._amounts[i] / re.M).toFixed(4); unit = 'моль'; } else { sliderMin = 0.1; sliderMax = 100; sliderStep = 0.1; sliderVal = +(this._amounts[i] / re.M * 22.4).toFixed(3); unit = 'л'; } const valSpan = _stEl('span', { style: 'font-size:.76rem;font-weight:700;color:#FFD166;min-width:52px;text-align:right;', textContent: sliderVal.toFixed(3) + ' ' + unit, }); const sl = document.createElement('input'); sl.type = 'range'; sl.min = sliderMin; sl.max = sliderMax; sl.step = sliderStep; sl.value = sliderVal; sl.style.cssText = 'width:100%;accent-color:#9B5DE5;cursor:pointer;'; sl.addEventListener('input', () => { const v = parseFloat(sl.value); if (mode === 'mass') this._amounts[i] = v; else if (mode === 'mol') this._amounts[i] = v * re.M; else this._amounts[i] = v / 22.4 * re.M; valSpan.textContent = v.toFixed(3) + ' ' + unit; this._compute(); this._updateAll(); }); const slRow = _stEl('div', { style: 'display:flex;align-items:center;gap:6px;' }); slRow.appendChild(sl); block.appendChild(slRow); block.appendChild(valSpan); lp.appendChild(block); }); // Reset button const resetBtn = _stEl('button', { style: 'width:100%;padding:6px;border-radius:6px;background:rgba(255,255,255,0.07);color:rgba(255,255,255,0.7);font-size:.73rem;border:1px solid rgba(255,255,255,0.12);cursor:pointer;margin-top:4px;', textContent: 'Сброс', }); resetBtn.addEventListener('click', () => { const r2 = StoichSim.RECIPES[this._recipeIdx]; this._amounts = r2.reactants.map(re => re.M); this._inputMode = r2.reactants.map(() => 'mass'); this._animState = 'idle'; this._animT = 0; this._rebuildLeft(); this._compute(); this._updateAll(); }); lp.appendChild(resetBtn); } /* ── Расчёт стехиометрии ─────────────────────────────────────────── */ _compute() { const r = StoichSim.RECIPES[this._recipeIdx]; // n_i / coef_i для каждого реагента const ratios = r.reactants.map((re, i) => (this._amounts[i] / re.M) / re.coef); const limitVal = Math.min(...ratios); const limitIdx = ratios.indexOf(limitVal); // Количество реагентов фактически израсходованных const reactantQ = r.reactants.map((re, i) => { const nConsumed = limitVal * re.coef; const mConsumed = nConsumed * re.M; const nActual = this._amounts[i] / re.M; const nExcess = nActual - nConsumed; return { n: nConsumed, m: mConsumed, v: nConsumed * 22.4, nExcess, mExcess: nExcess * re.M, vExcess: nExcess * 22.4, }; }); // Продукты const productQ = r.products.map(pr => { const nProd = limitVal * pr.coef; return { n: nProd, m: nProd * pr.M, v: nProd * 22.4, }; }); const prevLimitIdx = this._computed ? this._computed.limitIdx : -1; this._computed = { limitIdx, limitVal, ratios, reactantQ, productQ }; // LabFX: haptic + tick when limiting reagent changes if (window.LabFX && prevLimitIdx !== limitIdx) { LabFX.haptic(20); LabFX.sound.play('tick', { pitch: 0.8, volume: 0.3 }); } } /* ── Правая панель: пошаговый расчёт ───────────────────────────── */ _rebuildRight() { const rp = this._rightPanel; rp.innerHTML = ''; if (!this._computed) return; const comp = this._computed; const r = StoichSim.RECIPES[this._recipeIdx]; const title = _stEl('div', { style: 'font-size:.72rem;font-weight:700;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;', textContent: 'Решение', }); rp.appendChild(title); // Для каждого реагента показываем шаг n = m/M r.reactants.forEach((re, i) => { const m = this._amounts[i]; const n = m / re.M; const block = _stEl('div', { style: 'margin-bottom:10px;padding:7px 8px;background:rgba(255,255,255,0.04);border-radius:7px;', }); const head = _stEl('div', { style: `font-size:.75rem;font-weight:700;color:${re.color};margin-bottom:4px;`, textContent: re.sym + ' (реагент):', }); block.appendChild(head); // n = m/M rendered with katex if available const step1 = `n = \\frac{m}{M} = \\frac{${m.toFixed(2)}}{${re.M}} = ${n.toFixed(4)}\\text{ моль}`; const step1El = _stEl('div', { style: 'margin-bottom:3px;' }); _stKatex(step1El, step1); block.appendChild(step1El); rp.appendChild(block); }); // Лимитирующий реагент → расчёт продуктов const limRe = r.reactants[comp.limitIdx]; const limN = this._amounts[comp.limitIdx] / limRe.M; const limBlock = _stEl('div', { style: 'margin-bottom:10px;padding:7px 8px;background:rgba(239,71,111,0.1);border-radius:7px;border:1px solid rgba(239,71,111,0.3);', }); limBlock.appendChild(_stEl('div', { style: 'font-size:.73rem;font-weight:700;color:#EF476F;margin-bottom:4px;', textContent: 'Лимитирующий: ' + limRe.sym, })); const limFormula = `n_{\\text{лим}} = ${comp.limitVal.toFixed(4)}\\text{ моль}`; const limEl = _stEl('div', { style: 'margin-bottom:2px;' }); _stKatex(limEl, limFormula); limBlock.appendChild(limEl); rp.appendChild(limBlock); // Продукты r.products.forEach((pr, i) => { const q = comp.productQ[i]; const block = _stEl('div', { style: 'margin-bottom:10px;padding:7px 8px;background:rgba(255,255,255,0.04);border-radius:7px;', }); const head = _stEl('div', { style: `font-size:.75rem;font-weight:700;color:${pr.color};margin-bottom:4px;`, textContent: pr.sym + ' (продукт):', }); block.appendChild(head); // n₂ = (b/a)·n_lim const ratio = pr.coef + '/' + limRe.coef; const step1El = _stEl('div', { style: 'margin-bottom:3px;' }); _stKatex(step1El, `n = \\frac{${pr.coef}}{${limRe.coef}} \\cdot ${comp.limitVal.toFixed(4)} = ${q.n.toFixed(4)}\\text{ моль}`); block.appendChild(step1El); const step2El = _stEl('div', { style: 'margin-bottom:3px;' }); _stKatex(step2El, `m = n \\cdot M = ${q.n.toFixed(4)} \\cdot ${pr.M} = ${q.m.toFixed(3)}\\text{ г}`); block.appendChild(step2El); if (pr.phase === 'g') { const step3El = _stEl('div'); _stKatex(step3El, `V = n \\cdot 22{,}4 = ${q.v.toFixed(3)}\\text{ л}\\,(\\text{н.у.})`); block.appendChild(step3El); } rp.appendChild(block); }); } /* ── HUD ─────────────────────────────────────────────────────────── */ _rebuildHud() { const hud = this._hud; hud.innerHTML = ''; if (!this._computed) return; const comp = this._computed; const r = StoichSim.RECIPES[this._recipeIdx]; const limRe = r.reactants[comp.limitIdx]; const limQ = comp.reactantQ[comp.limitIdx]; const chip = (label, val, color) => { const c = _stEl('div', { style: 'display:flex;flex-direction:column;gap:1px;' }); c.appendChild(_stEl('span', { style: 'color:rgba(255,255,255,0.4);font-size:.67rem;', textContent: label })); c.appendChild(_stEl('span', { style: `color:${color};font-weight:700;font-size:.8rem;`, textContent: val })); return c; }; hud.appendChild(chip('Лимитирующий реагент', limRe.sym, '#EF476F')); hud.appendChild(_stEl('div', { style: 'width:1px;height:28px;background:rgba(255,255,255,0.1);' })); const excessN = limQ.nExcess; const otherExcesses = r.reactants .map((re, i) => ({ re, q: comp.reactantQ[i], i })) .filter(({ i }) => i !== comp.limitIdx); otherExcesses.forEach(({ re, q }) => { hud.appendChild(chip('Избыток ' + re.sym, q.mExcess.toFixed(2) + ' г', '#FFD166')); }); hud.appendChild(_stEl('div', { style: 'width:1px;height:28px;background:rgba(255,255,255,0.1);' })); const totalProdM = comp.productQ.reduce((s, q) => s + q.m, 0); hud.appendChild(chip('Выход (теор.)', totalProdM.toFixed(3) + ' г', '#06D6E0')); const totalGasV = r.products .map((pr, i) => pr.phase === 'g' ? comp.productQ[i].v : 0) .reduce((a, b) => a + b, 0); if (totalGasV > 0.0001) { hud.appendChild(chip('Газов (н.у.)', totalGasV.toFixed(3) + ' л', '#9B5DE5')); } } /* ── Обновить всё кроме левой панели (слайдеры уже обновлены) ──── */ _updateAll() { this._rebuildEquation(); this._rebuildRight(); this._rebuildHud(); this._draw(); } /* ── Canvas: размеры ─────────────────────────────────────────────── */ _fitCanvas() { const cv = this._canvas; const dpr = window.devicePixelRatio || 1; const w = cv.clientWidth; const h = cv.clientHeight; if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } this._W = w; this._H = h; } /* ── Canvas: рисование ──────────────────────────────────────────── */ _draw() { const ctx = this._ctx; const W = this._W || this._canvas.clientWidth; const H = this._H || this._canvas.clientHeight; if (!W || !H) return; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); const r = StoichSim.RECIPES[this._recipeIdx]; const comp = this._computed; if (!comp) return; const allSubs = [ ...r.reactants.map((s, i) => ({ s, i, isReactant: true, q: comp.reactantQ[i] })), ...r.products.map((s, i) => ({ s, i, isReactant: false, q: comp.productQ[i] })), ]; const N = allSubs.length; const boxW = Math.min(Math.floor((W - (N + 1) * 10) / N), 110); const boxH = Math.min(H - 40, 130); const totalW = N * boxW + (N - 1) * 10; const startX = (W - totalW) / 2; const topY = (H - boxH) / 2 - 10; // Стрелка-разделитель между реагентами и продуктами const sepIdx = r.reactants.length; const animT = this._animState === 'reacting' ? this._animT : (this._animState === 'done' ? 1 : 0); allSubs.forEach(({ s, i, isReactant, q }, k) => { const x = startX + k * (boxW + 10); // Стрелка → перед первым продуктом if (k === sepIdx) { ctx.save(); ctx.strokeStyle = `rgba(255,255,255,${0.2 + animT * 0.5})`; ctx.lineWidth = 2; const ax = x - 10; ctx.beginPath(); ctx.moveTo(ax - 12, topY + boxH / 2); ctx.lineTo(ax - 2, topY + boxH / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(ax - 7, topY + boxH / 2 - 5); ctx.lineTo(ax - 2, topY + boxH / 2); ctx.lineTo(ax - 7, topY + boxH / 2 + 5); ctx.stroke(); ctx.restore(); } // Highlight лимитирующего реагента const isLimit = isReactant && i === comp.limitIdx; this._drawBeaker(ctx, x, topY, boxW, boxH, s, q, isReactant, isLimit, animT); }); } _drawBeaker(ctx, x, y, bw, bh, sub, q, isReactant, isLimit, animT) { const r = 6; ctx.save(); // Border const borderColor = isLimit ? `rgba(239,71,111,${0.4 + animT * 0.4})` : 'rgba(255,255,255,0.1)'; ctx.strokeStyle = borderColor; ctx.lineWidth = isLimit ? 2 : 1; ctx.beginPath(); _stRoundRect(ctx, x, y, bw, bh, r); ctx.stroke(); // Background ctx.fillStyle = 'rgba(255,255,255,0.03)'; ctx.fill(); // Label ctx.fillStyle = sub.color; ctx.font = 'bold 11px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText(sub.sym, x + bw / 2, y + 16); // Particles const maxParticles = 20; const nParticles = isReactant ? Math.max(1, Math.round((q.n / (q.n + q.nExcess || q.n)) * maxParticles)) : Math.max(1, Math.round(Math.min(q.n / 0.2, 1) * maxParticles)); const areaX = x + 8; const areaY = y + 24; const areaW = bw - 16; const areaH = bh - 40; // Seed deterministic positions from sub.sym const seed = sub.sym.split('').reduce((a, c) => a + c.charCodeAt(0), 0); const pts = []; for (let p = 0; p < maxParticles; p++) { const px = areaX + _stLcg(seed + p * 7) * areaW; const py = areaY + _stLcg(seed + p * 7 + 3) * areaH; pts.push([px, py]); } const alpha = isReactant ? Math.max(0, 1 - animT * 1.2) : Math.min(1, animT * 1.5); ctx.globalAlpha = alpha; for (let p = 0; p < nParticles; p++) { const [px, py] = pts[p]; const jx = isReactant && animT > 0 ? (x + bw / 2 - px) * animT : 0; const jy = isReactant && animT > 0 ? (y + bh / 2 - py) * animT * 0.5 : 0; ctx.beginPath(); ctx.arc(px + jx, py + jy, 4, 0, Math.PI * 2); ctx.fillStyle = sub.color; ctx.fill(); ctx.globalAlpha = alpha * 0.5; ctx.strokeStyle = '#fff'; ctx.lineWidth = 0.5; ctx.stroke(); ctx.globalAlpha = alpha; } ctx.globalAlpha = 1; // Phase label const phaseText = sub.phase === 'g' ? '(г)' : sub.phase === 'aq' ? '(р-р)' : sub.phase === 'l' ? '(ж)' : '(тв)'; ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = '9px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText(phaseText, x + bw / 2, y + bh - 6); // Mass badge bottom-right ctx.fillStyle = 'rgba(255,214,102,0.8)'; ctx.font = 'bold 9px Manrope,sans-serif'; ctx.textAlign = 'right'; ctx.fillText(q.m.toFixed(2) + 'г', x + bw - 4, y + bh - 6); ctx.restore(); } /* ── Анимация реакции ───────────────────────────────────────────── */ _startAnim() { if (this._animState === 'reacting') return; this._animState = 'reacting'; this._animT = 0; const dur = 1200; // ms const start = performance.now(); let lastTs = start; // LabFX: fizz sound + bubble particles at reactant boxes if (window.LabFX && this._ctx) { LabFX.sound.play('fizz'); const W = this._canvas.offsetWidth || 300; const H = this._canvas.offsetHeight || 200; const r = StoichSim.RECIPES[this._recipeIdx]; r.reactants.forEach((re, i) => { const x = (W / (r.reactants.length + 1)) * (i + 1); LabFX.particles.emit({ ctx: this._ctx, x, y: H * 0.4, count: 8, color: re.color || '#FFFFFF', speed: 35, spread: 2.5, angle: -Math.PI / 2, gravity: -50, life: 800, shape: 'ring' }); }); } const tick = (now) => { const dt = (now - lastTs) / 1000; lastTs = now; if (window.LabFX) LabFX.particles.update(dt); this._animT = Math.min(1, (now - start) / dur); this._draw(); if (window.LabFX && this._ctx) LabFX.particles.draw(this._ctx); if (this._animT < 1) { this._raf = requestAnimationFrame(tick); } else { this._animState = 'done'; this._rebuildHud(); this._draw(); } }; this._raf = requestAnimationFrame(tick); } /* ── Public API для _openStoich ─────────────────────────────────── */ fit() { this._fitCanvas(); this._draw(); } destroy() { if (this._raf) cancelAnimationFrame(this._raf); if (this._ro) this._ro.disconnect(); } } /* ── helpers (stoichiometry-local, prefixed _st to avoid collisions) ─ */ function _stEl(tag, props) { const el = document.createElement(tag); Object.entries(props || {}).forEach(([k, v]) => { if (k === 'textContent') el.textContent = v; else if (k === 'style') el.style.cssText = v; else el[k] = v; }); return el; } function _stRoundRect(ctx, x, y, w, h, r) { ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); } // Simple deterministic pseudo-random [0,1) from seed function _stLcg(seed) { const a = 1664525, c = 1013904223, m = 2 ** 32; return ((a * seed + c) % m) / m; } function _stKatex(el, formula) { if (window.katex) { try { el.innerHTML = katex.renderToString(formula, { throwOnError: false, displayMode: false }); return; } catch(e) { /* fallback */ } } // plain text fallback el.textContent = formula; el.style.fontFamily = 'monospace'; el.style.fontSize = '.75rem'; el.style.color = 'rgba(255,255,255,0.7)'; } /* ═══════════════════════════════════════════════════════════════════ lab UI init — следует паттерну _openChemSandbox / _openEquilibrium ═══════════════════════════════════════════════════════════════════ */ var _stoichSim = null; function _openStoich() { document.getElementById('sim-topbar-title').textContent = 'Стехиометрия'; _simShow('sim-stoichiometry'); requestAnimationFrame(() => requestAnimationFrame(() => { const container = document.getElementById('stoichiometry-wrap'); if (!_stoichSim) { _stoichSim = new StoichSim(container); } else { _stoichSim.fit(); } })); }