Files
Maxim Dolgolyov be1e558be9 fix(labs): таблица Менделеева + UX качественных реакций + анимации стехиометрии
- Менделеев: clamp() для font-size символа элемента (2.4rem..4.4rem) + padding-top 28px → символ не обрезается на узких панелях
- Качественные реакции: в Свободно/Тренировке Проб1-4 содержат известные ионы (видна подпись), в Тренировке Образец — отдельный неизвестный; в Экзамене можно переключаться между пробирками и ответить отдельно для каждой (verdict сохраняется)
- Стехиометрия: непрерывный анимационный цикл — волна на поверхности жидкости, пузырьки в газах/растворах, пульсирующая красная рамка + ЛИМИТ-лейбл у лимитирующего реагента, искры вдоль стрелки реакции, glow на стрелке во время реакции
2026-05-26 16:26:10 +03:00

1222 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ═══════════════════════════════════════════════════════════════════════
StoichSim — «Стехиометрия»
Single-page dashboard: селектор + реагенты + продукты + канва + итоги
═══════════════════════════════════════════════════════════════════════ */
class StoichSim {
/* ── Рецепты реакций ─────────────────────────────────────────────── */
static RECIPES = [
{
name: 'Zn + 2HCl → ZnCl₂ + H₂↑',
label: 'Zn + HCl',
reactants: [
{ sym: 'Zn', coef: 1, M: 65.38, phase: 's', color: '#9BB8CC' },
{ sym: 'HCl', coef: 2, M: 36.46, phase: 'aq', color: '#78D278' },
],
products: [
{ sym: 'ZnCl₂', coef: 1, M: 136.28, phase: 'aq', color: '#4CC9F0' },
{ sym: 'H₂', coef: 1, M: 2.016, phase: 'g', color: '#FFD166' },
],
},
{
name: '2H₂ + O₂ → 2H₂O',
label: 'H₂ + O₂',
reactants: [
{ sym: 'H₂', coef: 2, M: 2.016, phase: 'g', color: '#FFD166' },
{ sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'H₂O', coef: 2, M: 18.015, phase: 'l', color: '#6EB4D7' },
],
},
{
name: 'CH₄ + 2O₂ → CO₂ + 2H₂O',
label: 'Горение метана',
reactants: [
{ sym: 'CH₄', coef: 1, M: 16.043, phase: 'g', color: '#FFD166' },
{ sym: 'O₂', coef: 2, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'CO₂', coef: 1, M: 44.01, phase: 'g', color: '#9B5DE5' },
{ sym: 'H₂O', coef: 2, M: 18.015, phase: 'g', color: '#6EB4D7' },
],
},
{
name: 'N₂ + 3H₂ → 2NH₃',
label: 'Синтез аммиака',
reactants: [
{ sym: 'N₂', coef: 1, M: 28.014, phase: 'g', color: '#9B5DE5' },
{ sym: 'H₂', coef: 3, M: 2.016, phase: 'g', color: '#FFD166' },
],
products: [
{ sym: 'NH₃', coef: 2, M: 17.031, phase: 'g', color: '#06D6E0' },
],
},
{
name: '2Al + 3CuSO₄ → Al₂(SO₄)₃ + 3Cu',
label: 'Al + CuSO₄',
reactants: [
{ sym: 'Al', coef: 2, M: 26.982, phase: 's', color: '#D6D6D6' },
{ sym: 'CuSO₄', coef: 3, M: 159.60, phase: 'aq', color: '#4CC9F0' },
],
products: [
{ sym: 'Al₂(SO₄)₃', coef: 1, M: 342.15, phase: 'aq', color: '#B8D4F0' },
{ sym: 'Cu', coef: 3, M: 63.546, phase: 's', color: '#C87840' },
],
},
{
name: '2Mg + O₂ → 2MgO',
label: 'Горение магния',
reactants: [
{ sym: 'Mg', coef: 2, M: 24.305, phase: 's', color: '#E8E8E8' },
{ sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'MgO', coef: 2, M: 40.304, phase: 's', color: '#FFFFFF' },
],
},
{
name: 'CaCO₃ → CaO + CO₂↑',
label: 'Разложение мела',
reactants: [
{ sym: 'CaCO₃', coef: 1, M: 100.086, phase: 's', color: '#F0F0F0' },
],
products: [
{ sym: 'CaO', coef: 1, M: 56.077, phase: 's', color: '#D4C4A0' },
{ sym: 'CO₂', coef: 1, M: 44.01, phase: 'g', color: '#9B5DE5' },
],
},
{
name: 'HCl + NaOH → NaCl + H₂O',
label: 'Нейтрализация',
reactants: [
{ sym: 'HCl', coef: 1, M: 36.46, phase: 'aq', color: '#78D278' },
{ sym: 'NaOH', coef: 1, M: 40.0, phase: 'aq', color: '#7BF5A4' },
],
products: [
{ sym: 'NaCl', coef: 1, M: 58.44, phase: 'aq', color: '#FFFFFF' },
{ sym: 'H₂O', coef: 1, M: 18.015, phase: 'l', color: '#6EB4D7' },
],
},
{
name: '2KMnO₄ → K₂MnO₄ + MnO₂ + O₂↑',
label: 'Разложение KMnO₄',
reactants: [
{ sym: 'KMnO₄', coef: 2, M: 158.034, phase: 's', color: '#9B59B6' },
],
products: [
{ sym: 'K₂MnO₄', coef: 1, M: 197.132, phase: 's', color: '#27AE60' },
{ sym: 'MnO₂', coef: 1, M: 86.937, phase: 's', color: '#8899AA' },
{ sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' },
],
},
{
name: 'C₂H₅OH + 3O₂ → 2CO₂ + 3H₂O',
label: 'Горение спирта',
reactants: [
{ sym: 'C₂H₅OH', coef: 1, M: 46.068, phase: 'l', color: '#FFD166' },
{ sym: 'O₂', coef: 3, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'CO₂', coef: 2, M: 44.01, phase: 'g', color: '#9B5DE5' },
{ sym: 'H₂O', coef: 3, M: 18.015, phase: 'g', color: '#6EB4D7' },
],
},
];
/* ── Конструктор ─────────────────────────────────────────────────── */
constructor(container) {
this._container = container;
this._recipeIdx = 0;
this._amounts = [];
this._inputMode = [];
this._computed = null;
this._animState = 'idle';
this._animT = 0;
this._raf = null;
this._idleRaf = null;
this._idleT = 0; // continuous time for wobble/bubbles
this._lastIdleTs = 0;
this._sliderPulse = 0; // 0..1, decays after slider change
this._canvas = null;
this._ctx = null;
this._ro = null;
this._W = 0;
this._H = 0;
this._initAmounts();
this._build();
this._startIdleLoop();
}
/* ── Непрерывный анимационный цикл (волны, пузырьки) ───────────── */
_startIdleLoop() {
if (this._idleRaf) return;
this._lastIdleTs = performance.now();
const loop = (now) => {
const dt = Math.min((now - this._lastIdleTs) / 1000, 0.05);
this._lastIdleTs = now;
this._idleT += dt;
if (this._sliderPulse > 0) this._sliderPulse = Math.max(0, this._sliderPulse - dt * 2);
/* skip redraw if reaction animation owns RAF */
if (this._animState !== 'reacting' && this._ctx) {
this._draw();
}
this._idleRaf = requestAnimationFrame(loop);
};
this._idleRaf = requestAnimationFrame(loop);
}
/* ── Инициализация начальных количеств ───────────────────────────── */
_initAmounts() {
const r = StoichSim.RECIPES[this._recipeIdx];
this._amounts = r.reactants.map(re => re.M);
this._inputMode = r.reactants.map(() => 'mass');
this._compute();
}
/* ── Построение всего интерфейса ─────────────────────────────────── */
_build() {
const c = this._container;
c.innerHTML = '';
c.style.cssText = [
'display:flex',
'flex-direction:column',
'height:100%',
'overflow:hidden',
'background:#0D0D1A',
'font-family:Manrope,sans-serif',
].join(';');
/* 1. Topbar */
this._topbar = _stEl('div', {
style: [
'flex:0 0 auto',
'display:flex',
'align-items:center',
'gap:10px',
'padding:0 14px',
'height:50px',
'background:rgba(255,255,255,0.04)',
'border-bottom:1px solid rgba(255,255,255,0.08)',
'min-width:0',
].join(';'),
});
c.appendChild(this._topbar);
/* 2. Основная двухколоночная область */
this._mainArea = _stEl('div', {
style: [
'display:flex',
'flex:1 1 auto',
'overflow:hidden',
'min-height:0',
].join(';'),
});
c.appendChild(this._mainArea);
/* 3. Канва */
this._canvasWrap = _stEl('div', {
style: [
'flex:0 0 180px',
'position:relative',
'width:100%',
'background:#0D0D1A',
'border-top:1px solid rgba(255,255,255,0.08)',
].join(';'),
});
c.appendChild(this._canvasWrap);
/* 4. Нижняя панель итогов */
this._summaryBar = _stEl('div', {
style: [
'flex:0 0 auto',
'display:flex',
'align-items:center',
'flex-wrap:wrap',
'gap:6px',
'padding:8px 14px',
'background:rgba(255,255,255,0.03)',
'border-top:1px solid rgba(255,255,255,0.08)',
].join(';'),
});
c.appendChild(this._summaryBar);
this._renderTopbar();
this._renderMainArea();
this._renderCanvas();
this._renderSummary();
}
/* ── Topbar ─────────────────────────────────────────────────────── */
_renderTopbar() {
const bar = this._topbar;
bar.innerHTML = '';
/* Селектор реакции */
const sel = document.createElement('select');
sel.style.cssText = [
'padding:6px 12px',
'border-radius:7px',
'background:rgba(255,255,255,0.08)',
'color:rgba(255,255,255,0.92)',
'border:1px solid rgba(255,255,255,0.15)',
'font-size:0.92rem',
'font-family:Manrope,sans-serif',
'cursor:pointer',
'flex:0 0 auto',
'max-width:180px',
].join(';');
StoichSim.RECIPES.forEach((rc, i) => {
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = rc.label;
if (i === this._recipeIdx) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener('change', () => {
this._recipeIdx = parseInt(sel.value, 10);
this._animState = 'idle';
this._animT = 0;
this._initAmounts();
this._rebuildAll();
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.3 });
});
bar.appendChild(sel);
/* Уравнение реакции */
this._eqLabel = _stEl('div', {
style: [
'flex:1 1 0',
'font-size:1.1rem',
'color:rgba(255,255,255,0.95)',
'font-weight:700',
'white-space:nowrap',
'overflow:hidden',
'text-overflow:ellipsis',
'min-width:0',
'padding:0 6px',
].join(';'),
textContent: StoichSim.RECIPES[this._recipeIdx].name,
});
bar.appendChild(this._eqLabel);
/* Кнопка сброса */
const resetBtn = _stEl('button', {
style: [
'flex:0 0 auto',
'padding:5px 12px',
'border-radius:6px',
'background:rgba(255,255,255,0.07)',
'color:rgba(255,255,255,0.75)',
'border:1px solid rgba(255,255,255,0.14)',
'font-size:0.85rem',
'font-family:Manrope,sans-serif',
'cursor:pointer',
'white-space:nowrap',
].join(';'),
textContent: 'Сброс',
});
resetBtn.addEventListener('click', () => {
this._animState = 'idle';
this._animT = 0;
this._initAmounts();
this._rebuildAll();
if (window.LabFX) LabFX.sound.play('click');
});
bar.appendChild(resetBtn);
}
/* ── Перестройка всего при смене реакции ─────────────────────────── */
_rebuildAll() {
const r = StoichSim.RECIPES[this._recipeIdx];
this._eqLabel.textContent = r.name;
this._renderMainArea();
this._renderSummary();
this._draw();
}
/* ── Двухколоночная область: реагенты + продукты ─────────────────── */
_renderMainArea() {
const area = this._mainArea;
area.innerHTML = '';
const colStyle = [
'flex:1 1 0',
'overflow-y:auto',
'overflow-x:hidden',
'padding:14px 12px',
'display:flex',
'flex-direction:column',
'gap:12px',
'min-width:0',
].join(';');
/* Левая колонка: реагенты */
const leftCol = _stEl('div', { style: colStyle });
leftCol.appendChild(this._sectionHeader('РЕАГЕНТЫ'));
this._reactantCards = [];
StoichSim.RECIPES[this._recipeIdx].reactants.forEach((re, i) => {
const card = this._buildReactantCard(re, i);
this._reactantCards.push(card);
leftCol.appendChild(card.el);
});
/* Разделитель */
const divider = _stEl('div', {
style: [
'flex:0 0 1px',
'width:1px',
'background:rgba(255,255,255,0.08)',
'align-self:stretch',
].join(';'),
});
/* Правая колонка: продукты */
const rightCol = _stEl('div', { style: colStyle });
rightCol.appendChild(this._sectionHeader('ПРОДУКТЫ'));
this._productCardEls = [];
StoichSim.RECIPES[this._recipeIdx].products.forEach((pr, i) => {
const el = this._buildProductCard(pr, i);
this._productCardEls.push(el);
rightCol.appendChild(el);
});
area.appendChild(leftCol);
area.appendChild(divider);
area.appendChild(rightCol);
}
/* ── Заголовок секции ─────────────────────────────────────────────── */
_sectionHeader(text) {
return _stEl('div', {
style: [
'font-size:0.78rem',
'font-weight:700',
'color:rgba(255,255,255,0.6)',
'text-transform:uppercase',
'letter-spacing:0.07em',
'padding-bottom:4px',
'border-bottom:1px solid rgba(255,255,255,0.07)',
].join(';'),
textContent: text,
});
}
/* ── Карточка реагента ───────────────────────────────────────────── */
_buildReactantCard(re, i) {
const card = _stEl('div', {
style: [
'padding:12px',
'border-radius:8px',
'background:rgba(255,255,255,0.05)',
'border:1px solid rgba(255,255,255,0.1)',
'display:flex',
'flex-direction:column',
'gap:8px',
].join(';'),
});
/* Строка 1: символ + переключатель единиц */
const row1 = _stEl('div', {
style: 'display:flex;align-items:center;justify-content:space-between;gap:8px;',
});
const symEl = _stEl('div', {
style: `font-size:1.15rem;font-weight:700;color:${re.color};`,
textContent: re.sym,
});
row1.appendChild(symEl);
/* Pill-переключатель единиц */
const pill = _stEl('div', {
style: [
'display:flex',
'border-radius:6px',
'overflow:hidden',
'border:1px solid rgba(255,255,255,0.12)',
'flex:0 0 auto',
].join(';'),
});
const modes = [['mass', 'г'], ['mol', 'моль'], ...(re.phase === 'g' ? [['vol', 'л']] : [])];
const modeButtons = {};
const updateMode = (newMode) => {
this._inputMode[i] = newMode;
modes.forEach(([m]) => {
const btn = modeButtons[m];
if (!btn) return;
const active = m === newMode;
btn.style.cssText = this._pillBtnStyle(active);
});
this._syncSlider(i, re, sliderEl, valEl);
this._updateAfterSlider();
};
modes.forEach(([m, label]) => {
const btn = _stEl('button', {
style: this._pillBtnStyle(this._inputMode[i] === m),
textContent: label,
});
btn.addEventListener('click', () => updateMode(m));
modeButtons[m] = btn;
pill.appendChild(btn);
});
row1.appendChild(pill);
card.appendChild(row1);
/* Строка 2: ползунок + значение */
const row2 = _stEl('div', {
style: 'display:flex;align-items:center;gap:8px;',
});
const slParams = this._sliderParams(i, re);
const sliderEl = document.createElement('input');
sliderEl.type = 'range';
sliderEl.min = String(slParams.min);
sliderEl.max = String(slParams.max);
sliderEl.step = String(slParams.step);
sliderEl.value = String(slParams.val);
sliderEl.style.cssText = 'flex:1 1 0;accent-color:#9B5DE5;cursor:pointer;height:6px;min-width:0;';
const valEl = _stEl('div', {
style: [
'font-size:1.0rem',
'font-weight:700',
'color:#FFD166',
'min-width:80px',
'text-align:right',
'flex:0 0 auto',
'white-space:nowrap',
].join(';'),
textContent: this._fmtSliderVal(i, re),
});
sliderEl.addEventListener('input', () => {
const v = parseFloat(sliderEl.value);
const mode = this._inputMode[i];
if (mode === 'mass') this._amounts[i] = v;
else if (mode === 'mol') this._amounts[i] = v * re.M;
else this._amounts[i] = (v / 22.4) * re.M;
valEl.textContent = this._fmtSliderVal(i, re);
this._updateAfterSlider();
});
row2.appendChild(sliderEl);
row2.appendChild(valEl);
card.appendChild(row2);
/* Бейдж лимита (скрытый изначально) */
const limitBadge = _stEl('div', {
style: [
'display:none',
'padding:2px 6px',
'border-radius:4px',
'background:rgba(239,71,111,0.2)',
'color:#EF476F',
'font-size:0.72rem',
'font-weight:700',
'width:fit-content',
].join(';'),
textContent: 'ЛИМИТ',
});
card.appendChild(limitBadge);
return { el: card, limitBadge, sliderEl, valEl, re, i };
}
/* ── Pill-кнопка стиль ───────────────────────────────────────────── */
_pillBtnStyle(active) {
return [
'padding:3px 8px',
'font-size:0.78rem',
'font-weight:600',
'cursor:pointer',
'border:none',
'font-family:Manrope,sans-serif',
'transition:background .12s',
active
? 'background:rgba(155,93,229,0.55);color:#fff'
: 'background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.65)',
].join(';');
}
/* ── Карточка продукта ───────────────────────────────────────────── */
_buildProductCard(pr, i) {
const comp = this._computed;
const q = comp ? comp.productQ[i] : { n: 0, m: 0, v: 0 };
const card = _stEl('div', {
style: [
'padding:12px',
'border-radius:8px',
'background:rgba(255,255,255,0.05)',
'border:1px solid rgba(255,255,255,0.1)',
'display:flex',
'flex-direction:column',
'gap:6px',
].join(';'),
});
card.setAttribute('data-prod-idx', i);
/* Строка 1: символ + бейдж (газ) */
const row1 = _stEl('div', {
style: 'display:flex;align-items:center;gap:8px;',
});
row1.appendChild(_stEl('div', {
style: `font-size:1.15rem;font-weight:700;color:${pr.color};`,
textContent: pr.sym,
}));
if (pr.phase === 'g') {
row1.appendChild(_stEl('div', {
style: [
'padding:2px 7px',
'border-radius:4px',
'background:rgba(155,93,229,0.15)',
'color:#9B5DE5',
'font-size:0.78rem',
'font-weight:600',
].join(';'),
textContent: 'газ',
}));
}
card.appendChild(row1);
/* Строки данных */
card.appendChild(this._dataRow('n =', q.n.toFixed(3) + ' моль'));
card.appendChild(this._dataRow('m =', q.m.toFixed(3) + ' г'));
if (pr.phase === 'g') {
card.appendChild(this._dataRow('V =', q.v.toFixed(3) + ' л (н.у.)'));
}
return card;
}
/* ── Строка данных label: value ───────────────────────────────────── */
_dataRow(label, value) {
const row = _stEl('div', {
style: 'display:flex;align-items:baseline;gap:6px;',
});
row.appendChild(_stEl('span', {
style: 'font-size:0.85rem;color:rgba(255,255,255,0.65);min-width:28px;',
textContent: label,
}));
const valEl = _stEl('span', {
style: 'font-size:0.95rem;color:rgba(255,255,255,0.92);font-weight:600;',
textContent: value,
});
row.appendChild(valEl);
return row;
}
/* ── Обновление после изменения ползунка ─────────────────────────── */
_updateAfterSlider() {
this._compute();
this._updateProductCards();
this._updateLimitBadges();
this._renderSummary();
this._draw();
}
/* ── Обновление карточек продуктов ───────────────────────────────── */
_updateProductCards() {
const r = StoichSim.RECIPES[this._recipeIdx];
const comp = this._computed;
if (!comp) return;
this._productCardEls.forEach((card, i) => {
const pr = r.products[i];
const q = comp.productQ[i];
const rows = card.querySelectorAll('[data-data-row]');
/* Пересоздаём строки с новыми значениями */
const existingRows = Array.from(card.querySelectorAll('[data-data-row]'));
existingRows.forEach(el => el.remove());
const newRows = [];
newRows.push(this._dataRow('n =', q.n.toFixed(3) + ' моль'));
newRows.push(this._dataRow('m =', q.m.toFixed(3) + ' г'));
if (pr.phase === 'g') {
newRows.push(this._dataRow('V =', q.v.toFixed(3) + ' л (н.у.)'));
}
newRows.forEach(r => {
r.setAttribute('data-data-row', '1');
card.appendChild(r);
});
});
/* Повторный проход — удалить старые, добавить свежие */
/* (карточки пересобираются полностью) */
const area = this._mainArea;
const cols = area.children;
if (cols.length < 3) return;
const rightCol = cols[2];
/* Убираем всё кроме заголовка */
while (rightCol.children.length > 1) {
rightCol.removeChild(rightCol.lastChild);
}
StoichSim.RECIPES[this._recipeIdx].products.forEach((pr, i) => {
const el = this._buildProductCard(pr, i);
this._productCardEls[i] = el;
rightCol.appendChild(el);
});
}
/* ── Обновление бейджей лимита ───────────────────────────────────── */
_updateLimitBadges() {
if (!this._computed || !this._reactantCards) return;
this._reactantCards.forEach((card, i) => {
card.limitBadge.style.display = (i === this._computed.limitIdx) ? 'block' : 'none';
/* Подсветка карточки лимитирующего реагента */
card.el.style.borderColor = (i === this._computed.limitIdx)
? 'rgba(239,71,111,0.5)'
: 'rgba(255,255,255,0.1)';
});
}
/* ── Canvas-область ─────────────────────────────────────────────── */
_renderCanvas() {
const wrap = this._canvasWrap;
wrap.innerHTML = '';
this._canvas = document.createElement('canvas');
this._canvas.style.cssText = 'display:block;width:100%;height:180px;';
wrap.appendChild(this._canvas);
/* Кнопка запуска анимации */
this._animBtn = _stEl('button', {
style: [
'position:absolute',
'right:14px',
'bottom:10px',
'padding:7px 18px',
'border-radius:8px',
'background:linear-gradient(135deg,#9B5DE5,#4CC9F0)',
'color:#fff',
'font-size:0.9rem',
'font-weight:700',
'border:none',
'cursor:pointer',
'font-family:Manrope,sans-serif',
'z-index:1',
].join(';'),
});
this._animBtn.textContent = 'Запустить реакцию';
this._animBtn.addEventListener('click', () => {
this._startAnim();
});
wrap.appendChild(this._animBtn);
requestAnimationFrame(() => {
this._ctx = this._canvas.getContext('2d');
if (window.ResizeObserver) {
if (this._ro) this._ro.disconnect();
this._ro = new ResizeObserver(() => { this._fitCanvas(); this._draw(); });
this._ro.observe(this._canvas);
}
this._fitCanvas();
this._draw();
});
}
/* ── Нижняя панель итогов ─────────────────────────────────────────── */
_renderSummary() {
const bar = this._summaryBar;
bar.innerHTML = '';
const r = StoichSim.RECIPES[this._recipeIdx];
const comp = this._computed;
if (!comp) return;
const limRe = r.reactants[comp.limitIdx];
const chips = [];
/* Лимит */
chips.push({ label: 'Лимит:', value: limRe.sym, color: '#EF476F' });
/* Избытки */
r.reactants.forEach((re, i) => {
if (i !== comp.limitIdx) {
const excessM = (comp.reactantQ[i].nExcess * re.M).toFixed(2);
chips.push({ label: 'Избыток ' + re.sym + ':', value: excessM + ' г', color: '#FFD166' });
}
});
/* Теоретический выход */
const totalProdM = comp.productQ.reduce((s, q) => s + q.m, 0);
chips.push({ label: 'Выход теор.:', value: totalProdM.toFixed(3) + ' г', color: '#06D6E0' });
/* Суммарный объём газов */
const totalGasV = r.products.reduce((s, pr, i) =>
s + (pr.phase === 'g' ? comp.productQ[i].v : 0), 0
);
if (totalGasV > 0.0001) {
chips.push({ label: 'Газов:', value: totalGasV.toFixed(3) + ' л', color: '#9B5DE5' });
}
chips.forEach((chip, idx) => {
if (idx > 0) {
bar.appendChild(_stEl('span', {
style: 'color:rgba(255,255,255,0.3);font-size:0.9rem;',
textContent: '|',
}));
}
const chipEl = _stEl('span', {
style: 'display:inline-flex;align-items:center;gap:4px;font-size:0.9rem;',
});
chipEl.appendChild(_stEl('span', {
style: 'color:rgba(255,255,255,0.65);',
textContent: chip.label,
}));
chipEl.appendChild(_stEl('span', {
style: `color:${chip.color};font-weight:700;`,
textContent: chip.value,
}));
bar.appendChild(chipEl);
});
}
/* ── Вспомогательные методы слайдера ─────────────────────────────── */
_sliderParams(i, re) {
const mode = this._inputMode[i];
if (mode === 'mol') {
return { min: 0.01, max: 10, step: 0.01, val: +(this._amounts[i] / re.M).toFixed(4) };
} else if (mode === 'vol') {
return { min: 0.1, max: 100, step: 0.1, val: +(this._amounts[i] / re.M * 22.4).toFixed(3) };
}
return {
min: +(re.M * 0.1).toFixed(2),
max: +(re.M * 10).toFixed(0),
step: +(re.M * 0.01).toFixed(2),
val: +this._amounts[i].toFixed(4),
};
}
_syncSlider(i, re, sliderEl, valEl) {
const p = this._sliderParams(i, re);
sliderEl.min = String(p.min);
sliderEl.max = String(p.max);
sliderEl.step = String(p.step);
sliderEl.value = String(p.val);
valEl.textContent = this._fmtSliderVal(i, re);
}
_fmtSliderVal(i, re) {
const mode = this._inputMode[i];
if (mode === 'mol') return (this._amounts[i] / re.M).toFixed(3) + ' моль';
else if (mode === 'vol') return (this._amounts[i] / re.M * 22.4).toFixed(3) + ' л';
return this._amounts[i].toFixed(2) + ' г';
}
/* ── Расчёт стехиометрии ─────────────────────────────────────────── */
_compute() {
const r = StoichSim.RECIPES[this._recipeIdx];
const ratios = r.reactants.map((re, i) => (this._amounts[i] / re.M) / re.coef);
const limitVal = Math.min(...ratios);
const limitIdx = ratios.indexOf(limitVal);
const reactantQ = r.reactants.map((re, i) => {
const nConsumed = limitVal * re.coef;
const nActual = this._amounts[i] / re.M;
return {
n: nConsumed,
m: nConsumed * re.M,
v: nConsumed * 22.4,
nExcess: nActual - nConsumed,
mExcess: (nActual - nConsumed) * re.M,
vExcess: (nActual - nConsumed) * 22.4,
};
});
const productQ = r.products.map(pr => {
const nProd = limitVal * pr.coef;
return { n: nProd, m: nProd * pr.M, v: nProd * 22.4 };
});
const prevLimitIdx = this._computed ? this._computed.limitIdx : -1;
this._computed = { limitIdx, limitVal, ratios, reactantQ, productQ };
if (window.LabFX && prevLimitIdx !== -1 && prevLimitIdx !== limitIdx) {
LabFX.haptic(20);
LabFX.sound.play('tick', { pitch: 0.8, volume: 0.3 });
}
}
/* ── Canvas ─────────────────────────────────────────────────────── */
_fitCanvas() {
const cv = this._canvas;
if (!cv) return;
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth;
const h = cv.clientHeight;
if (!w || !h) return;
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
cv.width = Math.round(w * dpr);
cv.height = Math.round(h * dpr);
this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
this._W = w;
this._H = h;
}
_draw() {
const ctx = this._ctx;
if (!ctx) return;
const W = this._W || (this._canvas ? this._canvas.clientWidth : 0);
const H = this._H || (this._canvas ? this._canvas.clientHeight : 0);
if (!W || !H) return;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
const r = StoichSim.RECIPES[this._recipeIdx];
const comp = this._computed;
if (!comp) return;
const allSubs = [
...r.reactants.map((s, i) => ({ s, i, isReactant: true, q: comp.reactantQ[i] })),
...r.products.map((s, i) => ({ s, i, isReactant: false, q: comp.productQ[i] })),
];
const N = allSubs.length;
const gap = 10;
const boxW = Math.min(Math.floor((W - (N + 1) * gap) / N), 140);
const boxH = Math.min(H - 28, 150);
const totalW = N * boxW + (N - 1) * gap;
const startX = (W - totalW) / 2;
const topY = (H - boxH) / 2 - 6;
const sepIdx = r.reactants.length;
const animT = this._animState === 'reacting' ? this._animT
: (this._animState === 'done' ? 1 : 0);
allSubs.forEach(({ s, i, isReactant, q }, k) => {
const x = startX + k * (boxW + gap);
/* Стрелка между реагентами и продуктами */
if (k === sepIdx) {
const arrowX = x - gap * 0.5;
const midY = topY + boxH / 2;
const t = this._idleT || 0;
const reacting = this._animState === 'reacting';
ctx.save();
if (reacting) {
ctx.shadowColor = '#9B5DE5';
ctx.shadowBlur = 12 + Math.sin(t * 12) * 6;
}
const alpha = reacting ? 0.85 + 0.15 * Math.sin(t * 12) : 0.35 + animT * 0.5;
ctx.strokeStyle = `rgba(255,255,255,${alpha})`;
ctx.lineWidth = reacting ? 3.2 : 2.5;
ctx.beginPath();
ctx.moveTo(arrowX - 18, midY);
ctx.lineTo(arrowX + 2, midY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(arrowX - 7, midY - 7);
ctx.lineTo(arrowX + 2, midY);
ctx.lineTo(arrowX - 7, midY + 7);
ctx.stroke();
ctx.restore();
/* sparks travelling along the arrow during reaction */
if (reacting) {
for (let s = 0; s < 3; s++) {
const sp = ((t * 1.5 + s * 0.33) % 1);
const sx = arrowX - 18 + sp * 20;
ctx.save();
ctx.fillStyle = `rgba(255,209,102,${1 - sp})`;
ctx.beginPath();
ctx.arc(sx, midY, 2.4, 0, Math.PI * 2);
ctx.fill();
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 t = this._idleT || 0;
/* Pulsing border for limit */
const pulse = isLimit ? (0.5 + 0.5 * Math.sin(t * 3.2)) : 0;
const borderColor = isLimit
? `rgba(239,71,111,${0.55 + 0.35 * pulse + animT * 0.3})`
: 'rgba(255,255,255,0.14)';
ctx.strokeStyle = borderColor;
ctx.lineWidth = isLimit ? 2 + pulse * 0.6 : 1;
ctx.beginPath();
_stRoundRect(ctx, x, y, bw, bh, 7);
ctx.stroke();
/* Glow for limit */
if (isLimit) {
ctx.save();
ctx.shadowColor = '#EF476F';
ctx.shadowBlur = 14 + pulse * 8;
ctx.strokeStyle = 'rgba(239,71,111,0)';
ctx.beginPath();
_stRoundRect(ctx, x, y, bw, bh, 7);
ctx.stroke();
ctx.restore();
}
ctx.fillStyle = 'rgba(255,255,255,0.03)';
ctx.fill();
/* Header symbol */
ctx.fillStyle = sub.color;
ctx.font = 'bold 13px Manrope,sans-serif';
ctx.textAlign = 'center';
ctx.fillText(sub.sym, x + bw / 2, y + 18);
const areaX = x + 8;
const areaY = y + 26;
const areaW = bw - 16;
const areaH = bh - 46;
/* Liquid level: scales with amount (reactants: m; products: n) */
let fillFrac;
if (isReactant) {
fillFrac = Math.min(1, q.n / 0.25 + 0.18);
fillFrac *= Math.max(0.15, 1 - animT);
} else {
fillFrac = Math.min(1, animT * 1.2) * Math.min(1, q.n / 0.25 + 0.18);
}
fillFrac = Math.max(0, Math.min(1, fillFrac));
const isGas = sub.phase === 'g';
const liquidH = areaH * fillFrac;
const liquidTop = areaY + areaH - liquidH;
if (liquidH > 1) {
/* wavy surface */
ctx.save();
const grad = ctx.createLinearGradient(0, liquidTop, 0, areaY + areaH);
grad.addColorStop(0, sub.color + 'aa');
grad.addColorStop(1, sub.color + '55');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(areaX, liquidTop);
const waveSeed = sub.sym.charCodeAt(0);
for (let xi = 0; xi <= areaW; xi += 2) {
const wave = Math.sin((xi / areaW) * Math.PI * 3 + t * 2 + waveSeed) * 1.4
+ Math.sin((xi / areaW) * Math.PI * 5 + t * 1.3) * 0.8;
ctx.lineTo(areaX + xi, liquidTop + wave);
}
ctx.lineTo(areaX + areaW, areaY + areaH);
ctx.lineTo(areaX, areaY + areaH);
ctx.closePath();
ctx.fill();
/* highlight on surface */
ctx.strokeStyle = sub.color + 'cc';
ctx.lineWidth = 1;
ctx.beginPath();
for (let xi = 0; xi <= areaW; xi += 2) {
const wave = Math.sin((xi / areaW) * Math.PI * 3 + t * 2 + waveSeed) * 1.4
+ Math.sin((xi / areaW) * Math.PI * 5 + t * 1.3) * 0.8;
if (xi === 0) ctx.moveTo(areaX + xi, liquidTop + wave);
else ctx.lineTo(areaX + xi, liquidTop + wave);
}
ctx.stroke();
ctx.restore();
/* bubbles for gas products / aq solutions */
if (isGas || sub.phase === 'aq') {
const seed2 = sub.sym.split('').reduce((a, ch) => a + ch.charCodeAt(0), 0);
const nBub = isGas ? 7 : 3;
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.45)';
for (let b = 0; b < nBub; b++) {
const phase = (t * (isGas ? 0.9 : 0.4) + b * 0.7 + seed2 * 0.13) % 1;
const bx = areaX + areaW * (0.15 + 0.7 * _stLcg(seed2 + b * 11));
const by = areaY + areaH - phase * liquidH;
const br = isGas ? (1.4 + (1 - phase) * 1.6) : (1.0 + (1 - phase) * 0.8);
ctx.beginPath();
ctx.arc(bx, by, br, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
/* particles flowing on reaction (kept from original logic, simplified) */
if (animT > 0 && animT < 1) {
const maxParticles = 16;
const nParticles = Math.max(1, Math.round(Math.min(q.n / 0.2, 1) * maxParticles));
const seedP = sub.sym.split('').reduce((a, ch) => a + ch.charCodeAt(0), 0);
const alpha = isReactant ? Math.max(0, 1 - animT * 1.5) : Math.min(1, animT * 1.8);
ctx.globalAlpha = alpha;
ctx.fillStyle = sub.color;
for (let p = 0; p < nParticles; p++) {
const px = areaX + _stLcg(seedP + p * 7) * areaW;
const py = areaY + _stLcg(seedP + p * 7 + 3) * areaH;
const jx = isReactant ? (x + bw / 2 - px) * animT : 0;
const jy = isReactant ? (y + bh / 2 - py) * animT * 0.5 : 0;
ctx.beginPath();
ctx.arc(px + jx, py + jy, 3, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
/* Phase label */
const phaseText = sub.phase === 'g' ? '(г)' : sub.phase === 'aq' ? '(р-р)' : sub.phase === 'l' ? '(ж)' : '(тв)';
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = '9px Manrope,sans-serif';
ctx.textAlign = 'center';
ctx.fillText(phaseText, x + bw / 2, y + bh - 18);
/* Mass label */
ctx.fillStyle = 'rgba(255,214,102,0.95)';
ctx.font = 'bold 9px Manrope,sans-serif';
ctx.textAlign = 'right';
ctx.fillText(q.m.toFixed(2) + 'г', x + bw - 4, y + bh - 6);
/* LIMIT label */
if (isLimit) {
ctx.fillStyle = `rgba(239,71,111,${0.7 + 0.3 * pulse})`;
ctx.font = 'bold 8.5px Manrope,sans-serif';
ctx.textAlign = 'left';
ctx.fillText('ЛИМИТ', x + 4, y + bh - 6);
}
ctx.restore();
}
/* ── Анимация реакции ───────────────────────────────────────────── */
_startAnim() {
if (this._animState === 'reacting') return;
this._animState = 'reacting';
this._animT = 0;
if (this._animBtn) {
this._animBtn.disabled = true;
this._animBtn.style.opacity = '0.5';
}
const dur = 1200;
const start = performance.now();
let lastTs = start;
if (window.LabFX && this._ctx) {
LabFX.sound.play('fizz');
const r = StoichSim.RECIPES[this._recipeIdx];
const W = this._W || 300;
const H = this._H || 180;
r.reactants.forEach((re, i) => {
const x = (W / (r.reactants.length + 1)) * (i + 1);
LabFX.particles.emit({
ctx: this._ctx, x, y: H * 0.4, count: 8,
color: re.color || '#FFFFFF', speed: 35, spread: 2.5,
angle: -Math.PI / 2, gravity: -50, life: 800, shape: 'ring',
});
});
}
const tick = (now) => {
const dt = (now - lastTs) / 1000;
lastTs = now;
if (window.LabFX) LabFX.particles.update(dt);
this._animT = Math.min(1, (now - start) / dur);
this._draw();
if (window.LabFX && this._ctx) LabFX.particles.draw(this._ctx);
if (this._animT < 1) {
this._raf = requestAnimationFrame(tick);
} else {
this._animState = 'done';
this._draw();
if (this._animBtn) {
this._animBtn.disabled = false;
this._animBtn.style.opacity = '1';
this._animBtn.textContent = 'Сбросить анимацию';
this._animBtn.onclick = () => {
this._animState = 'idle';
this._animT = 0;
this._animBtn.textContent = 'Запустить реакцию';
this._animBtn.onclick = () => this._startAnim();
this._draw();
};
}
}
};
this._raf = requestAnimationFrame(tick);
}
/* ── Public API ─────────────────────────────────────────────────── */
fit() {
this._fitCanvas();
this._draw();
}
destroy() {
if (this._raf) cancelAnimationFrame(this._raf);
if (this._idleRaf) cancelAnimationFrame(this._idleRaf);
if (this._ro) this._ro.disconnect();
this._canvas = null;
this._ctx = null;
this._idleRaf = null;
this._raf = null;
}
}
/* ── Helpers (prefixed _st to avoid collisions) ─────────────────── */
function _stEl(tag, props) {
const el = document.createElement(tag);
Object.entries(props || {}).forEach(([k, v]) => {
if (k === 'textContent') el.textContent = v;
else if (k === 'style') el.style.cssText = v;
else el[k] = v;
});
return el;
}
function _stRoundRect(ctx, x, y, w, h, r) {
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
function _stLcg(seed) {
const a = 1664525, c = 1013904223, m = 2 ** 32;
return ((a * seed + c) % m) / m;
}
/* ═══════════════════════════════════════════════════════════════════
Lab UI init
═══════════════════════════════════════════════════════════════════ */
var _stoichSim = null;
function _openStoich() {
document.getElementById('sim-topbar-title').textContent = 'Стехиометрия';
_simShow('sim-stoichiometry');
requestAnimationFrame(() => requestAnimationFrame(() => {
const container = document.getElementById('stoichiometry-wrap');
if (!_stoichSim) {
_stoichSim = new StoichSim(container);
} else {
_stoichSim.fit();
}
}));
}