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 = [
+ 'Как пользоваться',
+ '
',
+ '- Перетащи реагент в пробирку или нажми реагент (активная пробирка выделена синим)
',
+ '- Наблюдай реакцию на холсте и читай журнал
',
+ '- Свободно — просто эксперименты, нет задания
',
+ '- Тренировка — один неизвестный ион в Образце, определи его
',
+ '- Экзамен — 4 неизвестных иона (по одному в каждой пробирке), без подсказок
',
+ '- Используй пробирки Проб1-4 как вспомогательные для сравнения
',
+ '
',
+ ].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
═══════════════════════════════════════════════════════════════════ */