9aa8c76932
- Стехиометрия → 4-шаговый wizard (Реакция → Количества → Лимит → Продукты), KaTeX в displayMode, крупные карточки - Качественные реакции → центрированная сцена с большой пробиркой, журнал справа 290px, нижняя полка реагентов, убран список ионов - Контраст: основной текст rgba(.92), вторичный (.7), шрифты от .85rem
1225 lines
42 KiB
JavaScript
1225 lines
42 KiB
JavaScript
'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();
|
||
}
|
||
}));
|
||
}
|