'use strict'; /* ═══════════════════════════════════════════════════════════════════════ StoichSim — «Стехиометрия» Wizard UX: 4 шага — Выбор реакции → Количества → Лимит → Продукты ═══════════════════════════════════════════════════════════════════════ */ 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: '#8899AA' }, { 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._step = 1; // 1 | 2 | 3 | 4 this._recipeIdx = 0; this._amounts = []; // граммы для каждого реагента this._inputMode = []; // 'mass' | 'mol' | 'vol' this._computed = null; this._animState = 'idle'; this._animT = 0; this._raf = null; this._canvas = null; this._ctx = null; this._ro = null; this._W = 0; this._H = 0; this._build(); } /* ── Построение оболочки wizard ─────────────────────────────────── */ _build() { const c = this._container; c.innerHTML = ''; c.style.cssText = [ 'display:flex', 'flex-direction:column', 'height:100%', 'overflow:hidden', 'background:#0D0D1A', 'font-family:Manrope,sans-serif', ].join(';'); /* step indicator */ this._stepBar = _stEl('div', { style: [ 'flex:0 0 auto', 'display:flex', 'align-items:center', 'justify-content:center', 'gap:0', 'padding:12px 20px 10px', 'background:rgba(255,255,255,0.03)', 'border-bottom:1px solid rgba(255,255,255,0.08)', ].join(';'), }); c.appendChild(this._stepBar); /* content area */ this._content = _stEl('div', { style: 'flex:1 1 auto;overflow-y:auto;overflow-x:hidden;padding:20px;', }); c.appendChild(this._content); this._renderStep(); } /* ── Step indicator ─────────────────────────────────────────────── */ _renderStepBar() { const bar = this._stepBar; bar.innerHTML = ''; const steps = [ 'Реакция', 'Количества', 'Лимит', 'Продукты', ]; steps.forEach((label, idx) => { const num = idx + 1; const active = num === this._step; const done = num < this._step; /* circle */ const circle = _stEl('div', { style: [ 'width:28px', 'height:28px', 'border-radius:50%', 'display:flex', 'align-items:center', 'justify-content:center', 'font-size:.82rem', 'font-weight:700', 'flex-shrink:0', 'transition:background .2s', active ? 'background:#9B5DE5;color:#fff' : done ? 'background:rgba(155,93,229,0.35);color:rgba(255,255,255,0.9)' : 'background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.4)', ].join(';'), textContent: String(num), }); /* label */ const lbl = _stEl('div', { style: [ 'font-size:.72rem', 'margin-left:6px', 'white-space:nowrap', active ? 'color:rgba(255,255,255,0.92);font-weight:700' : done ? 'color:rgba(255,255,255,0.55)' : 'color:rgba(255,255,255,0.3)', ].join(';'), textContent: label, }); const item = _stEl('div', { style: 'display:flex;align-items:center;', }); item.appendChild(circle); item.appendChild(lbl); bar.appendChild(item); /* connector */ if (idx < steps.length - 1) { bar.appendChild(_stEl('div', { style: [ 'flex:0 0 24px', 'height:2px', 'margin:0 8px', 'border-radius:1px', num < this._step ? 'background:rgba(155,93,229,0.4)' : 'background:rgba(255,255,255,0.1)', ].join(';'), })); } }); } /* ── Главный рендер текущего шага ───────────────────────────────── */ _renderStep() { this._renderStepBar(); const c = this._content; c.innerHTML = ''; if (this._step === 1) this._renderStep1(c); else if (this._step === 2) this._renderStep2(c); else if (this._step === 3) this._renderStep3(c); else if (this._step === 4) this._renderStep4(c); } /* ══════════════════════════════════════════════════════════════════ ШАГ 1: Выбор реакции ══════════════════════════════════════════════════════════════════ */ _renderStep1(c) { /* заголовок */ c.appendChild(_stEl('div', { style: 'font-size:1.15rem;font-weight:700;color:rgba(255,255,255,0.92);margin-bottom:6px;', textContent: 'Выберите реакцию', })); c.appendChild(_stEl('div', { style: 'font-size:.85rem;color:rgba(255,255,255,0.5);margin-bottom:20px;', textContent: 'Нажмите на карточку с реакцией, затем нажмите «Далее».', })); /* большое уравнение выбранной реакции */ const eqCard = _stEl('div', { style: [ 'padding:18px 24px', 'background:rgba(155,93,229,0.12)', 'border:1px solid rgba(155,93,229,0.35)', 'border-radius:12px', 'margin-bottom:20px', 'text-align:center', ].join(';'), }); const eqDisplay = _stEl('div', { style: 'font-size:1.35rem;color:rgba(255,255,255,0.95);word-break:break-word;letter-spacing:.02em;', }); eqCard.appendChild(eqDisplay); c.appendChild(eqCard); const updateEq = () => { eqDisplay.textContent = StoichSim.RECIPES[this._recipeIdx].name; }; updateEq(); /* грид карточек */ const grid = _stEl('div', { style: [ 'display:grid', 'grid-template-columns:repeat(auto-fill,minmax(210px,1fr))', 'gap:10px', 'margin-bottom:24px', ].join(';'), }); StoichSim.RECIPES.forEach((rc, i) => { const card = _stEl('div', { style: this._recipeCardStyle(i === this._recipeIdx), }); card.appendChild(_stEl('div', { style: 'font-size:.78rem;font-weight:700;color:rgba(255,255,255,0.5);margin-bottom:4px;text-transform:uppercase;letter-spacing:.05em;', textContent: 'Реакция ' + (i + 1), })); card.appendChild(_stEl('div', { style: 'font-size:.95rem;font-weight:700;color:rgba(255,255,255,0.92);word-break:break-word;', textContent: rc.label, })); card.appendChild(_stEl('div', { style: 'font-size:.78rem;color:rgba(255,255,255,0.5);margin-top:4px;word-break:break-word;', textContent: rc.name, })); card.addEventListener('click', () => { this._recipeIdx = i; /* обновить стили всех карточек */ grid.querySelectorAll('[data-recipe-card]').forEach((el, j) => { el.style.cssText = this._recipeCardStyle(j === i); }); updateEq(); if (window.LabFX) LabFX.sound.play('click', { pitch: 1.3 }); }); card.setAttribute('data-recipe-card', i); grid.appendChild(card); }); c.appendChild(grid); /* кнопка Далее */ c.appendChild(this._navRow(null, () => this._goStep2())); } _recipeCardStyle(selected) { return [ 'padding:12px 14px', 'border-radius:10px', 'cursor:pointer', 'transition:background .15s,border-color .15s', selected ? 'background:rgba(155,93,229,0.2);border:2px solid #9B5DE5' : 'background:rgba(255,255,255,0.04);border:2px solid rgba(255,255,255,0.08)', ].join(';'); } _goStep2() { const r = StoichSim.RECIPES[this._recipeIdx]; this._amounts = r.reactants.map(re => re.M); this._inputMode = r.reactants.map(() => 'mass'); this._computed = null; this._step = 2; this._renderStep(); } /* ══════════════════════════════════════════════════════════════════ ШАГ 2: Введите количества ══════════════════════════════════════════════════════════════════ */ _renderStep2(c) { const r = StoichSim.RECIPES[this._recipeIdx]; c.appendChild(_stEl('div', { style: 'font-size:1.15rem;font-weight:700;color:rgba(255,255,255,0.92);margin-bottom:4px;', textContent: 'Задайте количества реагентов', })); c.appendChild(_stEl('div', { style: 'font-size:.85rem;color:rgba(255,255,255,0.5);margin-bottom:6px;word-break:break-word;', textContent: r.name, })); /* разделитель */ c.appendChild(_stEl('div', { style: 'height:1px;background:rgba(255,255,255,0.08);margin-bottom:18px;', })); const cards = _stEl('div', { style: 'display:flex;flex-direction:column;gap:14px;margin-bottom:24px;' }); r.reactants.forEach((re, i) => { const card = _stEl('div', { style: [ 'padding:16px 18px', 'background:rgba(255,255,255,0.05)', 'border:1px solid rgba(255,255,255,0.1)', 'border-radius:12px', ].join(';'), }); /* шапка карточки */ const head = _stEl('div', { style: 'display:flex;align-items:center;gap:12px;margin-bottom:12px;' }); head.appendChild(_stEl('div', { style: `font-size:1.3rem;font-weight:800;color:${re.color};`, textContent: re.sym, })); const phaseTxt = re.phase === 'g' ? 'газ' : re.phase === 'aq' ? 'раствор' : re.phase === 'l' ? 'жидкость' : 'твёрдое'; head.appendChild(_stEl('div', { style: 'font-size:.78rem;color:rgba(255,255,255,0.45);', textContent: phaseTxt + ' · M = ' + re.M + ' г/моль · коэф. ' + re.coef, })); card.appendChild(head); /* кнопки единиц */ const modeRow = _stEl('div', { style: 'display:flex;gap:6px;margin-bottom:12px;' }); const modes = [['mass', 'г (масса)'], ['mol', 'моль'], ...(re.phase === 'g' ? [['vol', 'л (объём)']] : [])]; const updateMode = (newMode) => { this._inputMode[i] = newMode; modeRow.querySelectorAll('[data-mode-btn]').forEach((btn) => { const m = btn.getAttribute('data-mode-btn'); btn.style.cssText = this._modeBtnStyle(m === newMode); }); this._syncSlider(i, re, sliderEl, valEl); }; modes.forEach(([m, label]) => { const btn = _stEl('button', { style: this._modeBtnStyle(this._inputMode[i] === m), textContent: label, }); btn.setAttribute('data-mode-btn', m); btn.addEventListener('click', () => updateMode(m)); modeRow.appendChild(btn); }); card.appendChild(modeRow); /* ползунок */ const slParams = this._sliderParams(i, re); const sliderEl = document.createElement('input'); sliderEl.type = 'range'; sliderEl.min = slParams.min; sliderEl.max = slParams.max; sliderEl.step = slParams.step; sliderEl.value = slParams.val; sliderEl.style.cssText = 'width:100%;accent-color:#9B5DE5;cursor:pointer;height:6px;margin-bottom:8px;display:block;'; /* значение */ const valEl = _stEl('div', { style: 'font-size:1.1rem;font-weight:700;color:#FFD166;text-align:right;', textContent: this._fmtSliderVal(i, re), }); sliderEl.addEventListener('input', () => { const v = parseFloat(sliderEl.value); const mode = this._inputMode[i]; 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; valEl.textContent = this._fmtSliderVal(i, re); }); card.appendChild(sliderEl); card.appendChild(valEl); /* запомнить ссылки на элементы для updateMode */ cards.appendChild(card); }); c.appendChild(cards); c.appendChild(this._navRow(() => { this._step = 1; this._renderStep(); }, () => this._goStep3())); } _modeBtnStyle(active) { return [ 'padding:6px 14px', 'border-radius:6px', 'font-size:.8rem', 'font-weight:600', 'cursor:pointer', 'border:1px solid', 'font-family:Manrope,sans-serif', 'transition:background .15s', active ? 'background:rgba(155,93,229,0.4);color:#fff;border-color:rgba(155,93,229,0.7)' : 'background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.65);border-color:rgba(255,255,255,0.12)', ].join(';'); } _sliderParams(i, re) { const mode = this._inputMode[i]; if (mode === 'mol') { return { min: 0.01, max: 10, step: 0.01, val: +(this._amounts[i] / re.M).toFixed(4) }; } else if (mode === 'vol') { return { min: 0.1, max: 100, step: 0.1, val: +(this._amounts[i] / re.M * 22.4).toFixed(3), }; } return { min: +(re.M * 0.1).toFixed(2), max: +(re.M * 10).toFixed(0), step: +(re.M * 0.01).toFixed(2), val: +this._amounts[i].toFixed(4), }; } _syncSlider(i, re, sliderEl, valEl) { const p = this._sliderParams(i, re); sliderEl.min = p.min; sliderEl.max = p.max; sliderEl.step = p.step; sliderEl.value = p.val; valEl.textContent = this._fmtSliderVal(i, re); } _fmtSliderVal(i, re) { const mode = this._inputMode[i]; if (mode === 'mol') { return (this._amounts[i] / re.M).toFixed(3) + ' моль'; } else if (mode === 'vol') { return (this._amounts[i] / re.M * 22.4).toFixed(3) + ' л'; } return this._amounts[i].toFixed(2) + ' г'; } _goStep3() { this._compute(); this._step = 3; this._renderStep(); } /* ══════════════════════════════════════════════════════════════════ ШАГ 3: Лимитирующий реагент ══════════════════════════════════════════════════════════════════ */ _renderStep3(c) { const r = StoichSim.RECIPES[this._recipeIdx]; const comp = this._computed; c.appendChild(_stEl('div', { style: 'font-size:1.15rem;font-weight:700;color:rgba(255,255,255,0.92);margin-bottom:4px;', textContent: 'Лимитирующий реагент', })); c.appendChild(_stEl('div', { style: 'font-size:.85rem;color:rgba(255,255,255,0.5);margin-bottom:18px;', textContent: 'Реагент с наименьшим отношением n/ν лимитирует реакцию.', })); /* таблица сравнения */ const table = _stEl('div', { style: 'display:flex;flex-direction:column;gap:10px;margin-bottom:20px;' }); r.reactants.forEach((re, i) => { const n = this._amounts[i] / re.M; const ratio = n / re.coef; const isLim = i === comp.limitIdx; const row = _stEl('div', { style: [ 'display:flex', 'align-items:center', 'gap:14px', 'padding:14px 18px', 'border-radius:12px', 'background:rgba(255,255,255,0.05)', isLim ? 'border:2px solid #EF476F' : 'border:2px solid rgba(255,255,255,0.08)', ].join(';'), }); /* символ */ row.appendChild(_stEl('div', { style: `font-size:1.3rem;font-weight:800;color:${re.color};min-width:60px;`, textContent: re.sym, })); /* данные */ const data = _stEl('div', { style: 'flex:1;display:flex;flex-direction:column;gap:4px;' }); const nLine = _stEl('div', { style: 'overflow-x:auto;' }); _stKatex(nLine, `n = \\dfrac{${this._amounts[i].toFixed(2)}}{${re.M}} = ${n.toFixed(4)}\\text{ моль}`, true ); data.appendChild(nLine); const ratioLine = _stEl('div', { style: 'overflow-x:auto;' }); _stKatex(ratioLine, `\\dfrac{n}{\\nu} = \\dfrac{${n.toFixed(4)}}{${re.coef}} = ${ratio.toFixed(4)}`, true ); data.appendChild(ratioLine); row.appendChild(data); /* бейдж */ if (isLim) { row.appendChild(_stEl('div', { style: [ 'padding:4px 10px', 'border-radius:6px', 'background:#EF476F', 'color:#fff', 'font-size:.75rem', 'font-weight:800', 'white-space:nowrap', ].join(';'), textContent: 'ЛИМИТ', })); } else { const excessN = n - comp.limitVal * re.coef; row.appendChild(_stEl('div', { style: [ 'padding:4px 10px', 'border-radius:6px', 'background:rgba(255,209,102,0.15)', 'color:#FFD166', 'font-size:.75rem', 'font-weight:700', 'white-space:nowrap', ].join(';'), textContent: 'избыток ' + (excessN * re.M).toFixed(2) + ' г', })); } table.appendChild(row); }); c.appendChild(table); /* формула n_lim */ const limBox = _stEl('div', { style: [ 'padding:14px 18px', 'background:rgba(239,71,111,0.1)', 'border:1px solid rgba(239,71,111,0.3)', 'border-radius:12px', 'margin-bottom:24px', 'overflow-x:auto', ].join(';'), }); limBox.appendChild(_stEl('div', { style: 'font-size:.78rem;font-weight:700;color:#EF476F;margin-bottom:8px;', textContent: 'Лимитирующий: ' + r.reactants[comp.limitIdx].sym, })); const limFormulaEl = _stEl('div', {}); const ratiosList = r.reactants .map((re, i) => (this._amounts[i] / re.M / re.coef).toFixed(4)) .join(';\\,'); _stKatex(limFormulaEl, `n_{\\text{лим}} = \\min\\!\\left(${ratiosList}\\right) = ${comp.limitVal.toFixed(4)}\\text{ моль}`, true ); limBox.appendChild(limFormulaEl); c.appendChild(limBox); c.appendChild(this._navRow(() => { this._step = 2; this._renderStep(); }, () => this._goStep4())); } _goStep4() { this._animState = 'idle'; this._animT = 0; this._step = 4; this._renderStep(); } /* ══════════════════════════════════════════════════════════════════ ШАГ 4: Продукты и итоги ══════════════════════════════════════════════════════════════════ */ _renderStep4(c) { const r = StoichSim.RECIPES[this._recipeIdx]; const comp = this._computed; const limRe = r.reactants[comp.limitIdx]; c.appendChild(_stEl('div', { style: 'font-size:1.15rem;font-weight:700;color:rgba(255,255,255,0.92);margin-bottom:4px;', textContent: 'Продукты реакции', })); c.appendChild(_stEl('div', { style: 'font-size:.85rem;color:rgba(255,255,255,0.5);margin-bottom:18px;word-break:break-word;', textContent: r.name, })); /* итоговые бейджи */ const badges = _stEl('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px;' }); badges.appendChild(this._badge('Лимит: ' + limRe.sym, '#EF476F', 'rgba(239,71,111,0.12)')); r.reactants.forEach((re, i) => { if (i !== comp.limitIdx) { const excessM = (comp.reactantQ[i].nExcess * re.M).toFixed(2); badges.appendChild(this._badge('Избыток ' + re.sym + ': ' + excessM + ' г', '#FFD166', 'rgba(255,209,102,0.1)')); } }); const totalProdM = comp.productQ.reduce((s, q) => s + q.m, 0); badges.appendChild(this._badge('Выход теор.: ' + totalProdM.toFixed(3) + ' г', '#06D6E0', 'rgba(6,214,224,0.1)')); const totalGasV = r.products .reduce((s, pr, i) => s + (pr.phase === 'g' ? comp.productQ[i].v : 0), 0); if (totalGasV > 0.0001) { badges.appendChild(this._badge('Газов: ' + totalGasV.toFixed(3) + ' л', '#9B5DE5', 'rgba(155,93,229,0.12)')); } c.appendChild(badges); /* карточки продуктов */ const cards = _stEl('div', { style: 'display:flex;flex-direction:column;gap:14px;margin-bottom:20px;' }); r.products.forEach((pr, i) => { const q = comp.productQ[i]; const card = _stEl('div', { style: [ 'padding:16px 18px', 'background:rgba(255,255,255,0.05)', 'border:1px solid rgba(255,255,255,0.1)', 'border-radius:12px', ].join(';'), }); /* шапка */ const head = _stEl('div', { style: 'display:flex;align-items:center;gap:10px;margin-bottom:12px;' }); head.appendChild(_stEl('div', { style: `font-size:1.3rem;font-weight:800;color:${pr.color};`, textContent: pr.sym, })); const phaseTxt = pr.phase === 'g' ? '(г)' : pr.phase === 'aq' ? '(р-р)' : pr.phase === 'l' ? '(ж)' : '(тв)'; head.appendChild(_stEl('div', { style: 'font-size:.78rem;color:rgba(255,255,255,0.45);', textContent: phaseTxt + ' · M = ' + pr.M + ' г/моль', })); card.appendChild(head); /* шаг 1: n продукта */ const step1 = _stEl('div', { style: 'overflow-x:auto;margin-bottom:8px;' }); _stKatex(step1, `n = \\dfrac{${pr.coef}}{${limRe.coef}} \\cdot n_{\\text{лим}} = \\dfrac{${pr.coef}}{${limRe.coef}} \\cdot ${comp.limitVal.toFixed(4)} = ${q.n.toFixed(4)}\\text{ моль}`, true ); card.appendChild(step1); /* шаг 2: масса */ const step2 = _stEl('div', { style: 'overflow-x:auto;margin-bottom:8px;' }); _stKatex(step2, `m = n \\cdot M = ${q.n.toFixed(4)} \\cdot ${pr.M} = ${q.m.toFixed(3)}\\text{ г}`, true ); card.appendChild(step2); /* шаг 3: объём (если газ) */ if (pr.phase === 'g') { const step3 = _stEl('div', { style: 'overflow-x:auto;' }); _stKatex(step3, `V = n \\cdot 22{,}4 = ${q.n.toFixed(4)} \\cdot 22{,}4 = ${q.v.toFixed(3)}\\text{ л (н.у.)}`, true ); card.appendChild(step3); } cards.appendChild(card); }); c.appendChild(cards); /* canvas с анимацией */ const canvasWrap = _stEl('div', { style: [ 'position:relative', 'width:100%', 'border-radius:12px', 'overflow:hidden', 'background:#0D0D1A', 'border:1px solid rgba(255,255,255,0.08)', 'margin-bottom:20px', ].join(';'), }); this._canvas = document.createElement('canvas'); this._canvas.style.cssText = 'display:block;width:100%;height:180px;'; canvasWrap.appendChild(this._canvas); const animBtn = _stEl('button', { style: [ 'display:block', 'margin:0 auto 4px', 'padding:8px 22px', 'border-radius:8px', 'background:linear-gradient(135deg,#9B5DE5,#4CC9F0)', 'color:#fff', 'font-size:.9rem', 'font-weight:700', 'border:none', 'cursor:pointer', 'font-family:Manrope,sans-serif', ].join(';'), textContent: 'Показать реакцию', }); animBtn.addEventListener('click', () => { this._startAnim(); animBtn.disabled = true; animBtn.style.opacity = '.5'; }); canvasWrap.appendChild(animBtn); c.appendChild(canvasWrap); /* запустить canvas */ requestAnimationFrame(() => { this._ctx = this._canvas.getContext('2d'); if (window.ResizeObserver) { if (this._ro) this._ro.disconnect(); this._ro = new ResizeObserver(() => { this._fitCanvas(); this._draw(); }); this._ro.observe(this._canvas); } this._fitCanvas(); this._draw(); }); /* навигация */ c.appendChild(this._navRow( () => { this._step = 3; this._renderStep(); }, null, 'Заново', () => { this._step = 1; this._recipeIdx = 0; this._amounts = []; this._inputMode = []; this._computed = null; this._renderStep(); } )); } _badge(text, color, bg) { return _stEl('div', { style: [ 'padding:5px 12px', 'border-radius:6px', `background:${bg}`, `color:${color}`, 'font-size:.8rem', 'font-weight:700', 'white-space:nowrap', ].join(';'), textContent: text, }); } /* ══════════════════════════════════════════════════════════════════ Навигационная строка back: function | null next: function | null resetLabel: строка для кнопки "Заново" (только на шаге 4) resetFn: function | null ══════════════════════════════════════════════════════════════════ */ _navRow(backFn, nextFn, resetLabel, resetFn) { const row = _stEl('div', { style: 'display:flex;align-items:center;gap:10px;justify-content:flex-end;', }); if (backFn) { const btn = _stEl('button', { style: this._navBtnStyle(false), textContent: 'Назад', }); btn.addEventListener('click', () => { if (window.LabFX) LabFX.sound.play('click'); backFn(); }); row.appendChild(btn); } if (resetFn && resetLabel) { const btn = _stEl('button', { style: [ this._navBtnStyle(false), 'background:rgba(255,255,255,0.06)', 'border:1px solid rgba(255,255,255,0.15)', ].join(';'), textContent: resetLabel, }); btn.addEventListener('click', () => { if (window.LabFX) LabFX.sound.play('click'); resetFn(); }); row.appendChild(btn); } if (nextFn) { const btn = _stEl('button', { style: this._navBtnStyle(true), textContent: 'Далее', }); btn.addEventListener('click', () => { if (window.LabFX) LabFX.sound.play('click', { pitch: 1.2 }); nextFn(); }); row.appendChild(btn); } return row; } _navBtnStyle(primary) { return [ 'padding:10px 24px', 'border-radius:8px', 'font-size:1rem', 'font-weight:700', 'cursor:pointer', 'border:none', 'font-family:Manrope,sans-serif', 'transition:opacity .15s', primary ? 'background:linear-gradient(135deg,#9B5DE5,#4CC9F0);color:#fff' : 'background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.8)', ].join(';'); } /* ── Расчёт стехиометрии ─────────────────────────────────────────── */ _compute() { const r = StoichSim.RECIPES[this._recipeIdx]; 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 nActual = this._amounts[i] / re.M; return { n: nConsumed, m: nConsumed * re.M, v: nConsumed * 22.4, nExcess: nActual - nConsumed, mExcess: (nActual - nConsumed) * re.M, vExcess: (nActual - nConsumed) * 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 }; if (window.LabFX && prevLimitIdx !== -1 && prevLimitIdx !== limitIdx) { LabFX.haptic(20); LabFX.sound.play('tick', { pitch: 0.8, volume: 0.3 }); } } /* ── Canvas ─────────────────────────────────────────────────────── */ _fitCanvas() { const cv = this._canvas; if (!cv) return; const dpr = window.devicePixelRatio || 1; const w = cv.clientWidth; const h = cv.clientHeight; if (!w || !h) return; 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; } _draw() { const ctx = this._ctx; if (!ctx) return; const W = this._W || (this._canvas ? this._canvas.clientWidth : 0); const H = this._H || (this._canvas ? this._canvas.clientHeight : 0); 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 gap = 12; const boxW = Math.min(Math.floor((W - (N + 1) * gap) / N), 120); const boxH = Math.min(H - 30, 140); const totalW = N * boxW + (N - 1) * gap; const startX = (W - totalW) / 2; const topY = (H - boxH) / 2 - 8; 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 + gap); if (k === sepIdx) { ctx.save(); ctx.strokeStyle = `rgba(255,255,255,${0.25 + animT * 0.5})`; ctx.lineWidth = 2; const ax = x - gap * 0.5; ctx.beginPath(); ctx.moveTo(ax - 10, topY + boxH / 2); ctx.lineTo(ax, topY + boxH / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(ax - 6, topY + boxH / 2 - 5); ctx.lineTo(ax, topY + boxH / 2); ctx.lineTo(ax - 6, topY + boxH / 2 + 5); ctx.stroke(); ctx.restore(); } 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) { ctx.save(); const borderColor = isLimit ? `rgba(239,71,111,${0.45 + animT * 0.4})` : 'rgba(255,255,255,0.12)'; ctx.strokeStyle = borderColor; ctx.lineWidth = isLimit ? 2 : 1; ctx.beginPath(); _stRoundRect(ctx, x, y, bw, bh, 6); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.03)'; ctx.fill(); ctx.fillStyle = sub.color; ctx.font = 'bold 12px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText(sub.sym, x + bw / 2, y + 17); 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 - 42; const seed = sub.sym.split('').reduce((a, ch) => a + ch.charCodeAt(0), 0); const pts = []; for (let p = 0; p < maxParticles; p++) { pts.push([ areaX + _stLcg(seed + p * 7) * areaW, areaY + _stLcg(seed + p * 7 + 3) * areaH, ]); } 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; const phaseText = sub.phase === 'g' ? '(г)' : sub.phase === 'aq' ? '(р-р)' : sub.phase === 'l' ? '(ж)' : '(тв)'; ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '9px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText(phaseText, x + bw / 2, y + bh - 16); ctx.fillStyle = 'rgba(255,214,102,0.85)'; 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; const start = performance.now(); let lastTs = start; if (window.LabFX && this._ctx) { LabFX.sound.play('fizz'); const r = StoichSim.RECIPES[this._recipeIdx]; const W = this._W || 300; const H = this._H || 180; 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._draw(); } }; this._raf = requestAnimationFrame(tick); } /* ── Public API ─────────────────────────────────────────────────── */ fit() { this._fitCanvas(); this._draw(); } destroy() { if (this._raf) cancelAnimationFrame(this._raf); if (this._ro) this._ro.disconnect(); this._canvas = null; this._ctx = null; } } /* ── Helpers (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(); } function _stLcg(seed) { const a = 1664525, c = 1013904223, m = 2 ** 32; return ((a * seed + c) % m) / m; } function _stKatex(el, formula, displayMode) { if (window.katex) { try { el.innerHTML = katex.renderToString(formula, { throwOnError: false, displayMode: displayMode === true, }); return; } catch (e) { /* fallback */ } } el.textContent = formula; el.style.fontFamily = 'monospace'; el.style.fontSize = '.85rem'; el.style.color = 'rgba(255,255,255,0.8)'; } /* ═══════════════════════════════════════════════════════════════════ Lab UI init ═══════════════════════════════════════════════════════════════════ */ 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(); } })); }