diff --git a/frontend/js/labs/qualanalysis.js b/frontend/js/labs/qualanalysis.js index 796dfe5..76500bf 100644 --- a/frontend/js/labs/qualanalysis.js +++ b/frontend/js/labs/qualanalysis.js @@ -1,7 +1,7 @@ 'use strict'; /* ════════════════════════════════════════════════════════════════════ - QualAnalysisSim — Качественный анализ катионов и анионов - Редизайн: centered tube, large log panel, reagent shelf bottom + QualAnalysisSim — Качественный анализ (Стол-лаборатория v3) + Layout: question-bar → 4 tubes + sample → reagent shelf → log → answer-bar ════════════════════════════════════════════════════════════════════ */ class QualAnalysisSim { @@ -216,8 +216,7 @@ class QualAnalysisSim { K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нейтрализация: H⁺ + NH₃ → NH₄⁺', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false }, H2SO4: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, - }, - indicators: { litmus: 'красный', methylorange: 'красный', phenolphthalein: 'бесцветный' } + } }, { id: 'OH-', label: 'OH⁻', type: 'an', group: 'Анионы', @@ -232,8 +231,7 @@ class QualAnalysisSim { K3FeCN6: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, NH3: { obs: 'Нет реакции', color: null, type: 'none', positive: false }, H2SO4: { obs: 'Нейтрализация', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false }, - }, - indicators: { litmus: 'синий', methylorange: 'жёлтый', phenolphthalein: 'малиновый' } + } }, /* АНИОНЫ */ { @@ -400,30 +398,65 @@ class QualAnalysisSim { { id: 'flame', label: 'Пламя', color: '#FF8800' }, ]; + /* ── Tube count based on viewport ──────────────────────────────── */ + static _tubeCount() { + return (typeof window !== 'undefined' && window.innerWidth < 600) ? 2 : 4; + } + /* ── constructor ─────────────────────────────────────────────── */ constructor(container) { this._container = container; - this._mode = 'identify'; - this._targetIon = null; - this._log = []; + this._mode = 'free'; // 'free' | 'train' | 'exam' + this._targetIon = null; // ион для «Тренировки» (один) + this._examIons = []; // ионы для «Экзамена» (по одному на пробирку) + this._log = []; // все записи журнала this._answered = false; - this._dropAnim = []; - this._precipParticles = []; - this._gasParticles = []; + this._score = 0; + this._scoreTotal = 0; this._raf = null; - this._tubeState = { - color: null, + this._lastT = 0; + this._dragReagent = null; + this._activeTube = 0; // индекс активной пробирки (0-3) + this._helpVisible = false; + this._tubeCount = QualAnalysisSim._tubeCount(); + + /* per-tube state array (index 0..tubeCount-1) */ + this._tubes = []; + this._tubeParticles = []; // { drops, precip, gas } per tube + this._tubeCanvas = null; + this._tubeCtx = null; + + this._build(); + this._bindEvents(); + this._startMode('free'); + } + + /* ── Helpers ─────────────────────────────────────────────────── */ + _makeTubeState(solColor) { + return { + solColor: solColor || 'rgba(100,180,255,0.15)', precipColor: null, precipH: 0, gasLabel: null, flameColor: null, - solColor: 'rgba(100,180,255,0.18)' + flameTimer: 0, }; - this._score = 0; - this._lastT = 0; - this._build(); - this._bindEvents(); - this._startMode('identify'); + } + + _makeTubeParticles() { + return { drops: [], precip: [], gas: [] }; + } + + _resetTubes() { + this._tubes = []; + this._tubeParticles = []; + for (let i = 0; i < this._tubeCount; i++) { + this._tubes.push(this._makeTubeState()); + this._tubeParticles.push(this._makeTubeParticles()); + } + /* sample tube (index = tubeCount) — always present in train/exam */ + this._tubes.push(this._makeTubeState()); + this._tubeParticles.push(this._makeTubeParticles()); } /* ── DOM build ───────────────────────────────────────────────── */ @@ -438,386 +471,597 @@ class QualAnalysisSim { 'font-family:Manrope,sans-serif', 'overflow:hidden', 'user-select:none', + 'position:relative', ].join(';'); - /* ── TOP BAR ── */ - const tb = document.createElement('div'); - tb.style.cssText = [ - 'display:flex', - 'align-items:center', - 'gap:10px', - 'padding:10px 16px', - 'border-bottom:1px solid rgba(255,255,255,0.09)', + /* ── QUESTION BAR ── */ + const qbar = document.createElement('div'); + qbar.id = 'qa-qbar'; + qbar.style.cssText = [ 'flex-shrink:0', - 'flex-wrap:wrap', - 'background:rgba(255,255,255,0.02)', + 'padding:10px 16px 8px', + 'background:rgba(155,93,229,0.10)', + 'border-bottom:1px solid rgba(155,93,229,0.22)', + 'position:relative', ].join(';'); - /* mode tabs */ - const tabsWrap = document.createElement('div'); - tabsWrap.style.cssText = 'display:flex;gap:6px'; + /* task text */ + const taskText = document.createElement('div'); + taskText.id = 'qa-task'; + taskText.style.cssText = [ + 'font-size:1.05rem', + 'font-weight:700', + 'color:rgba(255,255,255,0.95)', + 'margin-bottom:8px', + 'line-height:1.4', + 'padding-right:44px', + ].join(';'); + taskText.textContent = 'Выбери режим и начни эксперимент'; + qbar.appendChild(taskText); - 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; - }; + /* controls row */ + const ctrlRow = document.createElement('div'); + ctrlRow.style.cssText = 'display:flex;align-items:center;gap:12px;flex-wrap:wrap'; - 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); + /* mode select */ + const modeSel = document.createElement('select'); + modeSel.id = 'qa-mode-sel'; + modeSel.style.cssText = [ + 'padding:5px 10px', + 'border-radius:8px', + 'border:1px solid rgba(155,93,229,0.45)', + 'background:#1a1a2e', + 'color:#D0A0FF', + 'font-size:.92rem', + 'font-weight:700', + 'cursor:pointer', + 'outline:none', + ].join(';'); + [['free','Свободно'],['train','Тренировка'],['exam','Экзамен']].forEach(([v,t]) => { + const o = document.createElement('option'); + o.value = v; o.textContent = t; + modeSel.appendChild(o); + }); + ctrlRow.appendChild(modeSel); /* 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); + const scoreEl = document.createElement('span'); + scoreEl.id = 'qa-score-wrap'; + scoreEl.style.cssText = 'font-size:.95rem;color:rgba(255,255,255,0.65)'; + scoreEl.innerHTML = 'Счёт: 0'; + ctrlRow.appendChild(scoreEl); - /* new question button */ + /* new task button */ const btnNew = document.createElement('button'); btnNew.id = 'qa-btn-new'; - btnNew.textContent = 'Новый вопрос'; + btnNew.textContent = 'Новая задача'; btnNew.style.cssText = [ - 'padding:8px 18px', - 'border-radius:10px', - 'border:1px solid rgba(6,214,224,0.45)', + 'padding:5px 14px', + 'border-radius:8px', + 'border:1px solid rgba(6,214,224,0.5)', 'background:rgba(6,214,224,0.1)', 'color:#06D6E0', + 'font-size:.92rem', + 'font-weight:700', + 'cursor:pointer', + ].join(';'); + ctrlRow.appendChild(btnNew); + + qbar.appendChild(ctrlRow); + + /* help button */ + const btnHelp = document.createElement('button'); + btnHelp.id = 'qa-btn-help'; + btnHelp.title = 'Справка'; + btnHelp.style.cssText = [ + 'position:absolute', + 'top:10px', + 'right:14px', + 'width:30px', + 'height:30px', + 'border-radius:50%', + 'border:1px solid rgba(255,255,255,0.25)', + 'background:rgba(255,255,255,0.07)', + 'color:rgba(255,255,255,0.75)', 'font-size:.95rem', 'font-weight:700', 'cursor:pointer', - 'transition:background .15s', + 'display:flex', + 'align-items:center', + 'justify-content:center', ].join(';'); - tb.appendChild(btnNew); - this._container.appendChild(tb); + btnHelp.textContent = '?'; + qbar.appendChild(btnHelp); - /* ── 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); + this._container.appendChild(qbar); - /* CENTER SCENE */ + /* ── HELP POPOVER ── */ + const helpPop = document.createElement('div'); + helpPop.id = 'qa-help-pop'; + helpPop.style.cssText = [ + 'display:none', + 'position:absolute', + 'top:90px', + 'right:14px', + 'z-index:100', + 'width:280px', + 'background:#1a1a2e', + 'border:1px solid rgba(155,93,229,0.45)', + 'border-radius:12px', + 'padding:14px 16px', + 'font-size:.88rem', + 'color:rgba(255,255,255,0.85)', + 'line-height:1.55', + 'box-shadow:0 8px 32px rgba(0,0,0,0.6)', + ].join(';'); + helpPop.innerHTML = [ + 'Как пользоваться', + '', + ].join(''); + this._container.appendChild(helpPop); + + /* ── SCENE (canvas) ── */ const scene = document.createElement('div'); + scene.id = 'qa-scene'; scene.style.cssText = [ 'flex:1', - 'display:flex', - 'flex-direction:column', - 'min-width:0', - 'overflow:hidden', + 'min-height:220px', 'position:relative', + 'overflow:hidden', ].join(';'); - mainRow.appendChild(scene); - - /* 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'); + canvas.style.cssText = 'display:block;width:100%;height:100%'; + scene.appendChild(canvas); + this._container.appendChild(scene); + this._tubeCanvas = canvas; + this._tubeCtx = 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) ── */ + /* ── REAGENT SHELF ── */ const shelf = document.createElement('div'); shelf.id = 'qa-shelf'; shelf.style.cssText = [ 'flex-shrink:0', - 'border-top:1px solid rgba(255,255,255,0.09)', - 'padding:10px 14px', + 'padding:8px 14px', + 'border-top:1px solid rgba(255,255,255,0.07)', + 'border-bottom:1px solid rgba(255,255,255,0.07)', 'background:rgba(255,255,255,0.025)', 'display:flex', 'flex-wrap:wrap', - 'gap:7px', + 'gap:6px', 'align-items:center', + 'min-height:52px', ].join(';'); const shelfLabel = document.createElement('span'); shelfLabel.style.cssText = [ - 'font-size:.75rem', + 'font-size:.78rem', 'font-weight:700', 'text-transform:uppercase', 'letter-spacing:.06em', - 'color:rgba(255,255,255,0.4)', - 'margin-right:4px', + 'color:rgba(255,255,255,0.5)', 'flex-shrink:0', ].join(';'); - shelfLabel.textContent = 'Реагенты:'; + shelfLabel.textContent = 'Реагенты (drag в пробирку):'; 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.draggable = true; btn.style.cssText = [ - 'padding:7px 14px', - 'border-radius:9px', + 'padding:6px 13px', + 'border-radius:8px', `border:1px solid ${r.color}55`, - `background:${r.color}18`, + `background:${r.color}15`, `color:${r.color}`, - 'font-size:.95rem', + 'font-size:.9rem', 'font-weight:700', - 'cursor:pointer', - 'transition:background .15s, transform .1s', - 'min-width:60px', - 'text-align:center', + 'cursor:grab', + 'transition:background .15s,transform .1s', ].join(';'); btn.textContent = r.label; btn.addEventListener('mouseenter', () => { - btn.style.background = r.color + '33'; - btn.style.transform = 'translateY(-1px)'; + btn.style.background = r.color + '30'; + btn.style.transform = 'translateY(-2px)'; }); btn.addEventListener('mouseleave', () => { - btn.style.background = r.color + '18'; + btn.style.background = r.color + '15'; btn.style.transform = ''; }); shelf.appendChild(btn); }); this._container.appendChild(shelf); + /* ── LOG PANEL ── */ + const logWrap = document.createElement('div'); + logWrap.style.cssText = [ + 'flex-shrink:0', + 'max-height:180px', + 'overflow-y:auto', + 'border-bottom:1px solid rgba(255,255,255,0.07)', + 'background:rgba(0,0,0,0.15)', + ].join(';'); + + const logHeader = document.createElement('div'); + logHeader.style.cssText = [ + 'display:flex', + 'align-items:center', + 'justify-content:space-between', + 'padding:8px 14px 5px', + 'font-size:.78rem', + '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.06)', + 'position:sticky', + 'top:0', + 'background:#0D0D1A', + 'z-index:2', + ].join(';'); + + const logTitle = document.createElement('span'); + logTitle.textContent = 'Журнал наблюдений'; + logHeader.appendChild(logTitle); + + const btnClearLog = document.createElement('button'); + btnClearLog.id = 'qa-btn-clear-log'; + btnClearLog.textContent = 'Очистить'; + btnClearLog.style.cssText = [ + 'padding:3px 10px', + 'border-radius:6px', + 'border:1px solid rgba(255,255,255,0.18)', + 'background:rgba(255,255,255,0.04)', + 'color:rgba(255,255,255,0.65)', + 'font-size:.8rem', + 'cursor:pointer', + ].join(';'); + logHeader.appendChild(btnClearLog); + logWrap.appendChild(logHeader); + + const logList = document.createElement('div'); + logList.id = 'qa-log'; + logList.style.cssText = 'padding:8px 12px;display:flex;flex-direction:column;gap:5px'; + logWrap.appendChild(logList); + + const logHint = document.createElement('div'); + logHint.id = 'qa-log-hint'; + logHint.style.cssText = [ + 'padding:10px 14px', + 'font-size:.88rem', + 'color:rgba(255,255,255,0.38)', + 'text-align:center', + ].join(';'); + logHint.textContent = 'Перетащи реагент в пробирку, чтобы начать'; + logWrap.appendChild(logHint); + this._container.appendChild(logWrap); + + /* ── ANSWER BAR ── */ + const ansBar = document.createElement('div'); + ansBar.id = 'qa-ansbar'; + ansBar.style.cssText = [ + 'flex-shrink:0', + 'display:flex', + 'align-items:center', + 'gap:10px', + 'flex-wrap:wrap', + 'padding:8px 14px', + 'border-top:1px solid rgba(255,255,255,0.07)', + 'background:rgba(255,255,255,0.02)', + 'min-height:50px', + ].join(';'); + + const ansLabel = document.createElement('span'); + ansLabel.style.cssText = 'font-size:.88rem;color:rgba(255,255,255,0.65);flex-shrink:0'; + ansLabel.textContent = 'ОТВЕТ:'; + ansBar.appendChild(ansLabel); + + const ansSel = document.createElement('select'); + ansSel.id = 'qa-answer-sel'; + ansSel.style.cssText = [ + 'padding:6px 11px', + 'border-radius:8px', + 'border:1px solid rgba(255,255,255,0.2)', + 'background:#1a1a2e', + 'color:#E0E0FF', + 'font-size:.9rem', + 'cursor:pointer', + 'outline:none', + 'flex:1', + 'min-width:140px', + 'max-width:240px', + ].join(';'); + ansBar.appendChild(ansSel); + + const btnSubmit = document.createElement('button'); + btnSubmit.id = 'qa-submit'; + btnSubmit.textContent = 'Ответить'; + btnSubmit.style.cssText = [ + 'padding:6px 18px', + 'border-radius:8px', + 'border:none', + 'background:linear-gradient(135deg,#9B5DE5,#06D6E0)', + 'color:#fff', + 'font-size:.92rem', + 'font-weight:700', + 'cursor:pointer', + ].join(';'); + ansBar.appendChild(btnSubmit); + + const verdict = document.createElement('div'); + verdict.id = 'qa-verdict'; + verdict.style.cssText = [ + 'display:none', + 'font-size:.92rem', + 'font-weight:700', + 'padding:5px 13px', + 'border-radius:8px', + ].join(';'); + ansBar.appendChild(verdict); + + this._container.appendChild(ansBar); + this._resizeFit(); + this._updateAnswerSelect(); } + /* ── Resize ──────────────────────────────────────────────────── */ _resizeFit() { - const c = this._canvas; + const c = this._tubeCanvas; + if (!c) return; const p = c.parentElement; if (!p) return; const rect = p.getBoundingClientRect(); - const w = rect.width || p.clientWidth || 400; - const h = rect.height || p.clientHeight || 360; + const w = rect.width || p.clientWidth || 500; + const h = rect.height || p.clientHeight || 300; c.width = Math.round(w); c.height = Math.round(h); this._W = c.width; this._H = c.height; - this._drawTube(); + /* recheck tube count on resize */ + const newCount = QualAnalysisSim._tubeCount(); + if (newCount !== this._tubeCount) { + this._tubeCount = newCount; + this._resetTubes(); + } + this._drawScene(); } - /* ── 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)'; - }); + /* ── Tube geometry ───────────────────────────────────────────── */ + _tubeGeometry() { + const W = this._W || 500; + const H = this._H || 300; + const n = this._tubeCount; + /* show sample only in train/exam */ + const showSample = this._mode !== 'free'; + const totalSlots = showSample ? n + 1 : n; + const tubeW = Math.max(50, Math.min(80, (W - 32) / totalSlots - 18)); + const tH = Math.min(H * 0.72, 150); + const tTop = Math.max(20, (H - tH) * 0.38); + const tBot = tTop + tH; + const totalW = totalSlots * (tubeW + 18) - 18; + const startX = (W - totalW) / 2; + + const tubes = []; + for (let i = 0; i < totalSlots; i++) { + const isSample = showSample && i === n; + const tx = startX + i * (tubeW + 18); + tubes.push({ i, tx, tubeW, tTop, tBot, isSample }); + } + return { tubes, tubeW, tTop, tBot, H, W }; + } + + /* ── Hit test — which tube slot contains point (x,y) ── */ + _hitTestTube(x, y) { + const { tubes } = this._tubeGeometry(); + for (const t of tubes) { + if (x >= t.tx - 8 && x <= t.tx + t.tubeW + 8 && + y >= t.tTop - 10 && y <= t.tBot + 28) { + return t.i; + } + } + return -1; } /* ── Events ──────────────────────────────────────────────────── */ _bindEvents() { const $ = id => document.getElementById(id); - $('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()); + /* mode selector */ + $('qa-mode-sel').addEventListener('change', e => { + this._startMode(e.target.value); + }); + /* new task */ + $('qa-btn-new').addEventListener('click', () => this._startMode(this._mode)); + + /* submit */ + $('qa-submit').addEventListener('click', () => this._submitAnswer()); + + /* clear log */ + $('qa-btn-clear-log').addEventListener('click', () => this._clearLog()); + + /* help toggle */ + $('qa-btn-help').addEventListener('click', () => { + this._helpVisible = !this._helpVisible; + $('qa-help-pop').style.display = this._helpVisible ? 'block' : 'none'; + }); + document.addEventListener('click', e => { + if (this._helpVisible && !e.target.closest('#qa-help-pop') && e.target.id !== 'qa-btn-help') { + this._helpVisible = false; + const pop = $('qa-help-pop'); + if (pop) pop.style.display = 'none'; + } + }, true); + + /* reagent buttons — click on active tube */ this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => { btn.addEventListener('click', () => { - if (this._answered) return; - this._applyReagent(btn.dataset.reagent); + const rId = btn.dataset.reagent; + this._applyReagent(this._activeTube, rId); }); - btn.setAttribute('draggable', 'true'); + + /* drag start */ btn.addEventListener('dragstart', e => { this._dragReagent = btn.dataset.reagent; e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('text/plain', btn.dataset.reagent); }); }); - this._canvas.addEventListener('dragover', e => { + /* canvas — drop target + click to select tube */ + const canvas = this._tubeCanvas; + + canvas.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); - this._canvas.addEventListener('drop', e => { + + canvas.addEventListener('drop', e => { e.preventDefault(); - if (this._dragReagent && !this._answered) { - this._applyReagent(this._dragReagent); - this._dragReagent = null; + const rId = this._dragReagent || e.dataTransfer.getData('text/plain'); + if (!rId) return; + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) * (canvas.width / rect.width); + const y = (e.clientY - rect.top) * (canvas.height / rect.height); + const idx = this._hitTestTube(x, y); + if (idx >= 0) { + this._activeTube = idx; + this._applyReagent(idx, rId); + } + this._dragReagent = null; + }); + + canvas.addEventListener('click', e => { + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) * (canvas.width / rect.width); + const y = (e.clientY - rect.top) * (canvas.height / rect.height); + const idx = this._hitTestTube(x, y); + if (idx >= 0) { + this._activeTube = idx; + this._drawScene(); } }); + /* touch fallback: tap reagent then tap tube */ + canvas.addEventListener('touchstart', e => { + if (!this._pendingReagent) return; + const t = e.touches[0]; + const rect = canvas.getBoundingClientRect(); + const x = (t.clientX - rect.left) * (canvas.width / rect.width); + const y = (t.clientY - rect.top) * (canvas.height / rect.height); + const idx = this._hitTestTube(x, y); + if (idx >= 0) { + this._applyReagent(idx, this._pendingReagent); + this._pendingReagent = null; + e.preventDefault(); + } + }, { passive: false }); + + /* resize */ if (window.ResizeObserver) { const ro = new ResizeObserver(() => this._resizeFit()); - ro.observe(this._canvas.parentElement || this._container); + ro.observe(this._tubeCanvas.parentElement || this._container); } } /* ── Mode start ──────────────────────────────────────────────── */ _startMode(mode) { this._mode = mode; - this._log = []; this._answered = false; - this._dropAnim = []; - this._precipParticles = []; - this._gasParticles = []; + this._log = []; this._dragReagent = null; + this._pendingReagent = null; + this._tubeCount = QualAnalysisSim._tubeCount(); + this._activeTube = 0; + + /* update mode selector UI */ + const modeSel = document.getElementById('qa-mode-sel'); + if (modeSel) modeSel.value = mode; const ions = QualAnalysisSim.IONS; - this._targetIon = ions[Math.floor(Math.random() * ions.length)]; - this._tubeState = { - color: null, - precipColor: null, - precipH: 0, - gasLabel: null, - flameColor: null, - solColor: this._targetIon.solColor || 'rgba(100,180,255,0.18)', - }; - /* reset DOM */ - document.getElementById('qa-log').innerHTML = ''; + /* pick random ions */ + if (mode === 'train') { + this._targetIon = ions[Math.floor(Math.random() * ions.length)]; + this._examIons = []; + } else if (mode === 'exam') { + this._examIons = []; + const shuffled = ions.slice().sort(() => Math.random() - 0.5); + for (let i = 0; i < this._tubeCount; i++) { + this._examIons.push(shuffled[i % shuffled.length]); + } + this._targetIon = null; + } else { + this._targetIon = null; + this._examIons = []; + } + + /* reset tubes */ + this._resetTubes(); + /* set solution color for sample tube in train mode */ + if (mode === 'train' && this._targetIon) { + /* sample = index tubeCount */ + this._tubes[this._tubeCount].solColor = this._targetIon.solColor || 'rgba(100,180,255,0.15)'; + } + /* set colors for exam tubes */ + if (mode === 'exam') { + for (let i = 0; i < this._tubeCount; i++) { + if (this._examIons[i]) { + this._tubes[i].solColor = this._examIons[i].solColor || 'rgba(100,180,255,0.15)'; + } + } + } + + /* reset log */ + const logEl = document.getElementById('qa-log'); + if (logEl) logEl.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._populateQuestion(mode); - this._drawTube(); + /* verdict */ + const verdict = document.getElementById('qa-verdict'); + if (verdict) { verdict.style.display = 'none'; verdict.textContent = ''; } + + /* update answer select */ + this._updateAnswerSelect(); + + /* task text */ + this._updateTaskText(); + + /* answer bar visibility */ + const ansBar = document.getElementById('qa-ansbar'); + if (ansBar) ansBar.style.display = (mode === 'free') ? 'none' : 'flex'; + + this._drawScene(); if (!this._raf) this._animLoop(performance.now()); } - _populateQuestion(mode) { - const q = document.getElementById('qa-question'); - if (mode === 'identify') { - q.textContent = 'Добавляй реагенты — определи ион в пробирке, выбери ответ и нажми «Ответить»'; + _updateTaskText() { + const el = document.getElementById('qa-task'); + if (!el) return; + if (this._mode === 'free') { + el.textContent = 'Свободный режим — добавляй реагенты в пробирки и наблюдай реакции'; + } else if (this._mode === 'train') { + el.textContent = 'Тренировка: определи неизвестный ион в Образце'; } else { - q.textContent = 'Испытай неизвестный раствор реагентами, затем выбери ион и ответь'; + el.textContent = 'Экзамен: определи неизвестный ион в каждой пробирке (выбери пробирку, затем дай ответ)'; } } _updateAnswerSelect() { const sel = document.getElementById('qa-answer-sel'); + if (!sel) return; sel.innerHTML = ''; ['Катионы', 'Анионы'].forEach(grp => { const og = document.createElement('optgroup'); @@ -832,82 +1076,117 @@ class QualAnalysisSim { }); } - /* ── Apply reagent ───────────────────────────────────────────── */ - _applyReagent(reagentId) { - const ion = this._targetIon; + /* ── Apply reagent to tube ───────────────────────────────────── */ + _applyReagent(tubeIdx, reagentId) { + /* which ion is in this tube? */ + const isSample = tubeIdx === this._tubeCount; + let ion = null; + if (this._mode === 'train' && isSample) { + ion = this._targetIon; + } else if (this._mode === 'exam' && !isSample) { + ion = this._examIons[tubeIdx] || null; + } + /* if no assigned ion → free tube with blank reactions */ + /* still animate the drop but log "нет ионов" */ + if (!ion) { + /* animate drop only */ + const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId); + this._spawnDrop(tubeIdx, rInfo ? rInfo.color : '#FFF'); + if (window.LabFX) LabFX.sound.play('pour'); + return; + } + const rxn = ion.reactions[reagentId]; if (!rxn) return; const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId); const rLabel = rInfo ? rInfo.label : reagentId; + const ts = this._tubes[tubeIdx]; + const tp = this._tubeParticles[tubeIdx]; - /* sounds */ - if (window.LabFX) { - LabFX.sound.play(rxn.type === 'gas' ? 'fizz' : 'pour'); - } + if (window.LabFX) LabFX.sound.play(rxn.type === 'gas' ? 'fizz' : 'pour'); - /* update tube state */ + /* update tube visual state */ if (rxn.type === 'flame') { - this._tubeState.flameColor = rxn.color; - setTimeout(() => { this._tubeState.flameColor = null; this._drawTube(); }, 2000); + ts.flameColor = rxn.color; + ts.flameTimer = 2.0; } else if (rxn.type === 'precip' && rxn.color) { - this._tubeState.precipColor = rxn.color; - this._tubeState.precipH = 0; - this._precipParticles = this._spawnPrecipParticles(rxn.color); + ts.precipColor = rxn.color; + ts.precipH = 0; + tp.precip = this._spawnPrecipParticles(tubeIdx, rxn.color); } else if (rxn.type === 'solution' && rxn.color) { - this._tubeState.solColor = rxn.color; - } else if (rxn.type === 'gas' && rxn.color) { - this._tubeState.gasLabel = rxn.gasLabel || '↑'; - this._gasParticles = this._spawnGasParticles(rxn.color); + ts.solColor = rxn.color; + } else if (rxn.type === 'gas') { + ts.gasLabel = rxn.gasLabel || '↑'; + tp.gas = this._spawnGasParticles(tubeIdx, rxn.color || '#FFFFFF'); } - /* 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, - }); + this._spawnDrop(tubeIdx, rInfo ? rInfo.color : '#FFF'); - /* log */ - const entry = { reagent: rLabel, obs: rxn.obs, positive: rxn.positive, excess: rxn.excess || null }; + /* determine tube name for log */ + const isSampleTube = tubeIdx === this._tubeCount; + const tubeName = isSampleTube ? 'Образец' : ('Проб' + (tubeIdx + 1)); + + const entry = { + tubeIdx, + tubeName, + reagentId, + reagentLabel: rLabel, + reagentColor: rInfo ? rInfo.color : '#AAA', + obs: rxn.obs, + positive: rxn.positive, + excess: rxn.excess || null, + }; this._log.push(entry); this._renderLogEntry(entry); } - _spawnPrecipParticles(color) { - const cx = this._W * 0.5; - const cy = this._H * 0.55; + /* ── Particle spawners ───────────────────────────────────────── */ + _tubeCenter(tubeIdx) { + const { tubes } = this._tubeGeometry(); + const t = tubes[tubeIdx]; + if (!t) return { x: this._W / 2, tTop: 20, tBot: 200, tubeW: 70 }; + return { x: t.tx + t.tubeW / 2, tTop: t.tTop, tBot: t.tBot, tubeW: t.tubeW }; + } + + _spawnDrop(tubeIdx, color) { + const { x, tTop } = this._tubeCenter(tubeIdx); + const tp = this._tubeParticles[tubeIdx]; + if (!tp) return; + tp.drops.push({ x, y: tTop - 30, vy: 3, color, alpha: 1, done: false }); + } + + _spawnPrecipParticles(tubeIdx, color) { + const { x, tTop, tBot, tubeW } = this._tubeCenter(tubeIdx); + const cy = tTop + (tBot - tTop) * 0.5; const ps = []; - for (let i = 0; i < 32; i++) { + for (let i = 0; i < 28; i++) { ps.push({ - x: cx + (Math.random() - 0.5) * 80, + x: x + (Math.random() - 0.5) * tubeW * 0.7, y: cy, - vy: 0.6 + Math.random() * 2, - vx: (Math.random() - 0.5) * 2, + vy: 0.8 + Math.random() * 1.8, + vx: (Math.random() - 0.5) * 1.5, color, - r: 3 + Math.random() * 4, + r: 2.5 + Math.random() * 3, done: false, }); } return ps; } - _spawnGasParticles(color) { - const cx = this._W * 0.5; - const cy = this._H * 0.4; + _spawnGasParticles(tubeIdx, color) { + const { x, tTop, tBot, tubeW } = this._tubeCenter(tubeIdx); + const cy = tTop + (tBot - tTop) * 0.4; const ps = []; - for (let i = 0; i < 26; i++) { + for (let i = 0; i < 22; i++) { ps.push({ - x: cx + (Math.random() - 0.5) * 50, + x: x + (Math.random() - 0.5) * tubeW * 0.5, y: cy, - vy: -(1 + Math.random() * 1.8), - vx: (Math.random() - 0.5) * 1.2, + vy: -(0.8 + Math.random() * 1.6), + vx: (Math.random() - 0.5) * 1, color, - r: 4 + Math.random() * 5, - alpha: 0.9, + r: 3.5 + Math.random() * 4, + alpha: 0.85, done: false, }); } @@ -915,97 +1194,148 @@ class QualAnalysisSim { } /* ── Log rendering ───────────────────────────────────────────── */ + _clearLog() { + this._log = []; + const logEl = document.getElementById('qa-log'); + if (logEl) logEl.innerHTML = ''; + const hint = document.getElementById('qa-log-hint'); + if (hint) hint.style.display = ''; + } + _renderLogEntry(entry) { const hint = document.getElementById('qa-log-hint'); if (hint) hint.style.display = 'none'; - const log = document.getElementById('qa-log'); - const n = log.children.length + 1; - const col = entry.positive ? '#5EF08E' : 'rgba(255,255,255,0.4)'; + const logEl = document.getElementById('qa-log'); + if (!logEl) return; + + const col = entry.positive ? '#5EF08E' : 'rgba(255,255,255,0.35)'; const card = document.createElement('div'); card.style.cssText = [ - 'padding:9px 11px', - 'border-radius:10px', - `border-left:3px solid ${col}`, + 'padding:7px 11px', + 'border-radius:8px', + `border-left:3px solid ${entry.reagentColor}`, '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)', + 'border-top:1px solid rgba(255,255,255,0.06)', + 'border-right:1px solid rgba(255,255,255,0.06)', + 'border-bottom:1px solid rgba(255,255,255,0.06)', 'display:flex', - 'flex-direction:column', - 'gap:3px', + 'gap:8px', + 'align-items:flex-start', ].join(';'); - /* reagent row */ - const topRow = document.createElement('div'); - topRow.style.cssText = 'display:flex;align-items:center;gap:7px;font-size:.9rem'; + /* left part */ + const left = document.createElement('div'); + left.style.cssText = 'flex:1;min-width:0'; - 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 topLine = document.createElement('div'); + topLine.style.cssText = 'display:flex;align-items:center;gap:5px;flex-wrap:wrap;margin-bottom:3px'; - 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 tubeSpan = document.createElement('span'); + tubeSpan.style.cssText = 'font-size:.82rem;font-weight:700;color:rgba(255,255,255,0.6)'; + tubeSpan.textContent = entry.tubeName; - const arrowSpan = document.createElement('span'); - arrowSpan.style.cssText = 'color:rgba(255,255,255,0.35);font-size:.85rem'; - arrowSpan.textContent = '→'; + const plusSpan = document.createElement('span'); + plusSpan.style.cssText = 'font-size:.82rem;color:rgba(255,255,255,0.35)'; + plusSpan.textContent = '+'; - const dotSpan = document.createElement('span'); - dotSpan.style.cssText = `width:8px;height:8px;border-radius:50%;background:${col};flex-shrink:0;margin-left:auto`; + const rSpan = document.createElement('span'); + rSpan.style.cssText = `font-size:.9rem;font-weight:700;color:${entry.reagentColor}`; + rSpan.textContent = entry.reagentLabel; - topRow.appendChild(numSpan); - topRow.appendChild(reagentSpan); - topRow.appendChild(arrowSpan); - topRow.appendChild(dotSpan); - card.appendChild(topRow); + const arrSpan = document.createElement('span'); + arrSpan.style.cssText = 'font-size:.85rem;color:rgba(255,255,255,0.35)'; + arrSpan.textContent = '→'; + + topLine.appendChild(tubeSpan); + topLine.appendChild(plusSpan); + topLine.appendChild(rSpan); + topLine.appendChild(arrSpan); + left.appendChild(topLine); - /* 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.style.cssText = 'font-size:.88rem;color:rgba(255,255,255,0.85);line-height:1.4'; obsDiv.textContent = entry.obs; - card.appendChild(obsDiv); + left.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.style.cssText = 'font-size:.8rem;color:#FFD166;margin-top:2px'; exDiv.textContent = entry.excess; - card.appendChild(exDiv); + left.appendChild(exDiv); } - log.appendChild(card); - log.parentElement.scrollTop = log.parentElement.scrollHeight; + card.appendChild(left); + + /* badge */ + if (entry.positive) { + const badge = document.createElement('span'); + badge.style.cssText = [ + 'flex-shrink:0', + 'font-size:.75rem', + 'font-weight:700', + 'padding:2px 7px', + 'border-radius:10px', + 'background:rgba(94,240,142,0.18)', + 'color:#5EF08E', + 'border:1px solid rgba(94,240,142,0.35)', + 'margin-top:1px', + ].join(';'); + badge.textContent = 'pos'; + card.appendChild(badge); + } + + logEl.appendChild(card); + /* scroll to bottom */ + logEl.parentElement.scrollTop = logEl.parentElement.scrollHeight; } /* ── Submit answer ───────────────────────────────────────────── */ _submitAnswer() { if (this._answered) return; - const sel = document.getElementById('qa-answer-sel'); - const chosen = sel.value; - if (!chosen) return; - this._answered = true; + if (this._mode === 'free') return; + + const sel = document.getElementById('qa-answer-sel'); + const chosen = sel ? sel.value : ''; + if (!chosen) return; + + this._answered = true; + this._scoreTotal++; + + let correct = false; + let correctIon = null; + + if (this._mode === 'train') { + correctIon = this._targetIon; + correct = chosen === correctIon.id; + } else if (this._mode === 'exam') { + /* answer applies to active tube */ + correctIon = this._examIons[this._activeTube] || null; + correct = correctIon && chosen === correctIon.id; + } + + if (correct) this._score++; - const correct = chosen === this._targetIon.id; const verdict = document.getElementById('qa-verdict'); + if (!verdict) return; verdict.style.display = 'block'; + const scoreEl = document.getElementById('qa-score'); + if (scoreEl) scoreEl.textContent = this._score; + const totalEl = document.getElementById('qa-score-total'); + if (totalEl) totalEl.textContent = '/' + this._scoreTotal; + if (correct) { - this._score++; - document.getElementById('qa-score').textContent = this._score; - verdict.textContent = 'Верно! Это ' + this._targetIon.label; + verdict.textContent = 'Верно! Это ' + (correctIon ? correctIon.label : chosen); + verdict.style.cssText = verdict.style.cssText.replace(/display:[^;]+/, 'display:block'); verdict.style.background = 'rgba(94,240,142,0.15)'; verdict.style.color = '#5EF08E'; 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); + const label = correctIon ? correctIon.label : '?'; + verdict.textContent = 'Неверно — это ' + label; verdict.style.background = 'rgba(239,71,111,0.12)'; verdict.style.color = '#EF476F'; verdict.style.border = '1px solid rgba(239,71,111,0.35)'; @@ -1020,111 +1350,171 @@ class QualAnalysisSim { let needDraw = false; - this._dropAnim = this._dropAnim.filter(d => { - if (d.done) return false; - d.y += d.vy; - 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; - }); + /* update particles per tube */ + for (let i = 0; i < this._tubes.length; i++) { + const ts2 = this._tubes[i]; + const tp = this._tubeParticles[i]; + if (!tp) continue; - this._precipParticles.forEach(p => { - if (!p.done) { - p.x += p.vx; - p.y += p.vy; - p.vy *= 0.98; - 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); - } + /* flame timer */ + if (ts2.flameTimer > 0) { + ts2.flameTimer -= dt; + if (ts2.flameTimer <= 0) { ts2.flameColor = null; ts2.flameTimer = 0; } 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.6; - if (p.alpha <= 0 || p.y < 0) { p.done = true; return false; } - needDraw = true; - return true; - }); + /* drops */ + const { tBot } = this._tubeCenter(i); + const floor = tBot * 0.92; + tp.drops = tp.drops.filter(d => { + if (d.done) return false; + d.y += d.vy; + d.vy += 0.3; + if (d.y > floor) { d.done = true; needDraw = true; return false; } + needDraw = true; + return true; + }); - if (needDraw || this._tubeState.flameColor) this._drawTube(); + /* precip */ + const precipFloor = tBot - 4; + tp.precip.forEach(p => { + if (!p.done) { + p.x += p.vx; + p.y += p.vy; + p.vy *= 0.97; + if (p.y >= precipFloor) { + p.y = precipFloor; + p.vy = 0; p.vx = 0; + p.done = true; + ts2.precipH = Math.min(ts2.precipH + 2, 32); + } + needDraw = true; + } + }); + + /* gas */ + tp.gas = tp.gas.filter(p => { + if (p.done) return false; + p.y += p.vy; + p.x += p.vx; + p.alpha -= dt * 0.55; + if (p.alpha <= 0 || p.y < 0) { p.done = true; return false; } + needDraw = true; + return true; + }); + } + + if (needDraw) this._drawScene(); } /* ── Draw ────────────────────────────────────────────────────── */ - _drawTube() { - const ctx = this._ctx; - const W = this._W || 400; - const H = this._H || 400; + _drawScene() { + const ctx = this._tubeCtx; + const W = this._W || 500; + const H = this._H || 300; + if (!ctx) return; ctx.clearRect(0, 0, W, H); - /* background */ + /* dark background */ ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); - /* subtle bench surface */ - ctx.fillStyle = 'rgba(255,255,255,0.025)'; - ctx.fillRect(0, H * 0.88, W, H * 0.12); + /* bench surface */ + ctx.fillStyle = 'rgba(255,255,255,0.018)'; + ctx.fillRect(0, H * 0.84, W, H * 0.16); ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.beginPath(); - ctx.moveTo(0, H * 0.88); - ctx.lineTo(W, H * 0.88); + ctx.moveTo(0, H * 0.84); + ctx.lineTo(W, H * 0.84); ctx.stroke(); - /* 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 */ + const geom = this._tubeGeometry(); + const showSample = this._mode !== 'free'; - /* ── flame halo ── */ - if (this._tubeState.flameColor) { - const fc = this._tubeState.flameColor; - 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(); + for (const t of geom.tubes) { + this._drawOneTube(ctx, t, showSample); + } - /* flame label */ + /* clear-all button hint */ + if (showSample) { + const sampleT = geom.tubes[this._tubeCount]; + if (sampleT) { + const bx = sampleT.tx + sampleT.tubeW / 2; + const by = sampleT.tBot + 46; + ctx.save(); + ctx.font = '700 11px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('[Очистить все]', bx, by); + ctx.restore(); + this._clearBtnRect = { x: bx - 46, y: by - 9, w: 92, h: 18 }; + } + } + } + + _drawOneTube(ctx, t, showSample) { + const { i, tx, tubeW, tTop, tBot, isSample } = t; + const H = this._H || 300; + const tH = tBot - tTop; + const rBot = tubeW * 0.5; + const ts2 = this._tubes[i]; + const tp = this._tubeParticles[i]; + if (!ts2 || !tp) return; + + const isActive = i === this._activeTube; + + /* active highlight glow */ + if (isActive) { ctx.save(); - ctx.font = 'bold 14px Manrope,sans-serif'; - ctx.fillStyle = fc; - ctx.textAlign = 'center'; - 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.shadowColor = '#4CC9F0'; + ctx.shadowBlur = 18; + ctx.strokeStyle = '#4CC9F088'; + ctx.lineWidth = 2; + ctx.strokeRect(tx - 5, tTop - 5, tubeW + 10, tH + 10 + rBot); ctx.restore(); } - /* solution fill path helper */ + /* sample highlight (gold) */ + if (isSample) { + ctx.save(); + ctx.shadowColor = '#FFD166'; + ctx.shadowBlur = 20; + ctx.strokeStyle = '#FFD16688'; + ctx.lineWidth = 2; + ctx.strokeRect(tx - 7, tTop - 7, tubeW + 14, tH + 14 + rBot); + ctx.restore(); + } + + /* flame halo */ + if (ts2.flameColor) { + const fc = ts2.flameColor; + const gx = tx + tubeW / 2; + const gy = tTop - 28; + const gr = ctx.createRadialGradient(gx, gy, 5, gx, gy, 80); + gr.addColorStop(0, fc + 'CC'); + gr.addColorStop(0.4, fc + '44'); + gr.addColorStop(1, fc + '00'); + ctx.fillStyle = gr; + ctx.beginPath(); + ctx.arc(gx, gy, 80, 0, Math.PI * 2); + ctx.fill(); + + ctx.save(); + ctx.font = 'bold 11px Manrope,sans-serif'; + ctx.fillStyle = fc; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const flameLabel = this._flameLabel(fc); + ctx.fillText(flameLabel, gx, gy - 44); + ctx.restore(); + } + + /* tube fill path helper */ const tubePath = (fromY, toY) => { - const arcR = Math.min(rBot, (toY - fromY) * 0.5); + const arcR = Math.min(rBot, Math.max(2, (toY - fromY) * 0.5)); ctx.beginPath(); ctx.moveTo(tx, fromY); ctx.lineTo(tx, toY - arcR); @@ -1136,40 +1526,40 @@ class QualAnalysisSim { /* solution */ const solTop = tTop + tH * 0.06; - const solBot = tBot; - ctx.fillStyle = this._tubeState.solColor || 'rgba(100,180,255,0.18)'; - tubePath(solTop, solBot); + ctx.fillStyle = ts2.solColor || 'rgba(100,180,255,0.15)'; + tubePath(solTop, tBot); ctx.fill(); /* precipitate layer */ - if (this._tubeState.precipColor && this._tubeState.precipH > 0) { - const ph = this._tubeState.precipH; - const py = solBot - ph; - ctx.globalAlpha = 0.88; - ctx.fillStyle = this._tubeState.precipColor; - tubePath(py, solBot); + if (ts2.precipColor && ts2.precipH > 0) { + const ph = ts2.precipH; + ctx.globalAlpha = 0.85; + ctx.fillStyle = ts2.precipColor; + tubePath(tBot - ph, tBot); ctx.fill(); ctx.globalAlpha = 1; - /* label when settled */ - if (this._precipParticles.every(p => p.done)) { - const lastPrecip = [...this._log].reverse().find(l => { - 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 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) { + /* precip label when settled */ + if (tp.precip.every(p => p.done)) { + const lastPrecipEntry = [...this._log].reverse().find(l => + l.tubeIdx === i && (() => { + const rx2 = this._getIonForTube(i); + if (!rx2) return false; + const rxn = rx2.reactions[l.reagentId]; + return rxn && rxn.type === 'precip' && rxn.precipLabel; + })() + ); + if (lastPrecipEntry) { + const ion2 = this._getIonForTube(i); + const rxn2 = ion2 ? ion2.reactions[lastPrecipEntry.reagentId] : null; + if (rxn2 && rxn2.precipLabel) { ctx.save(); - ctx.font = 'bold 13px Manrope,sans-serif'; - const labelCol = this._tubeState.precipColor === '#111111' ? '#888' : this._tubeState.precipColor; - ctx.fillStyle = labelCol; + ctx.font = 'bold 11px Manrope,sans-serif'; + const lc = ts2.precipColor === '#111111' ? '#666' : ts2.precipColor; + ctx.fillStyle = lc; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(rx2.precipLabel, W * 0.5, solBot - ph * 0.5); + ctx.fillText(rxn2.precipLabel, tx + tubeW / 2, tBot - ph / 2); ctx.restore(); } } @@ -1177,21 +1567,21 @@ class QualAnalysisSim { } /* drop particles */ - this._dropAnim.forEach(d => { + tp.drops.forEach(d => { ctx.globalAlpha = d.alpha; ctx.fillStyle = d.color; ctx.beginPath(); - ctx.arc(d.x, d.y, 6, 0, Math.PI * 2); + ctx.arc(d.x, d.y, 5, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = d.color + '77'; + ctx.fillStyle = d.color + '66'; ctx.beginPath(); - ctx.arc(d.x, d.y - 10, 4, 0, Math.PI * 2); + ctx.arc(d.x, d.y - 8, 3, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; }); - /* precipitate flying particles */ - this._precipParticles.filter(p => !p.done).forEach(p => { + /* precip flying */ + tp.precip.filter(p => !p.done).forEach(p => { ctx.globalAlpha = 0.8; ctx.fillStyle = p.color; ctx.beginPath(); @@ -1201,10 +1591,10 @@ class QualAnalysisSim { }); /* gas bubbles */ - this._gasParticles.forEach(p => { + tp.gas.forEach(p => { ctx.globalAlpha = p.alpha; ctx.strokeStyle = p.color; - ctx.lineWidth = 2; + ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.stroke(); @@ -1212,22 +1602,22 @@ class QualAnalysisSim { }); /* gas label */ - if (this._tubeState.gasLabel) { + if (ts2.gasLabel && tp.gas.length > 0) { ctx.save(); - ctx.font = 'bold 15px Manrope,sans-serif'; + ctx.font = 'bold 11px Manrope,sans-serif'; ctx.fillStyle = '#FFFFAA'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; - ctx.fillText(this._tubeState.gasLabel, tx + tubeW + 12, tTop + tH * 0.2); + ctx.fillText(ts2.gasLabel, tx + tubeW + 5, tTop + tH * 0.2); ctx.restore(); } - /* ── tube glass outline ── */ + /* 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.shadowColor = isSample ? 'rgba(255,209,102,0.25)' : 'rgba(155,93,229,0.25)'; + ctx.shadowBlur = 10; + ctx.strokeStyle = isSample ? 'rgba(255,209,102,0.75)' : 'rgba(200,215,255,0.6)'; + ctx.lineWidth = isSample ? 2.5 : 2; ctx.beginPath(); ctx.moveTo(tx, tTop); ctx.lineTo(tx, tBot - rBot); @@ -1238,40 +1628,68 @@ class QualAnalysisSim { ctx.restore(); /* rim */ - ctx.strokeStyle = 'rgba(200,215,255,0.35)'; - ctx.lineWidth = 2; + ctx.strokeStyle = isSample ? 'rgba(255,209,102,0.4)' : 'rgba(200,215,255,0.3)'; + ctx.lineWidth = 1.5; ctx.beginPath(); - ctx.moveTo(tx - 5, tTop); - ctx.lineTo(tx + tubeW + 5, tTop); + ctx.moveTo(tx - 4, tTop); + ctx.lineTo(tx + tubeW + 4, tTop); ctx.stroke(); /* glass shine */ - const shine = ctx.createLinearGradient(tx, 0, tx + tubeW * 0.4, 0); - shine.addColorStop(0, 'rgba(255,255,255,0.12)'); + const shine = ctx.createLinearGradient(tx, 0, tx + tubeW * 0.38, 0); + shine.addColorStop(0, 'rgba(255,255,255,0.10)'); shine.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = shine; ctx.beginPath(); - 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.moveTo(tx + 3, tTop + 3); + ctx.lineTo(tx + tubeW * 0.30, tTop + 3); + ctx.lineTo(tx + tubeW * 0.30, tBot - tubeW * 0.22); + ctx.lineTo(tx + 3, tBot - tubeW * 0.22); ctx.closePath(); ctx.fill(); - /* mode watermark */ + /* label below tube */ + const labelY = tBot + 20; ctx.save(); - ctx.font = '700 11px Manrope,sans-serif'; - ctx.fillStyle = 'rgba(155,93,229,0.35)'; + ctx.font = '700 12px Manrope,sans-serif'; ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText( - this._mode === 'identify' ? 'РЕЖИМ: ОПРЕДЕЛИТЬ ИОН' : 'РЕЖИМ: НЕИЗВЕСТНЫЙ РАСТВОР', - W * 0.5, - H - 4 - ); + ctx.textBaseline = 'middle'; + if (isSample) { + ctx.fillStyle = '#FFD166'; + ctx.fillText('Образец', tx + tubeW / 2, labelY); + if (this._mode === 'train') { + ctx.font = '600 10px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,209,102,0.6)'; + ctx.fillText('(?)', tx + tubeW / 2, labelY + 14); + } + } else { + ctx.fillStyle = isActive ? '#4CC9F0' : 'rgba(255,255,255,0.65)'; + ctx.fillText('Проб' + (i + 1), tx + tubeW / 2, labelY); + if (this._mode === 'exam') { + ctx.font = '600 10px Manrope,sans-serif'; + ctx.fillStyle = isActive ? 'rgba(76,201,240,0.65)' : 'rgba(255,255,255,0.35)'; + ctx.fillText('(?)', tx + tubeW / 2, labelY + 14); + } + } ctx.restore(); } + _getIonForTube(tubeIdx) { + const isSample = tubeIdx === this._tubeCount; + if (this._mode === 'train' && isSample) return this._targetIon; + if (this._mode === 'exam' && !isSample) return this._examIons[tubeIdx] || null; + return null; + } + + _flameLabel(fc) { + if (fc === '#FFD700') return 'Жёлтое — Na⁺'; + if (fc === '#CC00FF') return 'Фиолет. — K⁺'; + if (fc === '#CC4400') return 'Красное — Ca²⁺'; + if (fc === '#00DD00') return 'Зелёное — Ba²⁺'; + if (fc === '#00BB44') return 'Зелёное — Cu²⁺'; + return 'Окрашивание пламени'; + } + /* ── Public API ──────────────────────────────────────────────── */ stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } @@ -1279,6 +1697,7 @@ class QualAnalysisSim { destroy() { this.stop(); + this._container.innerHTML = ''; } } diff --git a/frontend/js/labs/stoichiometry.js b/frontend/js/labs/stoichiometry.js index cdaf235..a1c7e7b 100644 --- a/frontend/js/labs/stoichiometry.js +++ b/frontend/js/labs/stoichiometry.js @@ -2,7 +2,7 @@ /* ═══════════════════════════════════════════════════════════════════════ StoichSim — «Стехиометрия» - Wizard UX: 4 шага — Выбор реакции → Количества → Лимит → Продукты + Single-page dashboard: селектор + реагенты + продукты + канва + итоги ═══════════════════════════════════════════════════════════════════════ */ class StoichSim { @@ -13,8 +13,8 @@ class StoichSim { 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' }, + { 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' }, @@ -129,25 +129,33 @@ class StoichSim { /* ── Конструктор ─────────────────────────────────────────────────── */ constructor(container) { - 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._container = container; + this._recipeIdx = 0; + this._amounts = []; + this._inputMode = []; + 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._initAmounts(); this._build(); } - /* ── Построение оболочки wizard ─────────────────────────────────── */ + /* ── Инициализация начальных количеств ───────────────────────────── */ + _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 = ''; @@ -160,351 +168,602 @@ class StoichSim { 'font-family:Manrope,sans-serif', ].join(';'); - /* step indicator */ - this._stepBar = _stEl('div', { + /* 1. Topbar */ + this._topbar = _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)', + '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._stepBar); + c.appendChild(this._topbar); - /* content area */ - this._content = _stEl('div', { - style: 'flex:1 1 auto;overflow-y:auto;overflow-x:hidden;padding:20px;', + /* 2. Основная двухколоночная область */ + this._mainArea = _stEl('div', { + style: [ + 'display:flex', + 'flex:1 1 auto', + 'overflow:hidden', + 'min-height:0', + ].join(';'), }); - c.appendChild(this._content); + c.appendChild(this._mainArea); - this._renderStep(); + /* 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(); } - /* ── Step indicator ─────────────────────────────────────────────── */ - _renderStepBar() { - const bar = this._stepBar; + /* ── Topbar ─────────────────────────────────────────────────────── */ + _renderTopbar() { + const bar = this._topbar; bar.innerHTML = ''; - const steps = [ - 'Реакция', - 'Количества', - 'Лимит', - 'Продукты', - ]; - steps.forEach((label, idx) => { - const num = idx + 1; - const active = num === this._step; - const done = num < this._step; - /* 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), + /* Селектор реакции */ + 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(); + }; - /* 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(';'), + modes.forEach(([m, label]) => { + const btn = _stEl('button', { + style: this._pillBtnStyle(this._inputMode[i] === m), textContent: label, }); - - const item = _stEl('div', { - style: 'display:flex;align-items:center;', - }); - item.appendChild(circle); - item.appendChild(lbl); - bar.appendChild(item); - - /* 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(';'), - })); - } + btn.addEventListener('click', () => updateMode(m)); + modeButtons[m] = btn; + pill.appendChild(btn); }); - } + row1.appendChild(pill); + card.appendChild(row1); - /* ── Главный рендер текущего шага ───────────────────────────────── */ - _renderStep() { - this._renderStepBar(); - const c = this._content; - c.innerHTML = ''; + /* Строка 2: ползунок + значение */ + const row2 = _stEl('div', { + style: 'display:flex;align-items:center;gap:8px;', + }); - 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); - } + 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;'; - /* ══════════════════════════════════════════════════════════════════ - ШАГ 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', { + const valEl = _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', + '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), }); - const eqDisplay = _stEl('div', { - style: 'font-size:1.35rem;color:rgba(255,255,255,0.95);word-break:break-word;letter-spacing:.02em;', + + 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(); }); - eqCard.appendChild(eqDisplay); - c.appendChild(eqCard); - const updateEq = () => { - eqDisplay.textContent = StoichSim.RECIPES[this._recipeIdx].name; - }; - updateEq(); + row2.appendChild(sliderEl); + row2.appendChild(valEl); + card.appendChild(row2); - /* грид карточек */ - const grid = _stEl('div', { + /* Бейдж лимита (скрытый изначально) */ + const limitBadge = _stEl('div', { style: [ - 'display:grid', - 'grid-template-columns:repeat(auto-fill,minmax(210px,1fr))', - 'gap:10px', - 'margin-bottom:24px', + '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); - 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())); + return { el: card, limitBadge, sliderEl, valEl, re, i }; } - _recipeCardStyle(selected) { + /* ── Pill-кнопка стиль ───────────────────────────────────────────── */ + _pillBtnStyle(active) { 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]; - - 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 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:12px;margin-bottom:12px;' }); - head.appendChild(_stEl('div', { - style: `font-size:1.3rem;font-weight:800;color:${re.color};`, - textContent: re.sym, - })); - 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); - }; - - modes.forEach(([m, label]) => { - const btn = _stEl('button', { - style: this._modeBtnStyle(this._inputMode[i] === m), - textContent: label, - }); - btn.setAttribute('data-mode-btn', m); - btn.addEventListener('click', () => updateMode(m)); - modeRow.appendChild(btn); - }); - card.appendChild(modeRow); - - /* ползунок */ - 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 valEl = _stEl('div', { - style: 'font-size:1.1rem;font-weight:700;color:#FFD166;text-align:right;', - 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); - }); - - card.appendChild(sliderEl); - card.appendChild(valEl); - - /* запомнить ссылки на элементы для 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', + 'padding:3px 8px', + 'font-size:0.78rem', 'font-weight:600', 'cursor:pointer', - 'border:1px solid', + 'border:none', 'font-family:Manrope,sans-serif', - 'transition:background .15s', + 'transition:background .12s', 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)', + ? '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: 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), @@ -516,412 +775,20 @@ class StoichSim { _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; + 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) + ' л'; - } + 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)')); - } - }); - - 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); - }); - 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(';'), - }); - - 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]; @@ -995,32 +862,35 @@ class StoichSim { ]; const N = allSubs.length; - const gap = 12; - const boxW = Math.min(Math.floor((W - (N + 1) * gap) / N), 120); - const boxH = Math.min(H - 30, 140); + 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 - 8; + const topY = (H - boxH) / 2 - 6; const sepIdx = r.reactants.length; - const animT = this._animState === 'reacting' ? this._animT : (this._animState === 'done' ? 1 : 0); + 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; ctx.save(); - ctx.strokeStyle = `rgba(255,255,255,${0.25 + animT * 0.5})`; - ctx.lineWidth = 2; - const ax = x - gap * 0.5; + ctx.strokeStyle = `rgba(255,255,255,${0.3 + animT * 0.5})`; + ctx.lineWidth = 2.5; ctx.beginPath(); - ctx.moveTo(ax - 10, topY + boxH / 2); - ctx.lineTo(ax, topY + boxH / 2); + ctx.moveTo(arrowX - 14, midY); + ctx.lineTo(arrowX + 2, midY); ctx.stroke(); ctx.beginPath(); - ctx.moveTo(ax - 6, topY + boxH / 2 - 5); - ctx.lineTo(ax, topY + boxH / 2); - ctx.lineTo(ax - 6, topY + boxH / 2 + 5); + ctx.moveTo(arrowX - 6, midY - 6); + ctx.lineTo(arrowX + 2, midY); + ctx.lineTo(arrowX - 6, midY + 6); ctx.stroke(); ctx.restore(); } @@ -1039,16 +909,16 @@ class StoichSim { ctx.strokeStyle = borderColor; ctx.lineWidth = isLimit ? 2 : 1; ctx.beginPath(); - _stRoundRect(ctx, x, y, bw, bh, 6); + _stRoundRect(ctx, x, y, bw, bh, 7); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.03)'; ctx.fill(); ctx.fillStyle = sub.color; - ctx.font = 'bold 12px Manrope,sans-serif'; + ctx.font = 'bold 13px Manrope,sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(sub.sym, x + bw / 2, y + 17); + ctx.fillText(sub.sym, x + bw / 2, y + 18); const maxParticles = 20; const nParticles = isReactant @@ -1056,9 +926,9 @@ class StoichSim { : Math.max(1, Math.round(Math.min(q.n / 0.2, 1) * maxParticles)); const areaX = x + 8; - const areaY = y + 24; + const areaY = y + 26; const areaW = bw - 16; - const areaH = bh - 42; + const areaH = bh - 46; const seed = sub.sym.split('').reduce((a, ch) => a + ch.charCodeAt(0), 0); const pts = []; @@ -1079,7 +949,7 @@ class StoichSim { 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.arc(px + jx, py + jy, 4.5, 0, Math.PI * 2); ctx.fillStyle = sub.color; ctx.fill(); ctx.globalAlpha = alpha * 0.5; @@ -1091,12 +961,12 @@ class StoichSim { ctx.globalAlpha = 1; const phaseText = sub.phase === 'g' ? '(г)' : sub.phase === 'aq' ? '(р-р)' : sub.phase === 'l' ? '(ж)' : '(тв)'; - ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.font = '9px Manrope,sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(phaseText, x + bw / 2, y + bh - 16); + ctx.fillText(phaseText, x + bw / 2, y + bh - 18); - ctx.fillStyle = 'rgba(255,214,102,0.85)'; + ctx.fillStyle = 'rgba(255,214,102,0.9)'; ctx.font = 'bold 9px Manrope,sans-serif'; ctx.textAlign = 'right'; ctx.fillText(q.m.toFixed(2) + 'г', x + bw - 4, y + bh - 6); @@ -1109,6 +979,12 @@ class StoichSim { 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; @@ -1140,6 +1016,18 @@ class StoichSim { } 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); @@ -1153,7 +1041,7 @@ class StoichSim { destroy() { if (this._raf) cancelAnimationFrame(this._raf); - if (this._ro) this._ro.disconnect(); + if (this._ro) this._ro.disconnect(); this._canvas = null; this._ctx = null; } @@ -1188,22 +1076,6 @@ function _stLcg(seed) { return ((a * seed + c) % m) / m; } -function _stKatex(el, formula, displayMode) { - if (window.katex) { - try { - el.innerHTML = katex.renderToString(formula, { - throwOnError: false, - displayMode: displayMode === true, - }); - return; - } catch (e) { /* fallback */ } - } - el.textContent = formula; - el.style.fontFamily = 'monospace'; - el.style.fontSize = '.85rem'; - el.style.color = 'rgba(255,255,255,0.8)'; -} - /* ═══════════════════════════════════════════════════════════════════ Lab UI init ═══════════════════════════════════════════════════════════════════ */