Files
Learn_System/frontend/js/labs/stoichiometry.js
T
Maxim Dolgolyov 4dce6d0d8f refactor(labs): третий редизайн стехиометрии и качественных реакций
- Стехиометрия: single-page dashboard (Реагенты слева + Продукты справа + канва + ИТОГИ), без шагов, без KaTeX
- Качественные реакции: стол-лаборатория с 4 пробирками + Образец, drag&drop реагентов, режимы Свободно/Тренировка/Экзамен
2026-05-26 16:17:17 +03:00

1097 lines
37 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 — «Стехиометрия»
Single-page dashboard: селектор + реагенты + продукты + канва + итоги
═══════════════════════════════════════════════════════════════════════ */
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._recipeIdx = 0;
this._amounts = [];
this._inputMode = [];
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._initAmounts();
this._build();
}
/* ── Инициализация начальных количеств ───────────────────────────── */
_initAmounts() {
const r = StoichSim.RECIPES[this._recipeIdx];
this._amounts = r.reactants.map(re => re.M);
this._inputMode = r.reactants.map(() => 'mass');
this._compute();
}
/* ── Построение всего интерфейса ─────────────────────────────────── */
_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(';');
/* 1. Topbar */
this._topbar = _stEl('div', {
style: [
'flex:0 0 auto',
'display:flex',
'align-items:center',
'gap:10px',
'padding:0 14px',
'height:50px',
'background:rgba(255,255,255,0.04)',
'border-bottom:1px solid rgba(255,255,255,0.08)',
'min-width:0',
].join(';'),
});
c.appendChild(this._topbar);
/* 2. Основная двухколоночная область */
this._mainArea = _stEl('div', {
style: [
'display:flex',
'flex:1 1 auto',
'overflow:hidden',
'min-height:0',
].join(';'),
});
c.appendChild(this._mainArea);
/* 3. Канва */
this._canvasWrap = _stEl('div', {
style: [
'flex:0 0 180px',
'position:relative',
'width:100%',
'background:#0D0D1A',
'border-top:1px solid rgba(255,255,255,0.08)',
].join(';'),
});
c.appendChild(this._canvasWrap);
/* 4. Нижняя панель итогов */
this._summaryBar = _stEl('div', {
style: [
'flex:0 0 auto',
'display:flex',
'align-items:center',
'flex-wrap:wrap',
'gap:6px',
'padding:8px 14px',
'background:rgba(255,255,255,0.03)',
'border-top:1px solid rgba(255,255,255,0.08)',
].join(';'),
});
c.appendChild(this._summaryBar);
this._renderTopbar();
this._renderMainArea();
this._renderCanvas();
this._renderSummary();
}
/* ── Topbar ─────────────────────────────────────────────────────── */
_renderTopbar() {
const bar = this._topbar;
bar.innerHTML = '';
/* Селектор реакции */
const sel = document.createElement('select');
sel.style.cssText = [
'padding:6px 12px',
'border-radius:7px',
'background:rgba(255,255,255,0.08)',
'color:rgba(255,255,255,0.92)',
'border:1px solid rgba(255,255,255,0.15)',
'font-size:0.92rem',
'font-family:Manrope,sans-serif',
'cursor:pointer',
'flex:0 0 auto',
'max-width:180px',
].join(';');
StoichSim.RECIPES.forEach((rc, i) => {
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = rc.label;
if (i === this._recipeIdx) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener('change', () => {
this._recipeIdx = parseInt(sel.value, 10);
this._animState = 'idle';
this._animT = 0;
this._initAmounts();
this._rebuildAll();
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.3 });
});
bar.appendChild(sel);
/* Уравнение реакции */
this._eqLabel = _stEl('div', {
style: [
'flex:1 1 0',
'font-size:1.1rem',
'color:rgba(255,255,255,0.95)',
'font-weight:700',
'white-space:nowrap',
'overflow:hidden',
'text-overflow:ellipsis',
'min-width:0',
'padding:0 6px',
].join(';'),
textContent: StoichSim.RECIPES[this._recipeIdx].name,
});
bar.appendChild(this._eqLabel);
/* Кнопка сброса */
const resetBtn = _stEl('button', {
style: [
'flex:0 0 auto',
'padding:5px 12px',
'border-radius:6px',
'background:rgba(255,255,255,0.07)',
'color:rgba(255,255,255,0.75)',
'border:1px solid rgba(255,255,255,0.14)',
'font-size:0.85rem',
'font-family:Manrope,sans-serif',
'cursor:pointer',
'white-space:nowrap',
].join(';'),
textContent: 'Сброс',
});
resetBtn.addEventListener('click', () => {
this._animState = 'idle';
this._animT = 0;
this._initAmounts();
this._rebuildAll();
if (window.LabFX) LabFX.sound.play('click');
});
bar.appendChild(resetBtn);
}
/* ── Перестройка всего при смене реакции ─────────────────────────── */
_rebuildAll() {
const r = StoichSim.RECIPES[this._recipeIdx];
this._eqLabel.textContent = r.name;
this._renderMainArea();
this._renderSummary();
this._draw();
}
/* ── Двухколоночная область: реагенты + продукты ─────────────────── */
_renderMainArea() {
const area = this._mainArea;
area.innerHTML = '';
const colStyle = [
'flex:1 1 0',
'overflow-y:auto',
'overflow-x:hidden',
'padding:14px 12px',
'display:flex',
'flex-direction:column',
'gap:12px',
'min-width:0',
].join(';');
/* Левая колонка: реагенты */
const leftCol = _stEl('div', { style: colStyle });
leftCol.appendChild(this._sectionHeader('РЕАГЕНТЫ'));
this._reactantCards = [];
StoichSim.RECIPES[this._recipeIdx].reactants.forEach((re, i) => {
const card = this._buildReactantCard(re, i);
this._reactantCards.push(card);
leftCol.appendChild(card.el);
});
/* Разделитель */
const divider = _stEl('div', {
style: [
'flex:0 0 1px',
'width:1px',
'background:rgba(255,255,255,0.08)',
'align-self:stretch',
].join(';'),
});
/* Правая колонка: продукты */
const rightCol = _stEl('div', { style: colStyle });
rightCol.appendChild(this._sectionHeader('ПРОДУКТЫ'));
this._productCardEls = [];
StoichSim.RECIPES[this._recipeIdx].products.forEach((pr, i) => {
const el = this._buildProductCard(pr, i);
this._productCardEls.push(el);
rightCol.appendChild(el);
});
area.appendChild(leftCol);
area.appendChild(divider);
area.appendChild(rightCol);
}
/* ── Заголовок секции ─────────────────────────────────────────────── */
_sectionHeader(text) {
return _stEl('div', {
style: [
'font-size:0.78rem',
'font-weight:700',
'color:rgba(255,255,255,0.6)',
'text-transform:uppercase',
'letter-spacing:0.07em',
'padding-bottom:4px',
'border-bottom:1px solid rgba(255,255,255,0.07)',
].join(';'),
textContent: text,
});
}
/* ── Карточка реагента ───────────────────────────────────────────── */
_buildReactantCard(re, i) {
const card = _stEl('div', {
style: [
'padding:12px',
'border-radius:8px',
'background:rgba(255,255,255,0.05)',
'border:1px solid rgba(255,255,255,0.1)',
'display:flex',
'flex-direction:column',
'gap:8px',
].join(';'),
});
/* Строка 1: символ + переключатель единиц */
const row1 = _stEl('div', {
style: 'display:flex;align-items:center;justify-content:space-between;gap:8px;',
});
const symEl = _stEl('div', {
style: `font-size:1.15rem;font-weight:700;color:${re.color};`,
textContent: re.sym,
});
row1.appendChild(symEl);
/* Pill-переключатель единиц */
const pill = _stEl('div', {
style: [
'display:flex',
'border-radius:6px',
'overflow:hidden',
'border:1px solid rgba(255,255,255,0.12)',
'flex:0 0 auto',
].join(';'),
});
const modes = [['mass', 'г'], ['mol', 'моль'], ...(re.phase === 'g' ? [['vol', 'л']] : [])];
const modeButtons = {};
const updateMode = (newMode) => {
this._inputMode[i] = newMode;
modes.forEach(([m]) => {
const btn = modeButtons[m];
if (!btn) return;
const active = m === newMode;
btn.style.cssText = this._pillBtnStyle(active);
});
this._syncSlider(i, re, sliderEl, valEl);
this._updateAfterSlider();
};
modes.forEach(([m, label]) => {
const btn = _stEl('button', {
style: this._pillBtnStyle(this._inputMode[i] === m),
textContent: label,
});
btn.addEventListener('click', () => updateMode(m));
modeButtons[m] = btn;
pill.appendChild(btn);
});
row1.appendChild(pill);
card.appendChild(row1);
/* Строка 2: ползунок + значение */
const row2 = _stEl('div', {
style: 'display:flex;align-items:center;gap:8px;',
});
const slParams = this._sliderParams(i, re);
const sliderEl = document.createElement('input');
sliderEl.type = 'range';
sliderEl.min = String(slParams.min);
sliderEl.max = String(slParams.max);
sliderEl.step = String(slParams.step);
sliderEl.value = String(slParams.val);
sliderEl.style.cssText = 'flex:1 1 0;accent-color:#9B5DE5;cursor:pointer;height:6px;min-width:0;';
const valEl = _stEl('div', {
style: [
'font-size:1.0rem',
'font-weight:700',
'color:#FFD166',
'min-width:80px',
'text-align:right',
'flex:0 0 auto',
'white-space:nowrap',
].join(';'),
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);
this._updateAfterSlider();
});
row2.appendChild(sliderEl);
row2.appendChild(valEl);
card.appendChild(row2);
/* Бейдж лимита (скрытый изначально) */
const limitBadge = _stEl('div', {
style: [
'display:none',
'padding:2px 6px',
'border-radius:4px',
'background:rgba(239,71,111,0.2)',
'color:#EF476F',
'font-size:0.72rem',
'font-weight:700',
'width:fit-content',
].join(';'),
textContent: 'ЛИМИТ',
});
card.appendChild(limitBadge);
return { el: card, limitBadge, sliderEl, valEl, re, i };
}
/* ── Pill-кнопка стиль ───────────────────────────────────────────── */
_pillBtnStyle(active) {
return [
'padding:3px 8px',
'font-size:0.78rem',
'font-weight:600',
'cursor:pointer',
'border:none',
'font-family:Manrope,sans-serif',
'transition:background .12s',
active
? 'background:rgba(155,93,229,0.55);color:#fff'
: 'background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.65)',
].join(';');
}
/* ── Карточка продукта ───────────────────────────────────────────── */
_buildProductCard(pr, i) {
const comp = this._computed;
const q = comp ? comp.productQ[i] : { n: 0, m: 0, v: 0 };
const card = _stEl('div', {
style: [
'padding:12px',
'border-radius:8px',
'background:rgba(255,255,255,0.05)',
'border:1px solid rgba(255,255,255,0.1)',
'display:flex',
'flex-direction:column',
'gap:6px',
].join(';'),
});
card.setAttribute('data-prod-idx', i);
/* Строка 1: символ + бейдж (газ) */
const row1 = _stEl('div', {
style: 'display:flex;align-items:center;gap:8px;',
});
row1.appendChild(_stEl('div', {
style: `font-size:1.15rem;font-weight:700;color:${pr.color};`,
textContent: pr.sym,
}));
if (pr.phase === 'g') {
row1.appendChild(_stEl('div', {
style: [
'padding:2px 7px',
'border-radius:4px',
'background:rgba(155,93,229,0.15)',
'color:#9B5DE5',
'font-size:0.78rem',
'font-weight:600',
].join(';'),
textContent: 'газ',
}));
}
card.appendChild(row1);
/* Строки данных */
card.appendChild(this._dataRow('n =', q.n.toFixed(3) + ' моль'));
card.appendChild(this._dataRow('m =', q.m.toFixed(3) + ' г'));
if (pr.phase === 'g') {
card.appendChild(this._dataRow('V =', q.v.toFixed(3) + ' л (н.у.)'));
}
return card;
}
/* ── Строка данных label: value ───────────────────────────────────── */
_dataRow(label, value) {
const row = _stEl('div', {
style: 'display:flex;align-items:baseline;gap:6px;',
});
row.appendChild(_stEl('span', {
style: 'font-size:0.85rem;color:rgba(255,255,255,0.65);min-width:28px;',
textContent: label,
}));
const valEl = _stEl('span', {
style: 'font-size:0.95rem;color:rgba(255,255,255,0.92);font-weight:600;',
textContent: value,
});
row.appendChild(valEl);
return row;
}
/* ── Обновление после изменения ползунка ─────────────────────────── */
_updateAfterSlider() {
this._compute();
this._updateProductCards();
this._updateLimitBadges();
this._renderSummary();
this._draw();
}
/* ── Обновление карточек продуктов ───────────────────────────────── */
_updateProductCards() {
const r = StoichSim.RECIPES[this._recipeIdx];
const comp = this._computed;
if (!comp) return;
this._productCardEls.forEach((card, i) => {
const pr = r.products[i];
const q = comp.productQ[i];
const rows = card.querySelectorAll('[data-data-row]');
/* Пересоздаём строки с новыми значениями */
const existingRows = Array.from(card.querySelectorAll('[data-data-row]'));
existingRows.forEach(el => el.remove());
const newRows = [];
newRows.push(this._dataRow('n =', q.n.toFixed(3) + ' моль'));
newRows.push(this._dataRow('m =', q.m.toFixed(3) + ' г'));
if (pr.phase === 'g') {
newRows.push(this._dataRow('V =', q.v.toFixed(3) + ' л (н.у.)'));
}
newRows.forEach(r => {
r.setAttribute('data-data-row', '1');
card.appendChild(r);
});
});
/* Повторный проход — удалить старые, добавить свежие */
/* (карточки пересобираются полностью) */
const area = this._mainArea;
const cols = area.children;
if (cols.length < 3) return;
const rightCol = cols[2];
/* Убираем всё кроме заголовка */
while (rightCol.children.length > 1) {
rightCol.removeChild(rightCol.lastChild);
}
StoichSim.RECIPES[this._recipeIdx].products.forEach((pr, i) => {
const el = this._buildProductCard(pr, i);
this._productCardEls[i] = el;
rightCol.appendChild(el);
});
}
/* ── Обновление бейджей лимита ───────────────────────────────────── */
_updateLimitBadges() {
if (!this._computed || !this._reactantCards) return;
this._reactantCards.forEach((card, i) => {
card.limitBadge.style.display = (i === this._computed.limitIdx) ? 'block' : 'none';
/* Подсветка карточки лимитирующего реагента */
card.el.style.borderColor = (i === this._computed.limitIdx)
? 'rgba(239,71,111,0.5)'
: 'rgba(255,255,255,0.1)';
});
}
/* ── Canvas-область ─────────────────────────────────────────────── */
_renderCanvas() {
const wrap = this._canvasWrap;
wrap.innerHTML = '';
this._canvas = document.createElement('canvas');
this._canvas.style.cssText = 'display:block;width:100%;height:180px;';
wrap.appendChild(this._canvas);
/* Кнопка запуска анимации */
this._animBtn = _stEl('button', {
style: [
'position:absolute',
'right:14px',
'bottom:10px',
'padding:7px 18px',
'border-radius:8px',
'background:linear-gradient(135deg,#9B5DE5,#4CC9F0)',
'color:#fff',
'font-size:0.9rem',
'font-weight:700',
'border:none',
'cursor:pointer',
'font-family:Manrope,sans-serif',
'z-index:1',
].join(';'),
});
this._animBtn.textContent = 'Запустить реакцию';
this._animBtn.addEventListener('click', () => {
this._startAnim();
});
wrap.appendChild(this._animBtn);
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();
});
}
/* ── Нижняя панель итогов ─────────────────────────────────────────── */
_renderSummary() {
const bar = this._summaryBar;
bar.innerHTML = '';
const r = StoichSim.RECIPES[this._recipeIdx];
const comp = this._computed;
if (!comp) return;
const limRe = r.reactants[comp.limitIdx];
const chips = [];
/* Лимит */
chips.push({ label: 'Лимит:', value: limRe.sym, color: '#EF476F' });
/* Избытки */
r.reactants.forEach((re, i) => {
if (i !== comp.limitIdx) {
const excessM = (comp.reactantQ[i].nExcess * re.M).toFixed(2);
chips.push({ label: 'Избыток ' + re.sym + ':', value: excessM + ' г', color: '#FFD166' });
}
});
/* Теоретический выход */
const totalProdM = comp.productQ.reduce((s, q) => s + q.m, 0);
chips.push({ label: 'Выход теор.:', value: totalProdM.toFixed(3) + ' г', color: '#06D6E0' });
/* Суммарный объём газов */
const totalGasV = r.products.reduce((s, pr, i) =>
s + (pr.phase === 'g' ? comp.productQ[i].v : 0), 0
);
if (totalGasV > 0.0001) {
chips.push({ label: 'Газов:', value: totalGasV.toFixed(3) + ' л', color: '#9B5DE5' });
}
chips.forEach((chip, idx) => {
if (idx > 0) {
bar.appendChild(_stEl('span', {
style: 'color:rgba(255,255,255,0.3);font-size:0.9rem;',
textContent: '|',
}));
}
const chipEl = _stEl('span', {
style: 'display:inline-flex;align-items:center;gap:4px;font-size:0.9rem;',
});
chipEl.appendChild(_stEl('span', {
style: 'color:rgba(255,255,255,0.65);',
textContent: chip.label,
}));
chipEl.appendChild(_stEl('span', {
style: `color:${chip.color};font-weight:700;`,
textContent: chip.value,
}));
bar.appendChild(chipEl);
});
}
/* ── Вспомогательные методы слайдера ─────────────────────────────── */
_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 = String(p.min);
sliderEl.max = String(p.max);
sliderEl.step = String(p.step);
sliderEl.value = String(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) + ' г';
}
/* ── Расчёт стехиометрии ─────────────────────────────────────────── */
_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 = 10;
const boxW = Math.min(Math.floor((W - (N + 1) * gap) / N), 140);
const boxH = Math.min(H - 28, 150);
const totalW = N * boxW + (N - 1) * gap;
const startX = (W - totalW) / 2;
const topY = (H - boxH) / 2 - 6;
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) {
const arrowX = x - gap * 0.5;
const midY = topY + boxH / 2;
ctx.save();
ctx.strokeStyle = `rgba(255,255,255,${0.3 + animT * 0.5})`;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(arrowX - 14, midY);
ctx.lineTo(arrowX + 2, midY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(arrowX - 6, midY - 6);
ctx.lineTo(arrowX + 2, midY);
ctx.lineTo(arrowX - 6, midY + 6);
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, 7);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.03)';
ctx.fill();
ctx.fillStyle = sub.color;
ctx.font = 'bold 13px Manrope,sans-serif';
ctx.textAlign = 'center';
ctx.fillText(sub.sym, x + bw / 2, y + 18);
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 + 26;
const areaW = bw - 16;
const areaH = bh - 46;
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.5, 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.45)';
ctx.font = '9px Manrope,sans-serif';
ctx.textAlign = 'center';
ctx.fillText(phaseText, x + bw / 2, y + bh - 18);
ctx.fillStyle = 'rgba(255,214,102,0.9)';
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;
if (this._animBtn) {
this._animBtn.disabled = true;
this._animBtn.style.opacity = '0.5';
}
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();
if (this._animBtn) {
this._animBtn.disabled = false;
this._animBtn.style.opacity = '1';
this._animBtn.textContent = 'Сбросить анимацию';
this._animBtn.onclick = () => {
this._animState = 'idle';
this._animT = 0;
this._animBtn.textContent = 'Запустить реакцию';
this._animBtn.onclick = () => this._startAnim();
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;
}
/* ═══════════════════════════════════════════════════════════════════
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();
}
}));
}