Files
Learn_System/frontend/js/labs/stoichiometry.js
T
Maxim Dolgolyov 9aa8c76932 refactor(labs): полная переработка стехиометрии и качественных реакций
- Стехиометрия → 4-шаговый wizard (Реакция → Количества → Лимит → Продукты), KaTeX в displayMode, крупные карточки
- Качественные реакции → центрированная сцена с большой пробиркой, журнал справа 290px, нижняя полка реагентов, убран список ионов
- Контраст: основной текст rgba(.92), вторичный (.7), шрифты от .85rem
2026-05-26 15:51:25 +03:00

1225 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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();
}
}));
}