6afe928c0d
ФУНДАМЕНТ (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>
893 lines
33 KiB
JavaScript
893 lines
33 KiB
JavaScript
'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();
|
||
}
|
||
}));
|
||
}
|