Files
Learn_System/frontend/js/labs/stoichiometry.js
T
Maxim Dolgolyov 8b3159b529 feat(labs): wave 3 — 5 new sims + optics merger
Оптическая скамья (opticsbench) — merger thinlens + mirror + refraction
- 4 режима: «Свободная сборка» / «Линза» / «Зеркало» / «Преломление»
- Все 3 движка слиты в OpticsBenchSim (1583 строк)
- Backward compat: #thinlens / #mirrors / #refraction → #opticsbench
- Удалены: thinlens.js, mirror.js, refraction.js

Радиоактивный распад (radioactive) — новая сима
- Monte-Carlo распад: λ·dt вероятность на тик, частицы меняют цвет, эмитируются α/β/γ
- Real-time N(t) график с теоретической кривой N₀·exp(-λt)
- 7 изотопов: ¹⁴C, ¹³¹I, ¹³⁷Cs, ²²⁶Ra, ⁴⁰K, ²³⁸U-chain, ²³⁵U-chain
- Цепочки распадов (U-238: 14 шагов сокращены до 5 ключевых)
- Dating mode для C-14: t = ln(N₀/N)/λ
- HUD: периодов прошло, % распалось, активность в Бк

Тепловые двигатели (heatengine) — новая сима
- 4 цикла: Карно / Отто / Дизель / Брайтон
- PV-диаграмма с замкнутым циклом, заполненной площадью работы
- Аналитически точные изотермы (PV=nRT) и адиабаты (PV^γ=const)
- Анимированный поршень с резервуарами (красный T_h / синий T_c)
- Частицы газа, скорость ∝ √T
- Hover-tooltips с формулами для каждого сегмента

Логические схемы (logic) — новая сима для информатики
- Drag-drop конструктор: 12 типов компонентов (INPUT/CLOCK/OUTPUT/AND/OR/NOT/XOR/NAND/NOR/XNOR/BUF/wire)
- Топологическая сортировка для propagation, цветовая подсветка HIGH/LOW
- Авто-генерация булевого выражения (∧ ∨ ¬ ⊕)
- Авто-таблица истинности (до 2^6 = 64 строк)
- 6 пресетов: полусумматор, полный сумматор, RS-триггер, D-триггер, декодер 2-в-4, мультиплексор 2-в-1

Стехиометрия (stoichiometry) — новая сима
- 10 реакций: Zn+HCl, H₂+O₂, CH₄+O₂, N₂+H₂ (Габер), Al+CuSO₄, Mg+O₂, CaCO₃→, HCl+NaOH, KMnO₄→, C₂H₅OH+O₂
- Sliders с переключением m/n/V (для газов V=n·22.4 при н.у.)
- Анимация частиц при реакции, подсветка лимитирующего реагента
- Пошаговый расчёт m→n→n_product→m_product с KaTeX
- HUD: лимит, избытки, теоретический выход

Каталог: 33 → 35 сим (5 новых − 3 удалённых merger)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:25:16 +03:00

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