From 9aa8c76932aeedad811a6553a55c0491c92a22cd Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 26 May 2026 15:51:25 +0300 Subject: [PATCH] =?UTF-8?q?refactor(labs):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D1=81=D1=82=D0=B5=D1=85=D0=B8=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D0=B8=D0=B8=20=D0=B8=20=D0=BA=D0=B0=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BA=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Стехиометрия → 4-шаговый wizard (Реакция → Количества → Лимит → Продукты), KaTeX в displayMode, крупные карточки - Качественные реакции → центрированная сцена с большой пробиркой, журнал справа 290px, нижняя полка реагентов, убран список ионов - Контраст: основной текст rgba(.92), вторичный (.7), шрифты от .85rem --- frontend/js/labs/qualanalysis.js | 898 ++++++++++++-------- frontend/js/labs/stoichiometry.js | 1298 ++++++++++++++++++----------- 2 files changed, 1388 insertions(+), 808 deletions(-) diff --git a/frontend/js/labs/qualanalysis.js b/frontend/js/labs/qualanalysis.js index c3ddb12..796dfe5 100644 --- a/frontend/js/labs/qualanalysis.js +++ b/frontend/js/labs/qualanalysis.js @@ -1,8 +1,7 @@ 'use strict'; /* ════════════════════════════════════════════════════════════════════ QualAnalysisSim — Качественный анализ катионов и анионов - Режим 1: «Определить ион» (guided identification) - Режим 2: «Неизвестное вещество» (drag-drop free experiment) + Редизайн: centered tube, large log panel, reagent shelf bottom ════════════════════════════════════════════════════════════════════ */ class QualAnalysisSim { @@ -404,7 +403,7 @@ class QualAnalysisSim { /* ── constructor ─────────────────────────────────────────────── */ constructor(container) { this._container = container; - this._mode = 'identify'; // 'identify' | 'unknown' + this._mode = 'identify'; this._targetIon = null; this._log = []; this._answered = false; @@ -412,9 +411,14 @@ class QualAnalysisSim { this._precipParticles = []; this._gasParticles = []; this._raf = null; - this._tubeState = { color: null, precipColor: null, precipH: 0, gasLabel: null, flameColor: null, solColor: 'rgba(100,180,255,0.18)' }; - this._dragReagent = null; - this._dragX = 0; this._dragY = 0; + this._tubeState = { + color: null, + precipColor: null, + precipH: 0, + gasLabel: null, + flameColor: null, + solColor: 'rgba(100,180,255,0.18)' + }; this._score = 0; this._lastT = 0; this._build(); @@ -425,107 +429,269 @@ class QualAnalysisSim { /* ── DOM build ───────────────────────────────────────────────── */ _build() { this._container.innerHTML = ''; - this._container.style.cssText = 'display:flex;flex-direction:column;height:100%;background:#0D0D1A;color:#E0E0FF;font-family:Manrope,sans-serif;overflow:hidden;user-select:none'; + this._container.style.cssText = [ + 'display:flex', + 'flex-direction:column', + 'height:100%', + 'background:#0D0D1A', + 'color:#E0E0FF', + 'font-family:Manrope,sans-serif', + 'overflow:hidden', + 'user-select:none', + ].join(';'); - /* top toolbar */ + /* ── TOP BAR ── */ const tb = document.createElement('div'); - tb.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.08);flex-shrink:0;flex-wrap:wrap'; - tb.innerHTML = ` - - -
- Счёт: 0 - `; + tb.style.cssText = [ + 'display:flex', + 'align-items:center', + 'gap:10px', + 'padding:10px 16px', + 'border-bottom:1px solid rgba(255,255,255,0.09)', + 'flex-shrink:0', + 'flex-wrap:wrap', + 'background:rgba(255,255,255,0.02)', + ].join(';'); + + /* mode tabs */ + const tabsWrap = document.createElement('div'); + tabsWrap.style.cssText = 'display:flex;gap:6px'; + + const makeTab = (id, text) => { + const b = document.createElement('button'); + b.id = id; + b.textContent = text; + b.style.cssText = [ + 'padding:8px 20px', + 'border-radius:10px', + 'font-size:1rem', + 'font-weight:700', + 'cursor:pointer', + 'transition:all .15s', + 'border:1px solid rgba(255,255,255,0.12)', + 'background:rgba(255,255,255,0.04)', + 'color:rgba(255,255,255,0.55)', + ].join(';'); + return b; + }; + + const btnIdentify = makeTab('qa-btn-identify', 'Определить ион'); + const btnUnknown = makeTab('qa-btn-unknown', 'Неизвестное вещество'); + tabsWrap.appendChild(btnIdentify); + tabsWrap.appendChild(btnUnknown); + tb.appendChild(tabsWrap); + + const spacer = document.createElement('div'); + spacer.style.cssText = 'flex:1'; + tb.appendChild(spacer); + + /* score */ + const scoreWrap = document.createElement('span'); + scoreWrap.style.cssText = 'font-size:1rem;color:rgba(255,255,255,0.7);font-weight:600'; + scoreWrap.innerHTML = 'Счёт: 0'; + tb.appendChild(scoreWrap); + + /* new question button */ + const btnNew = document.createElement('button'); + btnNew.id = 'qa-btn-new'; + btnNew.textContent = 'Новый вопрос'; + btnNew.style.cssText = [ + 'padding:8px 18px', + 'border-radius:10px', + 'border:1px solid rgba(6,214,224,0.45)', + 'background:rgba(6,214,224,0.1)', + 'color:#06D6E0', + 'font-size:.95rem', + 'font-weight:700', + 'cursor:pointer', + 'transition:background .15s', + ].join(';'); + tb.appendChild(btnNew); this._container.appendChild(tb); - /* main area */ - const main = document.createElement('div'); - main.style.cssText = 'display:flex;flex:1;min-height:0;overflow:hidden'; - this._container.appendChild(main); + /* ── MAIN ROW: scene + log ── */ + const mainRow = document.createElement('div'); + mainRow.style.cssText = 'display:flex;flex:1;min-height:0;overflow:hidden'; + this._container.appendChild(mainRow); - /* left panel: log + reagents */ - const left = document.createElement('div'); - left.id = 'qa-left'; - left.style.cssText = 'width:230px;display:flex;flex-direction:column;border-right:1px solid rgba(255,255,255,0.07);flex-shrink:0;overflow:hidden'; - main.appendChild(left); + /* CENTER SCENE */ + const scene = document.createElement('div'); + scene.style.cssText = [ + 'flex:1', + 'display:flex', + 'flex-direction:column', + 'min-width:0', + 'overflow:hidden', + 'position:relative', + ].join(';'); + mainRow.appendChild(scene); - /* reagent shelf */ + /* canvas wrapper — takes all available height */ + const canvasWrap = document.createElement('div'); + canvasWrap.style.cssText = 'flex:1;position:relative;min-height:0;overflow:hidden'; + scene.appendChild(canvasWrap); + + const canvas = document.createElement('canvas'); + canvas.id = 'qa-canvas'; + canvas.style.cssText = 'display:block;width:100%;height:100%;cursor:crosshair'; + canvasWrap.appendChild(canvas); + this._canvas = canvas; + this._ctx = canvas.getContext('2d'); + + /* answer card inside scene, below canvas area */ + const ansCard = document.createElement('div'); + ansCard.id = 'qa-anscard'; + ansCard.style.cssText = [ + 'flex-shrink:0', + 'padding:10px 16px', + 'border-top:1px solid rgba(255,255,255,0.08)', + 'display:flex', + 'align-items:center', + 'gap:10px', + 'flex-wrap:wrap', + 'background:rgba(255,255,255,0.025)', + ].join(';'); + ansCard.innerHTML = [ + '', + 'Добавляй реагенты и определи ион в пробирке', + '', + '', + '', + '
', + ].join(''); + scene.appendChild(ansCard); + + /* RIGHT PANEL — log */ + const logPanel = document.createElement('div'); + logPanel.style.cssText = [ + 'width:290px', + 'flex-shrink:0', + 'border-left:1px solid rgba(255,255,255,0.08)', + 'display:flex', + 'flex-direction:column', + 'overflow:hidden', + 'background:rgba(255,255,255,0.015)', + ].join(';'); + mainRow.appendChild(logPanel); + + const logHeader = document.createElement('div'); + logHeader.style.cssText = [ + 'padding:12px 14px 8px', + 'font-size:.82rem', + 'font-weight:700', + 'text-transform:uppercase', + 'letter-spacing:.07em', + 'color:rgba(255,255,255,0.55)', + 'border-bottom:1px solid rgba(255,255,255,0.07)', + 'flex-shrink:0', + ].join(';'); + logHeader.textContent = 'Журнал наблюдений'; + logPanel.appendChild(logHeader); + + const logScroll = document.createElement('div'); + logScroll.style.cssText = 'flex:1;overflow-y:auto;padding:8px 10px;display:flex;flex-direction:column;gap:6px'; + logPanel.appendChild(logScroll); + + const logList = document.createElement('div'); + logList.id = 'qa-log'; + logList.style.cssText = 'display:flex;flex-direction:column;gap:6px'; + logScroll.appendChild(logList); + + /* empty state hint */ + const logHint = document.createElement('div'); + logHint.id = 'qa-log-hint'; + logHint.style.cssText = [ + 'font-size:.88rem', + 'color:rgba(255,255,255,0.28)', + 'text-align:center', + 'margin-top:20px', + 'padding:0 10px', + 'line-height:1.5', + ].join(';'); + logHint.textContent = 'Нажимай реагенты снизу — здесь появятся результаты реакций'; + logScroll.appendChild(logHint); + + /* ── REAGENT SHELF (bottom) ── */ const shelf = document.createElement('div'); shelf.id = 'qa-shelf'; - shelf.style.cssText = 'padding:10px 8px 6px;border-bottom:1px solid rgba(255,255,255,0.07);flex-shrink:0'; - shelf.innerHTML = '
Реагенты
'; - const shelfGrid = document.createElement('div'); - shelfGrid.id = 'qa-shelf-grid'; - shelfGrid.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px'; + shelf.style.cssText = [ + 'flex-shrink:0', + 'border-top:1px solid rgba(255,255,255,0.09)', + 'padding:10px 14px', + 'background:rgba(255,255,255,0.025)', + 'display:flex', + 'flex-wrap:wrap', + 'gap:7px', + 'align-items:center', + ].join(';'); + + const shelfLabel = document.createElement('span'); + shelfLabel.style.cssText = [ + 'font-size:.75rem', + 'font-weight:700', + 'text-transform:uppercase', + 'letter-spacing:.06em', + 'color:rgba(255,255,255,0.4)', + 'margin-right:4px', + 'flex-shrink:0', + ].join(';'); + shelfLabel.textContent = 'Реагенты:'; + shelf.appendChild(shelfLabel); + QualAnalysisSim.REAGENTS.forEach(r => { const btn = document.createElement('button'); btn.className = 'qa-reagent-btn'; btn.dataset.reagent = r.id; btn.title = r.label; - btn.style.cssText = `padding:4px 7px;border-radius:7px;border:1px solid ${r.color}44;background:${r.color}18;color:${r.color};font-size:.72rem;font-weight:700;cursor:pointer;transition:background .15s`; + btn.style.cssText = [ + 'padding:7px 14px', + 'border-radius:9px', + `border:1px solid ${r.color}55`, + `background:${r.color}18`, + `color:${r.color}`, + 'font-size:.95rem', + 'font-weight:700', + 'cursor:pointer', + 'transition:background .15s, transform .1s', + 'min-width:60px', + 'text-align:center', + ].join(';'); btn.textContent = r.label; - shelfGrid.appendChild(btn); - }); - shelf.appendChild(shelfGrid); - left.appendChild(shelf); - - /* log */ - const logWrap = document.createElement('div'); - logWrap.style.cssText = 'flex:1;overflow-y:auto;padding:8px'; - logWrap.innerHTML = '
Наблюдения
'; - const logList = document.createElement('div'); - logList.id = 'qa-log'; - logList.style.cssText = 'display:flex;flex-direction:column;gap:4px'; - logWrap.appendChild(logList); - left.appendChild(logWrap); - - /* center: tube + canvas */ - const center = document.createElement('div'); - center.style.cssText = 'flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden'; - main.appendChild(center); - - const canvas = document.createElement('canvas'); - canvas.id = 'qa-canvas'; - canvas.style.cssText = 'display:block;cursor:crosshair'; - center.appendChild(canvas); - this._canvas = canvas; - this._ctx = canvas.getContext('2d'); - - /* answer bar */ - const ansBar = document.createElement('div'); - ansBar.id = 'qa-ansbar'; - ansBar.style.cssText = 'padding:8px 14px;border-top:1px solid rgba(255,255,255,0.07);display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap'; - ansBar.innerHTML = ` - Добавляй реагенты и определи ион в пробирке - - - `; - this._container.appendChild(ansBar); - - /* right panel: ion reference list (mode 1) */ - const right = document.createElement('div'); - right.id = 'qa-right'; - right.style.cssText = 'width:180px;border-left:1px solid rgba(255,255,255,0.07);overflow-y:auto;padding:8px;flex-shrink:0'; - right.innerHTML = '
Список ионов
'; - const ionList = document.createElement('div'); - ionList.id = 'qa-ionlist'; - ionList.style.cssText = 'display:flex;flex-direction:column;gap:3px'; - ['Катионы','Анионы'].forEach(grp => { - const h = document.createElement('div'); - h.style.cssText = 'font-size:.67rem;color:#666;text-transform:uppercase;letter-spacing:.05em;margin-top:6px;margin-bottom:2px'; - h.textContent = grp; - ionList.appendChild(h); - QualAnalysisSim.IONS.filter(i => i.group === grp).forEach(ion => { - const d = document.createElement('div'); - d.className = 'qa-ion-card'; - d.dataset.id = ion.id; - d.style.cssText = 'font-size:.75rem;padding:3px 7px;border-radius:6px;border:1px solid rgba(255,255,255,0.07);background:rgba(255,255,255,0.03);cursor:default;color:#CCC'; - d.textContent = ion.label; - ionList.appendChild(d); + btn.addEventListener('mouseenter', () => { + btn.style.background = r.color + '33'; + btn.style.transform = 'translateY(-1px)'; }); + btn.addEventListener('mouseleave', () => { + btn.style.background = r.color + '18'; + btn.style.transform = ''; + }); + shelf.appendChild(btn); }); - right.appendChild(ionList); - main.appendChild(right); + this._container.appendChild(shelf); this._resizeFit(); } @@ -534,58 +700,72 @@ class QualAnalysisSim { const c = this._canvas; const p = c.parentElement; if (!p) return; - const w = p.clientWidth || 400; - const h = p.clientHeight || 360; - c.width = w; - c.height = h; - this._W = w; this._H = h; + const rect = p.getBoundingClientRect(); + const w = rect.width || p.clientWidth || 400; + const h = rect.height || p.clientHeight || 360; + c.width = Math.round(w); + c.height = Math.round(h); + this._W = c.width; + this._H = c.height; this._drawTube(); } + /* ── Tab highlight ───────────────────────────────────────────── */ + _highlightMode(mode) { + const bi = document.getElementById('qa-btn-identify'); + const bu = document.getElementById('qa-btn-unknown'); + const activeStyle = [ + 'border:1px solid rgba(155,93,229,0.65)', + 'background:rgba(155,93,229,0.2)', + 'color:#D0A0FF', + ].join(';'); + const inactiveStyle = [ + 'border:1px solid rgba(255,255,255,0.12)', + 'background:rgba(255,255,255,0.04)', + 'color:rgba(255,255,255,0.55)', + ].join(';'); + /* reapply only the color/border parts by toggling a known block */ + [bi, bu].forEach(btn => { + const isActive = (btn === bi && mode === 'identify') || (btn === bu && mode === 'unknown'); + btn.style.border = isActive ? '1px solid rgba(155,93,229,0.65)' : '1px solid rgba(255,255,255,0.12)'; + btn.style.background = isActive ? 'rgba(155,93,229,0.2)' : 'rgba(255,255,255,0.04)'; + btn.style.color = isActive ? '#D0A0FF' : 'rgba(255,255,255,0.55)'; + }); + } + /* ── Events ──────────────────────────────────────────────────── */ _bindEvents() { - const el = id => document.getElementById(id); + const $ = id => document.getElementById(id); - el('qa-btn-identify').addEventListener('click', () => this._startMode('identify')); - el('qa-btn-unknown').addEventListener('click', () => this._startMode('unknown')); - el('qa-btn-new').addEventListener('click', () => this._startMode(this._mode)); - el('qa-submit').addEventListener('click', () => this._submitAnswer()); + $('qa-btn-identify').addEventListener('click', () => this._startMode('identify')); + $('qa-btn-unknown').addEventListener('click', () => this._startMode('unknown')); + $('qa-btn-new').addEventListener('click', () => this._startMode(this._mode)); + $('qa-submit').addEventListener('click', () => this._submitAnswer()); - /* reagent buttons → click to apply */ this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => { btn.addEventListener('click', () => { if (this._answered) return; - const rid = btn.dataset.reagent; - this._applyReagent(rid); - /* visual flash */ - const col = btn.style.color; - btn.style.background = col + '44'; - setTimeout(() => { btn.style.background = col + '18'; }, 200); + this._applyReagent(btn.dataset.reagent); }); - }); - - /* drag-and-drop reagent to canvas */ - this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => { + btn.setAttribute('draggable', 'true'); btn.addEventListener('dragstart', e => { this._dragReagent = btn.dataset.reagent; e.dataTransfer.effectAllowed = 'copy'; }); - btn.setAttribute('draggable', 'true'); }); - this._canvas.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); + this._canvas.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }); this._canvas.addEventListener('drop', e => { e.preventDefault(); if (this._dragReagent && !this._answered) { - const rect = this._canvas.getBoundingClientRect(); - this._dragX = e.clientX - rect.left; - this._dragY = e.clientY - rect.top; this._applyReagent(this._dragReagent); this._dragReagent = null; } }); - /* ResizeObserver */ if (window.ResizeObserver) { const ro = new ResizeObserver(() => this._resizeFit()); ro.observe(this._canvas.parentElement || this._container); @@ -600,8 +780,8 @@ class QualAnalysisSim { this._dropAnim = []; this._precipParticles = []; this._gasParticles = []; + this._dragReagent = null; - /* pick random ion */ const ions = QualAnalysisSim.IONS; this._targetIon = ions[Math.floor(Math.random() * ions.length)]; this._tubeState = { @@ -613,40 +793,33 @@ class QualAnalysisSim { solColor: this._targetIon.solColor || 'rgba(100,180,255,0.18)', }; - /* reset UI */ + /* reset DOM */ document.getElementById('qa-log').innerHTML = ''; + const hint = document.getElementById('qa-log-hint'); + if (hint) hint.style.display = ''; document.getElementById('qa-verdict').style.display = 'none'; document.getElementById('qa-verdict').textContent = ''; this._highlightMode(mode); this._updateAnswerSelect(); - this._populateAnswerQuestion(mode); - this._updateIonHighlight(null); + this._populateQuestion(mode); this._drawTube(); if (!this._raf) this._animLoop(performance.now()); } - _populateAnswerQuestion(mode) { + _populateQuestion(mode) { + const q = document.getElementById('qa-question'); if (mode === 'identify') { - document.getElementById('qa-question').textContent = 'Добавляй реагенты и определи ион в пробирке — выбери ответ и нажми «Ответить»'; + q.textContent = 'Добавляй реагенты — определи ион в пробирке, выбери ответ и нажми «Ответить»'; } else { - document.getElementById('qa-question').textContent = 'Испытай неизвестный раствор реагентами, затем выбери ион и ответь'; + q.textContent = 'Испытай неизвестный раствор реагентами, затем выбери ион и ответь'; } } - _highlightMode(mode) { - const bi = document.getElementById('qa-btn-identify'); - const bu = document.getElementById('qa-btn-unknown'); - const active = 'border:1px solid rgba(155,93,229,0.6);background:rgba(155,93,229,0.18);color:#D0A0FF'; - const inactive = 'border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:#aaa'; - bi.style.cssText = bi.style.cssText.replace(/border:[^;]+;background:[^;]+;color:[^;]+/, mode === 'identify' ? active : inactive); - bu.style.cssText = bu.style.cssText.replace(/border:[^;]+;background:[^;]+;color:[^;]+/, mode === 'unknown' ? active : inactive); - } - _updateAnswerSelect() { const sel = document.getElementById('qa-answer-sel'); sel.innerHTML = ''; - ['Катионы','Анионы'].forEach(grp => { + ['Катионы', 'Анионы'].forEach(grp => { const og = document.createElement('optgroup'); og.label = grp; QualAnalysisSim.IONS.filter(i => i.group === grp).forEach(ion => { @@ -668,13 +841,9 @@ class QualAnalysisSim { const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId); const rLabel = rInfo ? rInfo.label : reagentId; - /* LabFX sounds */ + /* sounds */ if (window.LabFX) { - if (rxn.type === 'gas') { - LabFX.sound.play('fizz'); - } else { - LabFX.sound.play('pour'); - } + LabFX.sound.play(rxn.type === 'gas' ? 'fizz' : 'pour'); } /* update tube state */ @@ -684,7 +853,6 @@ class QualAnalysisSim { } else if (rxn.type === 'precip' && rxn.color) { this._tubeState.precipColor = rxn.color; this._tubeState.precipH = 0; - /* animate precip settling */ this._precipParticles = this._spawnPrecipParticles(rxn.color); } else if (rxn.type === 'solution' && rxn.color) { this._tubeState.solColor = rxn.color; @@ -693,25 +861,36 @@ class QualAnalysisSim { this._gasParticles = this._spawnGasParticles(rxn.color); } - /* drop animation */ - const cx = this._W * 0.5; - const cy = 60; - this._dropAnim.push({ x: cx, y: 20, vy: 2, color: rInfo ? rInfo.color : '#FFF', alpha: 1, done: false }); + /* drop animation — from top center */ + this._dropAnim.push({ + x: this._W * 0.5, + y: 20, + vy: 2.5, + color: rInfo ? rInfo.color : '#FFF', + alpha: 1, + done: false, + }); - /* log entry */ - const isPositive = rxn.positive; - const entry = { reagent: rLabel, obs: rxn.obs, positive: isPositive, excess: rxn.excess || null }; + /* log */ + const entry = { reagent: rLabel, obs: rxn.obs, positive: rxn.positive, excess: rxn.excess || null }; this._log.push(entry); this._renderLogEntry(entry); - this._updateIonHighlight(this._log); } _spawnPrecipParticles(color) { const cx = this._W * 0.5; - const cy = this._H * 0.6; + const cy = this._H * 0.55; const ps = []; - for (let i = 0; i < 22; i++) { - ps.push({ x: cx + (Math.random() - 0.5) * 60, y: cy, vy: 0.5 + Math.random() * 1.5, vx: (Math.random() - 0.5) * 1.5, color, r: 2 + Math.random() * 3, done: false }); + for (let i = 0; i < 32; i++) { + ps.push({ + x: cx + (Math.random() - 0.5) * 80, + y: cy, + vy: 0.6 + Math.random() * 2, + vx: (Math.random() - 0.5) * 2, + color, + r: 3 + Math.random() * 4, + done: false, + }); } return ps; } @@ -720,53 +899,88 @@ class QualAnalysisSim { const cx = this._W * 0.5; const cy = this._H * 0.4; const ps = []; - for (let i = 0; i < 18; i++) { - ps.push({ x: cx + (Math.random() - 0.5) * 30, y: cy, vy: -(0.8 + Math.random() * 1.2), vx: (Math.random() - 0.5), color, r: 3 + Math.random() * 4, alpha: 0.85, done: false }); + for (let i = 0; i < 26; i++) { + ps.push({ + x: cx + (Math.random() - 0.5) * 50, + y: cy, + vy: -(1 + Math.random() * 1.8), + vx: (Math.random() - 0.5) * 1.2, + color, + r: 4 + Math.random() * 5, + alpha: 0.9, + done: false, + }); } return ps; } + /* ── Log rendering ───────────────────────────────────────────── */ _renderLogEntry(entry) { - const log = document.getElementById('qa-log'); - const d = document.createElement('div'); - const col = entry.positive ? '#5EF08E' : '#888'; - d.style.cssText = `font-size:.72rem;padding:4px 6px;border-radius:6px;border-left:3px solid ${col};background:rgba(255,255,255,0.03);color:#CCC;line-height:1.4`; - d.innerHTML = `${_esc(entry.reagent)}: ${_esc(entry.obs)}${entry.excess ? `
${_esc(entry.excess)}
` : ''}`; - log.appendChild(d); - log.scrollTop = log.scrollHeight; - } + const hint = document.getElementById('qa-log-hint'); + if (hint) hint.style.display = 'none'; - /* highlight ions that are consistent with observations */ - _updateIonHighlight(log) { - const cards = this._container.querySelectorAll('.qa-ion-card'); - if (!log || log.length === 0) { - cards.forEach(c => { c.style.background = 'rgba(255,255,255,0.03)'; c.style.color = '#CCC'; c.style.borderColor = 'rgba(255,255,255,0.07)'; }); - return; + const log = document.getElementById('qa-log'); + const n = log.children.length + 1; + const col = entry.positive ? '#5EF08E' : 'rgba(255,255,255,0.4)'; + + const card = document.createElement('div'); + card.style.cssText = [ + 'padding:9px 11px', + 'border-radius:10px', + `border-left:3px solid ${col}`, + 'background:rgba(255,255,255,0.04)', + 'border-top:1px solid rgba(255,255,255,0.07)', + 'border-right:1px solid rgba(255,255,255,0.07)', + 'border-bottom:1px solid rgba(255,255,255,0.07)', + 'display:flex', + 'flex-direction:column', + 'gap:3px', + ].join(';'); + + /* reagent row */ + const topRow = document.createElement('div'); + topRow.style.cssText = 'display:flex;align-items:center;gap:7px;font-size:.9rem'; + + const numSpan = document.createElement('span'); + numSpan.style.cssText = 'font-size:.78rem;color:rgba(255,255,255,0.35);font-weight:600;min-width:16px'; + numSpan.textContent = n + '.'; + + const reagentSpan = document.createElement('span'); + /* find color for this reagent */ + const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === entry.reagent || r.id === entry.reagent); + const rColor = rInfo ? rInfo.color : '#AAA'; + reagentSpan.style.cssText = `color:${rColor};font-weight:700;font-size:.92rem`; + reagentSpan.textContent = _esc(entry.reagent); + + const arrowSpan = document.createElement('span'); + arrowSpan.style.cssText = 'color:rgba(255,255,255,0.35);font-size:.85rem'; + arrowSpan.textContent = '→'; + + const dotSpan = document.createElement('span'); + dotSpan.style.cssText = `width:8px;height:8px;border-radius:50%;background:${col};flex-shrink:0;margin-left:auto`; + + topRow.appendChild(numSpan); + topRow.appendChild(reagentSpan); + topRow.appendChild(arrowSpan); + topRow.appendChild(dotSpan); + card.appendChild(topRow); + + /* observation text */ + const obsDiv = document.createElement('div'); + obsDiv.style.cssText = 'font-size:.88rem;color:rgba(255,255,255,0.8);line-height:1.45;padding-left:23px'; + obsDiv.textContent = entry.obs; + card.appendChild(obsDiv); + + /* excess note */ + if (entry.excess) { + const exDiv = document.createElement('div'); + exDiv.style.cssText = 'font-size:.8rem;color:#FFD166;padding-left:23px;margin-top:1px'; + exDiv.textContent = entry.excess; + card.appendChild(exDiv); } - cards.forEach(c => { - const ionId = c.dataset.id; - const ion = QualAnalysisSim.IONS.find(i => i.id === ionId); - if (!ion) return; - let compatible = true; - for (const entry of log) { - /* find reagent id from label */ - const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === entry.reagent || r.id === entry.reagent); - if (!rInfo) continue; - const expectedRxn = ion.reactions[rInfo.id]; - if (!expectedRxn) continue; - /* if positive result observed but ion doesn't produce positive here */ - if (entry.positive && !expectedRxn.positive) { compatible = false; break; } - } - if (compatible) { - c.style.background = 'rgba(155,93,229,0.12)'; - c.style.color = '#D0A0FF'; - c.style.borderColor = 'rgba(155,93,229,0.3)'; - } else { - c.style.background = 'rgba(255,255,255,0.01)'; - c.style.color = '#444'; - c.style.borderColor = 'rgba(255,255,255,0.04)'; - } - }); + + log.appendChild(card); + log.parentElement.scrollTop = log.parentElement.scrollHeight; } /* ── Submit answer ───────────────────────────────────────────── */ @@ -776,33 +990,26 @@ class QualAnalysisSim { const chosen = sel.value; if (!chosen) return; this._answered = true; + const correct = chosen === this._targetIon.id; const verdict = document.getElementById('qa-verdict'); verdict.style.display = 'block'; + if (correct) { this._score++; document.getElementById('qa-score').textContent = this._score; verdict.textContent = 'Верно! Это ' + this._targetIon.label; verdict.style.background = 'rgba(94,240,142,0.15)'; verdict.style.color = '#5EF08E'; - verdict.style.border = '1px solid rgba(94,240,142,0.3)'; + verdict.style.border = '1px solid rgba(94,240,142,0.35)'; if (window.LabFX) LabFX.sound.play('chime'); } else { const correctIon = QualAnalysisSim.IONS.find(i => i.id === this._targetIon.id); verdict.textContent = 'Неверно. Правильный ответ: ' + (correctIon ? correctIon.label : this._targetIon.id); verdict.style.background = 'rgba(239,71,111,0.12)'; verdict.style.color = '#EF476F'; - verdict.style.border = '1px solid rgba(239,71,111,0.3)'; + verdict.style.border = '1px solid rgba(239,71,111,0.35)'; } - /* highlight correct ion card */ - const cards = this._container.querySelectorAll('.qa-ion-card'); - cards.forEach(c => { - if (c.dataset.id === this._targetIon.id) { - c.style.background = 'rgba(94,240,142,0.15)'; - c.style.color = '#5EF08E'; - c.style.borderColor = '#5EF08E'; - } - }); } /* ── Animation loop ──────────────────────────────────────────── */ @@ -811,33 +1018,40 @@ class QualAnalysisSim { const dt = Math.min((t - this._lastT) / 1000, 0.05); this._lastT = t; - /* advance particles */ let needDraw = false; this._dropAnim = this._dropAnim.filter(d => { if (d.done) return false; d.y += d.vy; - d.vy += 0.2; - if (d.y > this._H * 0.55) { d.done = true; needDraw = true; return false; } + d.vy += 0.25; + const floor = this._H * 0.52; + if (d.y > floor) { d.done = true; needDraw = true; return false; } needDraw = true; return true; }); this._precipParticles.forEach(p => { if (!p.done) { - p.x += p.vx; p.y += p.vy; + p.x += p.vx; + p.y += p.vy; p.vy *= 0.98; - const floor = this._H * 0.82; - if (p.y >= floor) { p.y = floor; p.vy = 0; p.vx = 0; p.done = true; - this._tubeState.precipH = Math.min(this._tubeState.precipH + 2, 30); } + const floor = this._H * 0.83; + if (p.y >= floor) { + p.y = floor; + p.vy = 0; + p.vx = 0; + p.done = true; + this._tubeState.precipH = Math.min(this._tubeState.precipH + 2.5, 38); + } needDraw = true; } }); this._gasParticles = this._gasParticles.filter(p => { if (p.done) return false; - p.y += p.vy; p.x += p.vx; - p.alpha -= dt * 0.5; + p.y += p.vy; + p.x += p.vx; + p.alpha -= dt * 0.6; if (p.alpha <= 0 || p.y < 0) { p.done = true; return false; } needDraw = true; return true; @@ -850,128 +1064,139 @@ class QualAnalysisSim { _drawTube() { const ctx = this._ctx; const W = this._W || 400; - const H = this._H || 360; + const H = this._H || 400; + ctx.clearRect(0, 0, W, H); /* background */ ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); - /* bench surface */ - ctx.fillStyle = 'rgba(255,255,255,0.03)'; - ctx.fillRect(0, H * 0.87, W, H * 0.13); - ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + /* subtle bench surface */ + ctx.fillStyle = 'rgba(255,255,255,0.025)'; + ctx.fillRect(0, H * 0.88, W, H * 0.12); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; - ctx.beginPath(); ctx.moveTo(0, H * 0.87); ctx.lineTo(W, H * 0.87); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, H * 0.88); + ctx.lineTo(W, H * 0.88); + ctx.stroke(); - /* tube dimensions */ - const tx = W * 0.5 - 28; - const tw = 56; - const tTop = H * 0.15; - const tBot = H * 0.85; - const tH = tBot - tTop; - const r = 10; + /* tube dimensions — large, centered */ + const tubeW = Math.min(120, W * 0.22); + const tx = W * 0.5 - tubeW * 0.5; + const tTop = H * 0.1; + const tBot = H * 0.86; + const tH = tBot - tTop; + const rBot = tubeW * 0.5; /* bottom arc radius */ - /* flame halo */ + /* ── flame halo ── */ if (this._tubeState.flameColor) { const fc = this._tubeState.flameColor; - const grad = ctx.createRadialGradient(W * 0.5, tTop - 20, 5, W * 0.5, tTop - 20, 80); - grad.addColorStop(0, fc + 'CC'); - grad.addColorStop(0.4, fc + '44'); - grad.addColorStop(1, fc + '00'); - ctx.fillStyle = grad; - ctx.beginPath(); ctx.arc(W * 0.5, tTop - 20, 80, 0, Math.PI * 2); ctx.fill(); + const gx = W * 0.5; + const gy = tTop - 30; + const gr = ctx.createRadialGradient(gx, gy, 6, gx, gy, 110); + gr.addColorStop(0, fc + 'DD'); + gr.addColorStop(0.35, fc + '55'); + gr.addColorStop(1, fc + '00'); + ctx.fillStyle = gr; + ctx.beginPath(); + ctx.arc(gx, gy, 110, 0, Math.PI * 2); + ctx.fill(); /* flame label */ - ctx.font = 'bold 12px Manrope,sans-serif'; + ctx.save(); + ctx.font = 'bold 14px Manrope,sans-serif'; ctx.fillStyle = fc; ctx.textAlign = 'center'; - const flameLabel = (() => { - if (fc === '#FFD700') return 'Жёлтое пламя — Na⁺'; - if (fc === '#CC00FF') return 'Фиолетовое — K⁺'; - if (fc === '#CC4400') return 'Кирпично-красное — Ca²⁺'; - if (fc === '#00DD00') return 'Зелёное — Ba²⁺'; - if (fc === '#00BB44') return 'Зелёное пламя'; - return 'Окрашивание пламени'; - })(); - ctx.fillText(flameLabel, W * 0.5, tTop - 40); + ctx.textBaseline = 'middle'; + const flameLabel = ( + fc === '#FFD700' ? 'Жёлтое пламя — Na⁺' : + fc === '#CC00FF' ? 'Фиолетовое — K⁺' : + fc === '#CC4400' ? 'Кирпично-красное — Ca²⁺' : + fc === '#00DD00' ? 'Зелёное — Ba²⁺' : + fc === '#00BB44' ? 'Зелёное пламя — Cu²⁺' : + 'Окрашивание пламени' + ); + ctx.fillText(flameLabel, W * 0.5, tTop - 55); + ctx.restore(); } - /* tube shadow */ - ctx.shadowColor = 'rgba(155,93,229,0.2)'; - ctx.shadowBlur = 18; + /* solution fill path helper */ + const tubePath = (fromY, toY) => { + const arcR = Math.min(rBot, (toY - fromY) * 0.5); + ctx.beginPath(); + ctx.moveTo(tx, fromY); + ctx.lineTo(tx, toY - arcR); + ctx.arcTo(tx, toY, tx + rBot, toY, arcR); + ctx.arcTo(tx + tubeW, toY, tx + tubeW, toY - arcR, arcR); + ctx.lineTo(tx + tubeW, fromY); + ctx.closePath(); + }; - /* solution fill */ - const solTop = tTop + tH * 0.05; - const solBot = tBot - 5; - const solH = solBot - solTop; - ctx.shadowBlur = 0; + /* solution */ + const solTop = tTop + tH * 0.06; + const solBot = tBot; ctx.fillStyle = this._tubeState.solColor || 'rgba(100,180,255,0.18)'; - ctx.beginPath(); - ctx.moveTo(tx + r, solTop); - ctx.lineTo(tx + tw - r, solTop); - ctx.lineTo(tx + tw - r, solBot - r); - ctx.arcTo(tx + tw - r, solBot, tx + tw / 2, solBot, r); - ctx.arcTo(tx + r, solBot, tx + r, solBot - r, r); - ctx.lineTo(tx + r, solTop); - ctx.closePath(); + tubePath(solTop, solBot); ctx.fill(); /* precipitate layer */ if (this._tubeState.precipColor && this._tubeState.precipH > 0) { - const ph = this._tubeState.precipH; - const py = solBot - ph; - ctx.fillStyle = this._tubeState.precipColor; - ctx.globalAlpha = 0.85; - ctx.beginPath(); - ctx.moveTo(tx + r, py); - ctx.lineTo(tx + tw - r, py); - ctx.lineTo(tx + tw - r, solBot - r); - ctx.arcTo(tx + tw - r, solBot, tx + tw / 2, solBot, r); - ctx.arcTo(tx + r, solBot, tx + r, solBot - r, r); - ctx.lineTo(tx + r, py); - ctx.closePath(); + const ph = this._tubeState.precipH; + const py = solBot - ph; + ctx.globalAlpha = 0.88; + ctx.fillStyle = this._tubeState.precipColor; + tubePath(py, solBot); ctx.fill(); ctx.globalAlpha = 1; - /* precipitate label */ - if (this._precipParticles && this._precipParticles.every(p => p.done)) { - ctx.font = 'bold 10px Manrope,sans-serif'; - ctx.fillStyle = this._tubeState.precipColor === '#111111' ? '#888' : this._tubeState.precipColor; - ctx.textAlign = 'center'; - /* find label from last positive precip reaction */ + /* label when settled */ + if (this._precipParticles.every(p => p.done)) { const lastPrecip = [...this._log].reverse().find(l => { - const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === l.reagent || r.id === l.reagent); - if (!rInfo) return false; - const rxn = this._targetIon.reactions[rInfo.id]; - return rxn && rxn.type === 'precip' && rxn.precipLabel; + const ri = QualAnalysisSim.REAGENTS.find(r => r.label === l.reagent || r.id === l.reagent); + if (!ri) return false; + const rx2 = this._targetIon.reactions[ri.id]; + return rx2 && rx2.type === 'precip' && rx2.precipLabel; }); if (lastPrecip) { - const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === lastPrecip.reagent || r.id === lastPrecip.reagent); - const rxn = rInfo ? this._targetIon.reactions[rInfo.id] : null; - if (rxn && rxn.precipLabel) { - ctx.fillText(rxn.precipLabel, W * 0.5, solBot - ph / 2 + 4); + const ri = QualAnalysisSim.REAGENTS.find(r => r.label === lastPrecip.reagent || r.id === lastPrecip.reagent); + const rx2 = ri ? this._targetIon.reactions[ri.id] : null; + if (rx2 && rx2.precipLabel) { + ctx.save(); + ctx.font = 'bold 13px Manrope,sans-serif'; + const labelCol = this._tubeState.precipColor === '#111111' ? '#888' : this._tubeState.precipColor; + ctx.fillStyle = labelCol; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(rx2.precipLabel, W * 0.5, solBot - ph * 0.5); + ctx.restore(); } } } } - /* falling drop particles */ + /* drop particles */ this._dropAnim.forEach(d => { ctx.globalAlpha = d.alpha; ctx.fillStyle = d.color; - ctx.beginPath(); ctx.arc(d.x, d.y, 5, 0, Math.PI * 2); ctx.fill(); - /* drop tail */ - ctx.fillStyle = d.color + '88'; - ctx.beginPath(); ctx.arc(d.x, d.y - 8, 3, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); + ctx.arc(d.x, d.y, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = d.color + '77'; + ctx.beginPath(); + ctx.arc(d.x, d.y - 10, 4, 0, Math.PI * 2); + ctx.fill(); ctx.globalAlpha = 1; }); - /* floating precipitate particles */ + /* precipitate flying particles */ this._precipParticles.filter(p => !p.done).forEach(p => { - ctx.globalAlpha = 0.75; + ctx.globalAlpha = 0.8; ctx.fillStyle = p.color; - ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fill(); ctx.globalAlpha = 1; }); @@ -979,68 +1204,91 @@ class QualAnalysisSim { this._gasParticles.forEach(p => { ctx.globalAlpha = p.alpha; ctx.strokeStyle = p.color; - ctx.lineWidth = 1.5; - ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.stroke(); + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.stroke(); ctx.globalAlpha = 1; }); - /* gas label above tube */ + /* gas label */ if (this._tubeState.gasLabel) { - ctx.font = 'bold 13px Manrope,sans-serif'; + ctx.save(); + ctx.font = 'bold 15px Manrope,sans-serif'; ctx.fillStyle = '#FFFFAA'; - ctx.textAlign = 'center'; - ctx.fillText(this._tubeState.gasLabel, W * 0.5 + 40, tTop + 20); + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(this._tubeState.gasLabel, tx + tubeW + 12, tTop + tH * 0.2); + ctx.restore(); } - /* tube glass outline */ - ctx.shadowColor = 'rgba(155,93,229,0.3)'; - ctx.shadowBlur = 12; - ctx.strokeStyle = 'rgba(200,210,255,0.55)'; - ctx.lineWidth = 2; + /* ── tube glass outline ── */ + ctx.save(); + ctx.shadowColor = 'rgba(155,93,229,0.35)'; + ctx.shadowBlur = 14; + ctx.strokeStyle = 'rgba(200,215,255,0.6)'; + ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(tx, tTop); - ctx.lineTo(tx, tBot - r); - ctx.arcTo(tx, tBot, tx + r, tBot, r); - ctx.lineTo(tx + tw - r, tBot); - ctx.arcTo(tx + tw, tBot, tx + tw, tBot - r, r); - ctx.lineTo(tx + tw, tTop); + ctx.lineTo(tx, tBot - rBot); + ctx.arcTo(tx, tBot, tx + rBot, tBot, rBot); + ctx.arcTo(tx + tubeW, tBot, tx + tubeW, tBot - rBot, rBot); + ctx.lineTo(tx + tubeW, tTop); ctx.stroke(); - ctx.shadowBlur = 0; + ctx.restore(); - /* tube opening rim */ - ctx.strokeStyle = 'rgba(200,210,255,0.35)'; - ctx.lineWidth = 1.5; - ctx.beginPath(); ctx.moveTo(tx - 4, tTop); ctx.lineTo(tx + tw + 4, tTop); ctx.stroke(); + /* rim */ + ctx.strokeStyle = 'rgba(200,215,255,0.35)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(tx - 5, tTop); + ctx.lineTo(tx + tubeW + 5, tTop); + ctx.stroke(); - /* tube shine */ - const shine = ctx.createLinearGradient(tx, 0, tx + tw * 0.35, 0); - shine.addColorStop(0, 'rgba(255,255,255,0.10)'); + /* glass shine */ + const shine = ctx.createLinearGradient(tx, 0, tx + tubeW * 0.4, 0); + shine.addColorStop(0, 'rgba(255,255,255,0.12)'); shine.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = shine; ctx.beginPath(); - ctx.moveTo(tx + 3, tTop); - ctx.lineTo(tx + tw * 0.3, tTop); - ctx.lineTo(tx + tw * 0.3, tBot - 15); - ctx.lineTo(tx + 3, tBot - 15); + ctx.moveTo(tx + 4, tTop + 4); + ctx.lineTo(tx + tubeW * 0.32, tTop + 4); + ctx.lineTo(tx + tubeW * 0.32, tBot - tubeW * 0.25); + ctx.lineTo(tx + 4, tBot - tubeW * 0.25); ctx.closePath(); ctx.fill(); - /* mode label */ + /* mode watermark */ + ctx.save(); ctx.font = '700 11px Manrope,sans-serif'; - ctx.fillStyle = 'rgba(155,93,229,0.5)'; + ctx.fillStyle = 'rgba(155,93,229,0.35)'; ctx.textAlign = 'center'; - ctx.fillText(this._mode === 'identify' ? 'РЕЖИМ: ОПРЕДЕЛИТЬ ИОН' : 'РЕЖИМ: НЕИЗВЕСТНЫЙ РАСТВОР', W * 0.5, H - 8); + ctx.textBaseline = 'bottom'; + ctx.fillText( + this._mode === 'identify' ? 'РЕЖИМ: ОПРЕДЕЛИТЬ ИОН' : 'РЕЖИМ: НЕИЗВЕСТНЫЙ РАСТВОР', + W * 0.5, + H - 4 + ); + ctx.restore(); } + /* ── Public API ──────────────────────────────────────────────── */ stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } + + destroy() { + this.stop(); + } } /* ── helpers ─────────────────────────────────────────────────────── */ function _esc(s) { if (!s) return ''; - return String(s).replace(/&/g,'&').replace(//g,'>'); + return String(s) + .replace(/&/g, '&') + .replace(//g, '>'); } /* ── lab UI init ─────────────────────────────────────────────────── */ diff --git a/frontend/js/labs/stoichiometry.js b/frontend/js/labs/stoichiometry.js index 9a98f85..cdaf235 100644 --- a/frontend/js/labs/stoichiometry.js +++ b/frontend/js/labs/stoichiometry.js @@ -2,7 +2,7 @@ /* ═══════════════════════════════════════════════════════════════════════ StoichSim — «Стехиометрия» - Визуальный интерактивный калькулятор стехиометрии с анимацией. + Wizard UX: 4 шага — Выбор реакции → Количества → Лимит → Продукты ═══════════════════════════════════════════════════════════════════════ */ class StoichSim { @@ -59,8 +59,8 @@ class StoichSim { 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' }, + { 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' }, @@ -93,8 +93,8 @@ class StoichSim { 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' }, + { 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' }, @@ -109,7 +109,7 @@ class StoichSim { ], 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: 'MnO₂', coef: 1, M: 86.937, phase: 's', color: '#8899AA' }, { sym: 'O₂', coef: 1, M: 31.998, phase: 'g', color: '#EF476F' }, ], }, @@ -129,500 +129,842 @@ class StoichSim { /* ── Конструктор ─────────────────────────────────────────────────── */ 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 260px;min-width:0;display:flex;flex-direction:column;gap:0;overflow-y:auto;overflow-x:hidden;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._container = container; + this._step = 1; // 1 | 2 | 3 | 4 + this._recipeIdx = 0; + this._amounts = []; // граммы для каждого реагента + this._inputMode = []; // 'mass' | 'mol' | 'vol' + this._computed = null; this._animState = 'idle'; this._animT = 0; + this._raf = null; + this._canvas = null; + this._ctx = null; + this._ro = null; + this._W = 0; + this._H = 0; - this._rebuildLeft(); - this._rebuildEquation(); - this._compute(); - this._rebuildRight(); - this._fitCanvas(); - this._draw(); + this._build(); } - /* ── Уравнение реакции ──────────────────────────────────────────── */ - _rebuildEquation() { - const r = StoichSim.RECIPES[this._recipeIdx]; - const eb = this._eqBar; - eb.innerHTML = ''; + /* ── Построение оболочки wizard ─────────────────────────────────── */ + _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(';'); - // Реакции 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); + /* step indicator */ + this._stepBar = _stEl('div', { + style: [ + 'flex:0 0 auto', + 'display:flex', + 'align-items:center', + 'justify-content:center', + 'gap:0', + 'padding:12px 20px 10px', + 'background:rgba(255,255,255,0.03)', + 'border-bottom:1px solid rgba(255,255,255,0.08)', + ].join(';'), }); - sel.addEventListener('change', () => { this._setRecipe(+sel.value); }); - selWrap.appendChild(sel); + c.appendChild(this._stepBar); - // 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, + /* content area */ + this._content = _stEl('div', { + style: 'flex:1 1 auto;overflow-y:auto;overflow-x:hidden;padding:20px;', }); - selWrap.appendChild(eqText); + c.appendChild(this._content); - // 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); + this._renderStep(); } - _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 })), + /* ── Step indicator ─────────────────────────────────────────────── */ + _renderStepBar() { + const bar = this._stepBar; + bar.innerHTML = ''; + const steps = [ + 'Реакция', + 'Количества', + 'Лимит', + 'Продукты', ]; + steps.forEach((label, idx) => { + const num = idx + 1; + const active = num === this._step; + const done = num < this._step; - 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, + /* circle */ + const circle = _stEl('div', { + style: [ + 'width:28px', + 'height:28px', + 'border-radius:50%', + 'display:flex', + 'align-items:center', + 'justify-content:center', + 'font-size:.82rem', + 'font-weight:700', + 'flex-shrink:0', + 'transition:background .2s', + active + ? 'background:#9B5DE5;color:#fff' + : done + ? 'background:rgba(155,93,229,0.35);color:rgba(255,255,255,0.9)' + : 'background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.4)', + ].join(';'), + textContent: String(num), }); - 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) + ' г', + /* label */ + const lbl = _stEl('div', { + style: [ + 'font-size:.72rem', + 'margin-left:6px', + 'white-space:nowrap', + active + ? 'color:rgba(255,255,255,0.92);font-weight:700' + : done + ? 'color:rgba(255,255,255,0.55)' + : 'color:rgba(255,255,255,0.3)', + ].join(';'), + textContent: label, }); - wrap.appendChild(mBadge); - const nBadge = _stEl('span', { - style: `font-size:.68rem;color:rgba(255,255,255,0.5);`, - textContent: q.n.toFixed(4) + ' моль', + const item = _stEl('div', { + style: 'display:flex;align-items:center;', }); - wrap.appendChild(nBadge); + item.appendChild(circle); + item.appendChild(lbl); + bar.appendChild(item); - 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: '→', + /* connector */ + if (idx < steps.length - 1) { + bar.appendChild(_stEl('div', { + style: [ + 'flex:0 0 24px', + 'height:2px', + 'margin:0 8px', + 'border-radius:1px', + num < this._step + ? 'background:rgba(155,93,229,0.4)' + : 'background:rgba(255,255,255,0.1)', + ].join(';'), })); } }); - - eb.appendChild(badgesRow); } - /* ── Левая панель: inputs ───────────────────────────────────────── */ - _rebuildLeft() { - const lp = this._leftPanel; - lp.innerHTML = ''; + /* ── Главный рендер текущего шага ───────────────────────────────── */ + _renderStep() { + this._renderStepBar(); + const c = this._content; + c.innerHTML = ''; + + if (this._step === 1) this._renderStep1(c); + else if (this._step === 2) this._renderStep2(c); + else if (this._step === 3) this._renderStep3(c); + else if (this._step === 4) this._renderStep4(c); + } + + /* ══════════════════════════════════════════════════════════════════ + ШАГ 1: Выбор реакции + ══════════════════════════════════════════════════════════════════ */ + _renderStep1(c) { + /* заголовок */ + c.appendChild(_stEl('div', { + style: 'font-size:1.15rem;font-weight:700;color:rgba(255,255,255,0.92);margin-bottom:6px;', + textContent: 'Выберите реакцию', + })); + c.appendChild(_stEl('div', { + style: 'font-size:.85rem;color:rgba(255,255,255,0.5);margin-bottom:20px;', + textContent: 'Нажмите на карточку с реакцией, затем нажмите «Далее».', + })); + + /* большое уравнение выбранной реакции */ + const eqCard = _stEl('div', { + style: [ + 'padding:18px 24px', + 'background:rgba(155,93,229,0.12)', + 'border:1px solid rgba(155,93,229,0.35)', + 'border-radius:12px', + 'margin-bottom:20px', + 'text-align:center', + ].join(';'), + }); + const eqDisplay = _stEl('div', { + style: 'font-size:1.35rem;color:rgba(255,255,255,0.95);word-break:break-word;letter-spacing:.02em;', + }); + eqCard.appendChild(eqDisplay); + c.appendChild(eqCard); + + const updateEq = () => { + eqDisplay.textContent = StoichSim.RECIPES[this._recipeIdx].name; + }; + updateEq(); + + /* грид карточек */ + const grid = _stEl('div', { + style: [ + 'display:grid', + 'grid-template-columns:repeat(auto-fill,minmax(210px,1fr))', + 'gap:10px', + 'margin-bottom:24px', + ].join(';'), + }); + + StoichSim.RECIPES.forEach((rc, i) => { + const card = _stEl('div', { + style: this._recipeCardStyle(i === this._recipeIdx), + }); + + card.appendChild(_stEl('div', { + style: 'font-size:.78rem;font-weight:700;color:rgba(255,255,255,0.5);margin-bottom:4px;text-transform:uppercase;letter-spacing:.05em;', + textContent: 'Реакция ' + (i + 1), + })); + card.appendChild(_stEl('div', { + style: 'font-size:.95rem;font-weight:700;color:rgba(255,255,255,0.92);word-break:break-word;', + textContent: rc.label, + })); + card.appendChild(_stEl('div', { + style: 'font-size:.78rem;color:rgba(255,255,255,0.5);margin-top:4px;word-break:break-word;', + textContent: rc.name, + })); + + card.addEventListener('click', () => { + this._recipeIdx = i; + /* обновить стили всех карточек */ + grid.querySelectorAll('[data-recipe-card]').forEach((el, j) => { + el.style.cssText = this._recipeCardStyle(j === i); + }); + updateEq(); + if (window.LabFX) LabFX.sound.play('click', { pitch: 1.3 }); + }); + card.setAttribute('data-recipe-card', i); + grid.appendChild(card); + }); + c.appendChild(grid); + + /* кнопка Далее */ + c.appendChild(this._navRow(null, () => this._goStep2())); + } + + _recipeCardStyle(selected) { + return [ + 'padding:12px 14px', + 'border-radius:10px', + 'cursor:pointer', + 'transition:background .15s,border-color .15s', + selected + ? 'background:rgba(155,93,229,0.2);border:2px solid #9B5DE5' + : 'background:rgba(255,255,255,0.04);border:2px solid rgba(255,255,255,0.08)', + ].join(';'); + } + + _goStep2() { + const r = StoichSim.RECIPES[this._recipeIdx]; + this._amounts = r.reactants.map(re => re.M); + this._inputMode = r.reactants.map(() => 'mass'); + this._computed = null; + this._step = 2; + this._renderStep(); + } + + /* ══════════════════════════════════════════════════════════════════ + ШАГ 2: Введите количества + ══════════════════════════════════════════════════════════════════ */ + _renderStep2(c) { 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); + c.appendChild(_stEl('div', { + style: 'font-size:1.15rem;font-weight:700;color:rgba(255,255,255,0.92);margin-bottom:4px;', + textContent: 'Задайте количества реагентов', + })); + c.appendChild(_stEl('div', { + style: 'font-size:.85rem;color:rgba(255,255,255,0.5);margin-bottom:6px;word-break:break-word;', + textContent: r.name, + })); + + /* разделитель */ + c.appendChild(_stEl('div', { + style: 'height:1px;background:rgba(255,255,255,0.08);margin-bottom:18px;', + })); + + const cards = _stEl('div', { style: 'display:flex;flex-direction:column;gap:14px;margin-bottom:24px;' }); 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;', + const card = _stEl('div', { + style: [ + 'padding:16px 18px', + 'background:rgba(255,255,255,0.05)', + 'border:1px solid rgba(255,255,255,0.1)', + 'border-radius:12px', + ].join(';'), }); - // Name - const nameRow = _stEl('div', { - style: `font-size:.8rem;font-weight:700;color:${re.color};margin-bottom:6px;`, + /* шапка карточки */ + const head = _stEl('div', { style: 'display:flex;align-items:center;gap:12px;margin-bottom:12px;' }); + head.appendChild(_stEl('div', { + style: `font-size:1.3rem;font-weight:800;color:${re.color};`, textContent: re.sym, - }); - block.appendChild(nameRow); + })); + const phaseTxt = re.phase === 'g' ? 'газ' : re.phase === 'aq' ? 'раствор' : re.phase === 'l' ? 'жидкость' : 'твёрдое'; + head.appendChild(_stEl('div', { + style: 'font-size:.78rem;color:rgba(255,255,255,0.45);', + textContent: phaseTxt + ' · M = ' + re.M + ' г/моль · коэф. ' + re.coef, + })); + card.appendChild(head); + + /* кнопки единиц */ + const modeRow = _stEl('div', { style: 'display:flex;gap:6px;margin-bottom:12px;' }); + const modes = [['mass', 'г (масса)'], ['mol', 'моль'], ...(re.phase === 'g' ? [['vol', 'л (объём)']] : [])]; + + const updateMode = (newMode) => { + this._inputMode[i] = newMode; + modeRow.querySelectorAll('[data-mode-btn]').forEach((btn) => { + const m = btn.getAttribute('data-mode-btn'); + btn.style.cssText = this._modeBtnStyle(m === newMode); + }); + this._syncSlider(i, re, sliderEl, valEl); + }; - // 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;`, + style: this._modeBtnStyle(this._inputMode[i] === m), 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(); - }); + btn.setAttribute('data-mode-btn', m); + btn.addEventListener('click', () => updateMode(m)); modeRow.appendChild(btn); }); - block.appendChild(modeRow); + card.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 slParams = this._sliderParams(i, re); + const sliderEl = document.createElement('input'); + sliderEl.type = 'range'; + sliderEl.min = slParams.min; + sliderEl.max = slParams.max; + sliderEl.step = slParams.step; + sliderEl.value = slParams.val; + sliderEl.style.cssText = 'width:100%;accent-color:#9B5DE5;cursor:pointer;height:6px;margin-bottom:8px;display:block;'; - 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 valEl = _stEl('div', { + style: 'font-size:1.1rem;font-weight:700;color:#FFD166;text-align:right;', + textContent: this._fmtSliderVal(i, re), }); - 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); + 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; - valSpan.textContent = v.toFixed(3) + ' ' + unit; - this._compute(); - this._updateAll(); + valEl.textContent = this._fmtSliderVal(i, re); }); - const slRow = _stEl('div', { style: 'display:flex;align-items:center;gap:6px;' }); - slRow.appendChild(sl); + card.appendChild(sliderEl); + card.appendChild(valEl); - block.appendChild(slRow); - block.appendChild(valSpan); - lp.appendChild(block); + /* запомнить ссылки на элементы для updateMode */ + cards.appendChild(card); + }); + c.appendChild(cards); + + c.appendChild(this._navRow(() => { this._step = 1; this._renderStep(); }, () => this._goStep3())); + } + + _modeBtnStyle(active) { + return [ + 'padding:6px 14px', + 'border-radius:6px', + 'font-size:.8rem', + 'font-weight:600', + 'cursor:pointer', + 'border:1px solid', + 'font-family:Manrope,sans-serif', + 'transition:background .15s', + active + ? 'background:rgba(155,93,229,0.4);color:#fff;border-color:rgba(155,93,229,0.7)' + : 'background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.65);border-color:rgba(255,255,255,0.12)', + ].join(';'); + } + + _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 = p.min; + sliderEl.max = p.max; + sliderEl.step = p.step; + sliderEl.value = 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) + ' г'; + } + + _goStep3() { + this._compute(); + this._step = 3; + this._renderStep(); + } + + /* ══════════════════════════════════════════════════════════════════ + ШАГ 3: Лимитирующий реагент + ══════════════════════════════════════════════════════════════════ */ + _renderStep3(c) { + const r = StoichSim.RECIPES[this._recipeIdx]; + const comp = this._computed; + + c.appendChild(_stEl('div', { + style: 'font-size:1.15rem;font-weight:700;color:rgba(255,255,255,0.92);margin-bottom:4px;', + textContent: 'Лимитирующий реагент', + })); + c.appendChild(_stEl('div', { + style: 'font-size:.85rem;color:rgba(255,255,255,0.5);margin-bottom:18px;', + textContent: 'Реагент с наименьшим отношением n/ν лимитирует реакцию.', + })); + + /* таблица сравнения */ + const table = _stEl('div', { style: 'display:flex;flex-direction:column;gap:10px;margin-bottom:20px;' }); + + r.reactants.forEach((re, i) => { + const n = this._amounts[i] / re.M; + const ratio = n / re.coef; + const isLim = i === comp.limitIdx; + + const row = _stEl('div', { + style: [ + 'display:flex', + 'align-items:center', + 'gap:14px', + 'padding:14px 18px', + 'border-radius:12px', + 'background:rgba(255,255,255,0.05)', + isLim + ? 'border:2px solid #EF476F' + : 'border:2px solid rgba(255,255,255,0.08)', + ].join(';'), + }); + + /* символ */ + row.appendChild(_stEl('div', { + style: `font-size:1.3rem;font-weight:800;color:${re.color};min-width:60px;`, + textContent: re.sym, + })); + + /* данные */ + const data = _stEl('div', { style: 'flex:1;display:flex;flex-direction:column;gap:4px;' }); + + const nLine = _stEl('div', { style: 'overflow-x:auto;' }); + _stKatex(nLine, + `n = \\dfrac{${this._amounts[i].toFixed(2)}}{${re.M}} = ${n.toFixed(4)}\\text{ моль}`, + true + ); + data.appendChild(nLine); + + const ratioLine = _stEl('div', { style: 'overflow-x:auto;' }); + _stKatex(ratioLine, + `\\dfrac{n}{\\nu} = \\dfrac{${n.toFixed(4)}}{${re.coef}} = ${ratio.toFixed(4)}`, + true + ); + data.appendChild(ratioLine); + + row.appendChild(data); + + /* бейдж */ + if (isLim) { + row.appendChild(_stEl('div', { + style: [ + 'padding:4px 10px', + 'border-radius:6px', + 'background:#EF476F', + 'color:#fff', + 'font-size:.75rem', + 'font-weight:800', + 'white-space:nowrap', + ].join(';'), + textContent: 'ЛИМИТ', + })); + } else { + const excessN = n - comp.limitVal * re.coef; + row.appendChild(_stEl('div', { + style: [ + 'padding:4px 10px', + 'border-radius:6px', + 'background:rgba(255,209,102,0.15)', + 'color:#FFD166', + 'font-size:.75rem', + 'font-weight:700', + 'white-space:nowrap', + ].join(';'), + textContent: 'избыток ' + (excessN * re.M).toFixed(2) + ' г', + })); + } + + table.appendChild(row); + }); + c.appendChild(table); + + /* формула n_lim */ + const limBox = _stEl('div', { + style: [ + 'padding:14px 18px', + 'background:rgba(239,71,111,0.1)', + 'border:1px solid rgba(239,71,111,0.3)', + 'border-radius:12px', + 'margin-bottom:24px', + 'overflow-x:auto', + ].join(';'), + }); + limBox.appendChild(_stEl('div', { + style: 'font-size:.78rem;font-weight:700;color:#EF476F;margin-bottom:8px;', + textContent: 'Лимитирующий: ' + r.reactants[comp.limitIdx].sym, + })); + const limFormulaEl = _stEl('div', {}); + const ratiosList = r.reactants + .map((re, i) => (this._amounts[i] / re.M / re.coef).toFixed(4)) + .join(';\\,'); + _stKatex(limFormulaEl, + `n_{\\text{лим}} = \\min\\!\\left(${ratiosList}\\right) = ${comp.limitVal.toFixed(4)}\\text{ моль}`, + true + ); + limBox.appendChild(limFormulaEl); + c.appendChild(limBox); + + c.appendChild(this._navRow(() => { this._step = 2; this._renderStep(); }, () => this._goStep4())); + } + + _goStep4() { + this._animState = 'idle'; + this._animT = 0; + this._step = 4; + this._renderStep(); + } + + /* ══════════════════════════════════════════════════════════════════ + ШАГ 4: Продукты и итоги + ══════════════════════════════════════════════════════════════════ */ + _renderStep4(c) { + const r = StoichSim.RECIPES[this._recipeIdx]; + const comp = this._computed; + const limRe = r.reactants[comp.limitIdx]; + + c.appendChild(_stEl('div', { + style: 'font-size:1.15rem;font-weight:700;color:rgba(255,255,255,0.92);margin-bottom:4px;', + textContent: 'Продукты реакции', + })); + c.appendChild(_stEl('div', { + style: 'font-size:.85rem;color:rgba(255,255,255,0.5);margin-bottom:18px;word-break:break-word;', + textContent: r.name, + })); + + /* итоговые бейджи */ + const badges = _stEl('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px;' }); + badges.appendChild(this._badge('Лимит: ' + limRe.sym, '#EF476F', 'rgba(239,71,111,0.12)')); + + r.reactants.forEach((re, i) => { + if (i !== comp.limitIdx) { + const excessM = (comp.reactantQ[i].nExcess * re.M).toFixed(2); + badges.appendChild(this._badge('Избыток ' + re.sym + ': ' + excessM + ' г', '#FFD166', 'rgba(255,209,102,0.1)')); + } }); - // 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: 'Сброс', + const totalProdM = comp.productQ.reduce((s, q) => s + q.m, 0); + badges.appendChild(this._badge('Выход теор.: ' + totalProdM.toFixed(3) + ' г', '#06D6E0', 'rgba(6,214,224,0.1)')); + + const totalGasV = r.products + .reduce((s, pr, i) => s + (pr.phase === 'g' ? comp.productQ[i].v : 0), 0); + if (totalGasV > 0.0001) { + badges.appendChild(this._badge('Газов: ' + totalGasV.toFixed(3) + ' л', '#9B5DE5', 'rgba(155,93,229,0.12)')); + } + c.appendChild(badges); + + /* карточки продуктов */ + const cards = _stEl('div', { style: 'display:flex;flex-direction:column;gap:14px;margin-bottom:20px;' }); + + r.products.forEach((pr, i) => { + const q = comp.productQ[i]; + const card = _stEl('div', { + style: [ + 'padding:16px 18px', + 'background:rgba(255,255,255,0.05)', + 'border:1px solid rgba(255,255,255,0.1)', + 'border-radius:12px', + ].join(';'), + }); + + /* шапка */ + const head = _stEl('div', { style: 'display:flex;align-items:center;gap:10px;margin-bottom:12px;' }); + head.appendChild(_stEl('div', { + style: `font-size:1.3rem;font-weight:800;color:${pr.color};`, + textContent: pr.sym, + })); + const phaseTxt = pr.phase === 'g' ? '(г)' : pr.phase === 'aq' ? '(р-р)' : pr.phase === 'l' ? '(ж)' : '(тв)'; + head.appendChild(_stEl('div', { + style: 'font-size:.78rem;color:rgba(255,255,255,0.45);', + textContent: phaseTxt + ' · M = ' + pr.M + ' г/моль', + })); + card.appendChild(head); + + /* шаг 1: n продукта */ + const step1 = _stEl('div', { style: 'overflow-x:auto;margin-bottom:8px;' }); + _stKatex(step1, + `n = \\dfrac{${pr.coef}}{${limRe.coef}} \\cdot n_{\\text{лим}} = \\dfrac{${pr.coef}}{${limRe.coef}} \\cdot ${comp.limitVal.toFixed(4)} = ${q.n.toFixed(4)}\\text{ моль}`, + true + ); + card.appendChild(step1); + + /* шаг 2: масса */ + const step2 = _stEl('div', { style: 'overflow-x:auto;margin-bottom:8px;' }); + _stKatex(step2, + `m = n \\cdot M = ${q.n.toFixed(4)} \\cdot ${pr.M} = ${q.m.toFixed(3)}\\text{ г}`, + true + ); + card.appendChild(step2); + + /* шаг 3: объём (если газ) */ + if (pr.phase === 'g') { + const step3 = _stEl('div', { style: 'overflow-x:auto;' }); + _stKatex(step3, + `V = n \\cdot 22{,}4 = ${q.n.toFixed(4)} \\cdot 22{,}4 = ${q.v.toFixed(3)}\\text{ л (н.у.)}`, + true + ); + card.appendChild(step3); + } + + cards.appendChild(card); }); - 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(); + c.appendChild(cards); + + /* canvas с анимацией */ + const canvasWrap = _stEl('div', { + style: [ + 'position:relative', + 'width:100%', + 'border-radius:12px', + 'overflow:hidden', + 'background:#0D0D1A', + 'border:1px solid rgba(255,255,255,0.08)', + 'margin-bottom:20px', + ].join(';'), }); - lp.appendChild(resetBtn); + + this._canvas = document.createElement('canvas'); + this._canvas.style.cssText = 'display:block;width:100%;height:180px;'; + canvasWrap.appendChild(this._canvas); + + const animBtn = _stEl('button', { + style: [ + 'display:block', + 'margin:0 auto 4px', + 'padding:8px 22px', + 'border-radius:8px', + 'background:linear-gradient(135deg,#9B5DE5,#4CC9F0)', + 'color:#fff', + 'font-size:.9rem', + 'font-weight:700', + 'border:none', + 'cursor:pointer', + 'font-family:Manrope,sans-serif', + ].join(';'), + textContent: 'Показать реакцию', + }); + animBtn.addEventListener('click', () => { + this._startAnim(); + animBtn.disabled = true; + animBtn.style.opacity = '.5'; + }); + canvasWrap.appendChild(animBtn); + c.appendChild(canvasWrap); + + /* запустить canvas */ + 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(); + }); + + /* навигация */ + c.appendChild(this._navRow( + () => { this._step = 3; this._renderStep(); }, + null, + 'Заново', + () => { + this._step = 1; + this._recipeIdx = 0; + this._amounts = []; + this._inputMode = []; + this._computed = null; + this._renderStep(); + } + )); + } + + _badge(text, color, bg) { + return _stEl('div', { + style: [ + 'padding:5px 12px', + 'border-radius:6px', + `background:${bg}`, + `color:${color}`, + 'font-size:.8rem', + 'font-weight:700', + 'white-space:nowrap', + ].join(';'), + textContent: text, + }); + } + + /* ══════════════════════════════════════════════════════════════════ + Навигационная строка + back: function | null + next: function | null + resetLabel: строка для кнопки "Заново" (только на шаге 4) + resetFn: function | null + ══════════════════════════════════════════════════════════════════ */ + _navRow(backFn, nextFn, resetLabel, resetFn) { + const row = _stEl('div', { + style: 'display:flex;align-items:center;gap:10px;justify-content:flex-end;', + }); + + if (backFn) { + const btn = _stEl('button', { + style: this._navBtnStyle(false), + textContent: 'Назад', + }); + btn.addEventListener('click', () => { + if (window.LabFX) LabFX.sound.play('click'); + backFn(); + }); + row.appendChild(btn); + } + + if (resetFn && resetLabel) { + const btn = _stEl('button', { + style: [ + this._navBtnStyle(false), + 'background:rgba(255,255,255,0.06)', + 'border:1px solid rgba(255,255,255,0.15)', + ].join(';'), + textContent: resetLabel, + }); + btn.addEventListener('click', () => { + if (window.LabFX) LabFX.sound.play('click'); + resetFn(); + }); + row.appendChild(btn); + } + + if (nextFn) { + const btn = _stEl('button', { + style: this._navBtnStyle(true), + textContent: 'Далее', + }); + btn.addEventListener('click', () => { + if (window.LabFX) LabFX.sound.play('click', { pitch: 1.2 }); + nextFn(); + }); + row.appendChild(btn); + } + + return row; + } + + _navBtnStyle(primary) { + return [ + 'padding:10px 24px', + 'border-radius:8px', + 'font-size:1rem', + 'font-weight:700', + 'cursor:pointer', + 'border:none', + 'font-family:Manrope,sans-serif', + 'transition:opacity .15s', + primary + ? 'background:linear-gradient(135deg,#9B5DE5,#4CC9F0);color:#fff' + : 'background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.8)', + ].join(';'); } /* ── Расчёт стехиометрии ─────────────────────────────────────────── */ _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 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, + 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, - }; + 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) { + if (window.LabFX && prevLimitIdx !== -1 && 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;overflow-x:auto;max-width:100%;font-size:.85rem;' }); - _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;overflow-x:auto;max-width:100%;font-size:.85rem;' }); - _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;overflow-x:auto;max-width:100%;font-size:.85rem;' }); - _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;overflow-x:auto;max-width:100%;font-size:.85rem;' }); - _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', { style: 'overflow-x:auto;max-width:100%;font-size:.85rem;' }); - _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: размеры ─────────────────────────────────────────────── */ + /* ── 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); @@ -632,11 +974,11 @@ class StoichSim { this._H = h; } - /* ── Canvas: рисование ──────────────────────────────────────────── */ _draw() { const ctx = this._ctx; - const W = this._W || this._canvas.clientWidth; - const H = this._H || this._canvas.clientHeight; + 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); @@ -653,85 +995,78 @@ class StoichSim { ]; 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 gap = 12; + const boxW = Math.min(Math.floor((W - (N + 1) * gap) / N), 120); + const boxH = Math.min(H - 30, 140); + const totalW = N * boxW + (N - 1) * gap; const startX = (W - totalW) / 2; - const topY = (H - boxH) / 2 - 10; + const topY = (H - boxH) / 2 - 8; - // Стрелка-разделитель между реагентами и продуктами 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); + const x = startX + k * (boxW + gap); - // Стрелка → перед первым продуктом if (k === sepIdx) { ctx.save(); - ctx.strokeStyle = `rgba(255,255,255,${0.2 + animT * 0.5})`; + ctx.strokeStyle = `rgba(255,255,255,${0.25 + animT * 0.5})`; ctx.lineWidth = 2; - const ax = x - 10; + const ax = x - gap * 0.5; ctx.beginPath(); - ctx.moveTo(ax - 12, topY + boxH / 2); - ctx.lineTo(ax - 2, topY + boxH / 2); + ctx.moveTo(ax - 10, topY + boxH / 2); + ctx.lineTo(ax, 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.moveTo(ax - 6, topY + boxH / 2 - 5); + ctx.lineTo(ax, topY + boxH / 2); + ctx.lineTo(ax - 6, 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)'; + ? `rgba(239,71,111,${0.45 + animT * 0.4})` + : 'rgba(255,255,255,0.12)'; ctx.strokeStyle = borderColor; ctx.lineWidth = isLimit ? 2 : 1; ctx.beginPath(); - _stRoundRect(ctx, x, y, bw, bh, r); + _stRoundRect(ctx, x, y, bw, bh, 6); 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.font = 'bold 12px Manrope,sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(sub.sym, x + bw / 2, y + 16); + ctx.fillText(sub.sym, x + bw / 2, y + 17); - // 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((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; + const areaH = bh - 42; - // Seed deterministic positions from sub.sym - const seed = sub.sym.split('').reduce((a, c) => a + c.charCodeAt(0), 0); - const pts = []; + const seed = sub.sym.split('').reduce((a, ch) => a + ch.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]); + pts.push([ + areaX + _stLcg(seed + p * 7) * areaW, + areaY + _stLcg(seed + p * 7 + 3) * areaH, + ]); } const alpha = isReactant @@ -741,12 +1076,8 @@ class StoichSim { 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; + 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; @@ -759,15 +1090,13 @@ class StoichSim { } 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.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '9px Manrope,sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(phaseText, x + bw / 2, y + bh - 6); + ctx.fillText(phaseText, x + bw / 2, y + bh - 16); - // Mass badge bottom-right - ctx.fillStyle = 'rgba(255,214,102,0.8)'; + ctx.fillStyle = 'rgba(255,214,102,0.85)'; ctx.font = 'bold 9px Manrope,sans-serif'; ctx.textAlign = 'right'; ctx.fillText(q.m.toFixed(2) + 'г', x + bw - 4, y + bh - 6); @@ -780,21 +1109,22 @@ class StoichSim { if (this._animState === 'reacting') return; this._animState = 'reacting'; this._animT = 0; - const dur = 1200; // ms + const dur = 1200; const start = performance.now(); - let lastTs = start; + 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]; + 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' }); + 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', + }); }); } @@ -809,14 +1139,13 @@ class StoichSim { this._raf = requestAnimationFrame(tick); } else { this._animState = 'done'; - this._rebuildHud(); this._draw(); } }; this._raf = requestAnimationFrame(tick); } - /* ── Public API для _openStoich ─────────────────────────────────── */ + /* ── Public API ─────────────────────────────────────────────────── */ fit() { this._fitCanvas(); this._draw(); @@ -825,10 +1154,12 @@ class StoichSim { destroy() { if (this._raf) cancelAnimationFrame(this._raf); if (this._ro) this._ro.disconnect(); + this._canvas = null; + this._ctx = null; } } -/* ── helpers (stoichiometry-local, prefixed _st to avoid collisions) ─ */ +/* ── Helpers (prefixed _st to avoid collisions) ─────────────────── */ function _stEl(tag, props) { const el = document.createElement(tag); Object.entries(props || {}).forEach(([k, v]) => { @@ -842,38 +1173,39 @@ function _stEl(tag, props) { 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.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.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); - ctx.arcTo(x, y, x + r, 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) { +function _stKatex(el, formula, displayMode) { if (window.katex) { try { - el.innerHTML = katex.renderToString(formula, { throwOnError: false, displayMode: false }); + el.innerHTML = katex.renderToString(formula, { + throwOnError: false, + displayMode: displayMode === true, + }); return; - } catch(e) { /* fallback */ } + } 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)'; + el.style.fontSize = '.85rem'; + el.style.color = 'rgba(255,255,255,0.8)'; } /* ═══════════════════════════════════════════════════════════════════ - lab UI init — следует паттерну _openChemSandbox / _openEquilibrium + Lab UI init ═══════════════════════════════════════════════════════════════════ */ var _stoichSim = null;