'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(); } })); }