be1e558be9
- Менделеев: clamp() для font-size символа элемента (2.4rem..4.4rem) + padding-top 28px → символ не обрезается на узких панелях - Качественные реакции: в Свободно/Тренировке Проб1-4 содержат известные ионы (видна подпись), в Тренировке Образец — отдельный неизвестный; в Экзамене можно переключаться между пробирками и ответить отдельно для каждой (verdict сохраняется) - Стехиометрия: непрерывный анимационный цикл — волна на поверхности жидкости, пузырьки в газах/растворах, пульсирующая красная рамка + ЛИМИТ-лейбл у лимитирующего реагента, искры вдоль стрелки реакции, glow на стрелке во время реакции
1222 lines
42 KiB
JavaScript
1222 lines
42 KiB
JavaScript
'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();
|
||
}
|
||
}));
|
||
}
|