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