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>
This commit is contained in:
Maxim Dolgolyov
2026-05-23 13:25:16 +03:00
parent 8f30a8cef6
commit 8b3159b529
13 changed files with 5347 additions and 2232 deletions
+863
View File
@@ -0,0 +1,863 @@
'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();
}
}));
}