Files
Learn_System/frontend/js/labs/stoichiometry.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

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

893 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ═══════════════════════════════════════════════════════════════════════
StoichSim — «Стехиометрия»
Визуальный интерактивный калькулятор стехиометрии с анимацией.
═══════════════════════════════════════════════════════════════════════ */
class StoichSim {
/* ── Рецепты реакций ─────────────────────────────────────────────── */
static RECIPES = [
{
name: 'Zn + 2HCl → ZnCl₂ + H₂↑',
label: 'Zn + HCl',
reactants: [
{ sym: 'Zn', coef: 1, M: 65.38, phase: 's', color: '#9BB8CC' },
{ sym: 'HCl', coef: 2, M: 36.46, phase: 'aq', color: '#78D278' },
],
products: [
{ sym: 'ZnCl₂', coef: 1, M: 136.28, phase: 'aq', color: '#4CC9F0' },
{ sym: 'H₂', coef: 1, M: 2.016, phase: 'g', color: '#FFD166' },
],
},
{
name: '2H₂ + O₂ → 2H₂O',
label: 'H₂ + O₂',
reactants: [
{ sym: 'H₂', coef: 2, M: 2.016, phase: 'g', color: '#FFD166' },
{ sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'H₂O', coef: 2, M: 18.015, phase: 'l', color: '#6EB4D7' },
],
},
{
name: 'CH₄ + 2O₂ → CO₂ + 2H₂O',
label: 'Горение метана',
reactants: [
{ sym: 'CH₄', coef: 1, M: 16.043, phase: 'g', color: '#FFD166' },
{ sym: 'O₂', coef: 2, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'CO₂', coef: 1, M: 44.01, phase: 'g', color: '#9B5DE5' },
{ sym: 'H₂O', coef: 2, M: 18.015, phase: 'g', color: '#6EB4D7' },
],
},
{
name: 'N₂ + 3H₂ → 2NH₃',
label: 'Синтез аммиака',
reactants: [
{ sym: 'N₂', coef: 1, M: 28.014, phase: 'g', color: '#9B5DE5' },
{ sym: 'H₂', coef: 3, M: 2.016, phase: 'g', color: '#FFD166' },
],
products: [
{ sym: 'NH₃', coef: 2, M: 17.031, phase: 'g', color: '#06D6E0' },
],
},
{
name: '2Al + 3CuSO₄ → Al₂(SO₄)₃ + 3Cu',
label: 'Al + CuSO₄',
reactants: [
{ sym: 'Al', coef: 2, M: 26.982, phase: 's', color: '#D6D6D6' },
{ sym: 'CuSO₄',coef: 3, M: 159.60, phase: 'aq', color: '#4CC9F0' },
],
products: [
{ sym: 'Al₂(SO₄)₃', coef: 1, M: 342.15, phase: 'aq', color: '#B8D4F0' },
{ sym: 'Cu', coef: 3, M: 63.546, phase: 's', color: '#C87840' },
],
},
{
name: '2Mg + O₂ → 2MgO',
label: 'Горение магния',
reactants: [
{ sym: 'Mg', coef: 2, M: 24.305, phase: 's', color: '#E8E8E8' },
{ sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'MgO', coef: 2, M: 40.304, phase: 's', color: '#FFFFFF' },
],
},
{
name: 'CaCO₃ → CaO + CO₂↑',
label: 'Разложение мела',
reactants: [
{ sym: 'CaCO₃', coef: 1, M: 100.086, phase: 's', color: '#F0F0F0' },
],
products: [
{ sym: 'CaO', coef: 1, M: 56.077, phase: 's', color: '#D4C4A0' },
{ sym: 'CO₂', coef: 1, M: 44.01, phase: 'g', color: '#9B5DE5' },
],
},
{
name: 'HCl + NaOH → NaCl + H₂O',
label: 'Нейтрализация',
reactants: [
{ sym: 'HCl', coef: 1, M: 36.46, phase: 'aq', color: '#78D278' },
{ sym: 'NaOH', coef: 1, M: 40.0, phase: 'aq', color: '#7BF5A4' },
],
products: [
{ sym: 'NaCl', coef: 1, M: 58.44, phase: 'aq', color: '#FFFFFF' },
{ sym: 'H₂O', coef: 1, M: 18.015, phase: 'l', color: '#6EB4D7' },
],
},
{
name: '2KMnO₄ → K₂MnO₄ + MnO₂ + O₂↑',
label: 'Разложение KMnO₄',
reactants: [
{ sym: 'KMnO₄', coef: 2, M: 158.034, phase: 's', color: '#9B59B6' },
],
products: [
{ sym: 'K₂MnO₄', coef: 1, M: 197.132, phase: 's', color: '#27AE60' },
{ sym: 'MnO₂', coef: 1, M: 86.937, phase: 's', color: '#1A1A2E' },
{ sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' },
],
},
{
name: 'C₂H₅OH + 3O₂ → 2CO₂ + 3H₂O',
label: 'Горение спирта',
reactants: [
{ sym: 'C₂H₅OH', coef: 1, M: 46.068, phase: 'l', color: '#FFD166' },
{ sym: 'O₂', coef: 3, M: 31.998, phase: 'g', color: '#EF476F' },
],
products: [
{ sym: 'CO₂', coef: 2, M: 44.01, phase: 'g', color: '#9B5DE5' },
{ sym: 'H₂O', coef: 3, M: 18.015, phase: 'g', color: '#6EB4D7' },
],
},
];
/* ── Конструктор ─────────────────────────────────────────────────── */
constructor(container) {
this._container = container;
this._recipeIdx = 0;
this._amounts = []; // граммы для каждого реагента
this._inputMode = []; // 'mass' | 'mol' | 'vol' для каждого реагента
this._animState = 'idle'; // idle | reacting | done
this._animT = 0;
this._raf = null;
this._computed = null; // результаты последнего расчёта
this._init();
this._setRecipe(0);
}
/* ── Построение DOM ─────────────────────────────────────────────── */
_init() {
const c = this._container;
c.innerHTML = '';
// ── Wrapper layout ──
c.style.cssText = 'display:flex;flex-direction:column;height:100%;overflow:hidden;background:#0D0D1A;';
// ── Equation bar ──
this._eqBar = _stEl('div', {
style: 'flex:0 0 auto;padding:10px 16px 6px;background:rgba(255,255,255,0.04);border-bottom:1px solid rgba(255,255,255,0.08);',
});
c.appendChild(this._eqBar);
// ── Main area ──
const main = _stEl('div', { style: 'flex:1 1 auto;display:flex;min-height:0;overflow:hidden;' });
c.appendChild(main);
// Left panel (reagent inputs)
this._leftPanel = _stEl('div', {
style: 'flex:0 0 220px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:10px 10px;border-right:1px solid rgba(255,255,255,0.07);',
});
main.appendChild(this._leftPanel);
// Center canvas area
const centerWrap = _stEl('div', {
style: 'flex:1 1 auto;display:flex;flex-direction:column;align-items:stretch;min-width:0;',
});
this._canvas = document.createElement('canvas');
this._canvas.style.cssText = 'flex:1 1 auto;width:100%;height:100%;display:block;';
centerWrap.appendChild(this._canvas);
main.appendChild(centerWrap);
// Right panel (step-by-step)
this._rightPanel = _stEl('div', {
style: 'flex:0 0 240px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:10px 10px;border-left:1px solid rgba(255,255,255,0.07);',
});
main.appendChild(this._rightPanel);
// ── Bottom HUD ──
this._hud = _stEl('div', {
style: 'flex:0 0 auto;display:flex;gap:12px;flex-wrap:wrap;align-items:center;padding:8px 16px;background:rgba(0,0,0,0.3);border-top:1px solid rgba(255,255,255,0.07);font-size:.76rem;',
});
c.appendChild(this._hud);
// Canvas context
this._ctx = this._canvas.getContext('2d');
// ResizeObserver
if (window.ResizeObserver) {
this._ro = new ResizeObserver(() => { this._fitCanvas(); this._draw(); });
this._ro.observe(this._canvas);
}
}
/* ── Выбрать рецепт ─────────────────────────────────────────────── */
_setRecipe(idx) {
if (this._recipeIdx !== undefined && this._recipeIdx !== idx && window.LabFX) {
LabFX.sound.play('click', { pitch: 1.3 });
}
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,
};
});
const prevLimitIdx = this._computed ? this._computed.limitIdx : -1;
this._computed = { limitIdx, limitVal, ratios, reactantQ, productQ };
// LabFX: haptic + tick when limiting reagent changes
if (window.LabFX && prevLimitIdx !== limitIdx) {
LabFX.haptic(20);
LabFX.sound.play('tick', { pitch: 0.8, volume: 0.3 });
}
}
/* ── Правая панель: пошаговый расчёт ───────────────────────────── */
_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();
let lastTs = start;
// LabFX: fizz sound + bubble particles at reactant boxes
if (window.LabFX && this._ctx) {
LabFX.sound.play('fizz');
const W = this._canvas.offsetWidth || 300;
const H = this._canvas.offsetHeight || 200;
const r = StoichSim.RECIPES[this._recipeIdx];
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._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();
}
}));
}