fix(labs): таблица Менделеева + UX качественных реакций + анимации стехиометрии

- Менделеев: clamp() для font-size символа элемента (2.4rem..4.4rem) + padding-top 28px → символ не обрезается на узких панелях
- Качественные реакции: в Свободно/Тренировке Проб1-4 содержат известные ионы (видна подпись), в Тренировке Образец — отдельный неизвестный; в Экзамене можно переключаться между пробирками и ответить отдельно для каждой (verdict сохраняется)
- Стехиометрия: непрерывный анимационный цикл — волна на поверхности жидкости, пузырьки в газах/растворах, пульсирующая красная рамка + ЛИМИТ-лейбл у лимитирующего реагента, искры вдоль стрелки реакции, glow на стрелке во время реакции
This commit is contained in:
Maxim Dolgolyov
2026-05-26 16:26:10 +03:00
parent 4dce6d0d8f
commit be1e558be9
3 changed files with 254 additions and 73 deletions
+81 -28
View File
@@ -949,6 +949,25 @@ class QualAnalysisSim {
const idx = this._hitTestTube(x, y);
if (idx >= 0) {
this._activeTube = idx;
/* in exam: switching tube allows re-submit; verdict resets */
if (this._mode === 'exam') {
const alreadyAnswered = this._examAnswered && this._examAnswered[idx];
this._answered = !!alreadyAnswered;
const verdict = document.getElementById('qa-verdict');
if (verdict) {
if (alreadyAnswered) {
verdict.style.display = 'block';
verdict.textContent = alreadyAnswered.text;
verdict.style.background = alreadyAnswered.bg;
verdict.style.color = alreadyAnswered.fg;
verdict.style.border = alreadyAnswered.border;
} else {
verdict.style.display = 'none';
verdict.textContent = '';
}
}
}
this._updateTaskText();
this._drawScene();
}
});
@@ -979,6 +998,7 @@ class QualAnalysisSim {
_startMode(mode) {
this._mode = mode;
this._answered = false;
this._examAnswered = {}; // per-tube answered tracking for exam
this._log = [];
this._dragReagent = null;
this._pendingReagent = null;
@@ -991,27 +1011,44 @@ class QualAnalysisSim {
const ions = QualAnalysisSim.IONS;
/* pick random ions */
/* pick known ions for helper tubes (visible labels) */
this._helperIons = [];
const shuffled = ions.slice().sort(() => Math.random() - 0.5);
for (let i = 0; i < this._tubeCount; i++) {
this._helperIons.push(shuffled[i % shuffled.length]);
}
/* pick random ions per mode */
if (mode === 'train') {
this._targetIon = ions[Math.floor(Math.random() * ions.length)];
/* pick target distinct from helpers if possible */
const helperIds = new Set(this._helperIons.map(h => h.id));
const pool = ions.filter(i => !helperIds.has(i.id));
const src = pool.length > 0 ? pool : ions;
this._targetIon = src[Math.floor(Math.random() * src.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]);
}
/* exam: helper tubes hold unknown ions, no sample */
this._examIons = this._helperIons.slice();
this._helperIons = []; // hide helper labels in exam (they're unknown)
this._targetIon = null;
} else {
/* free mode: helpers have known ions, no sample */
this._targetIon = null;
this._examIons = [];
}
/* reset tubes */
this._resetTubes();
/* paint helper tubes with their ion colors (free + train modes) */
if (mode !== 'exam') {
for (let i = 0; i < this._tubeCount; i++) {
if (this._helperIons[i]) {
this._tubes[i].solColor = this._helperIons[i].solColor || 'rgba(100,180,255,0.15)';
}
}
}
/* 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 */
@@ -1051,11 +1088,12 @@ class QualAnalysisSim {
const el = document.getElementById('qa-task');
if (!el) return;
if (this._mode === 'free') {
el.textContent = 'Свободный режим — добавляй реагенты в пробирки и наблюдай реакции';
el.textContent = 'Свободно: в каждой пробирке известный ион (см. подпись) — пробуй реагенты, изучай реакции';
} else if (this._mode === 'train') {
el.textContent = 'Тренировка: определи неизвестный ион в Образце';
el.textContent = 'Тренировка: определи ион в Образце (золотая рамка). В Проб1–4 — известные ионы для сравнения';
} else {
el.textContent = 'Экзамен: определи неизвестный ион в каждой пробирке (выбери пробирку, затем дай ответ)';
const ansFor = 'Проб' + (this._activeTube + 1);
el.textContent = 'Экзамен: в каждой пробирке свой неизвестный ион. Кликни на пробирку → определи реакциями → ответь. Сейчас отвечаешь для: ' + ansFor;
}
}
@@ -1080,12 +1118,7 @@ class QualAnalysisSim {
_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;
}
let ion = this._getIonForTube(tubeIdx);
/* if no assigned ion → free tube with blank reactions */
/* still animate the drop but log "нет ионов" */
if (!ion) {
@@ -1326,19 +1359,30 @@ class QualAnalysisSim {
const totalEl = document.getElementById('qa-score-total');
if (totalEl) totalEl.textContent = '/' + this._scoreTotal;
let vText, vBg, vFg, vBorder;
if (correct) {
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)';
vText = 'Верно! Это ' + (correctIon ? correctIon.label : chosen);
vBg = 'rgba(94,240,142,0.15)';
vFg = '#5EF08E';
vBorder = '1px solid rgba(94,240,142,0.35)';
if (window.LabFX) LabFX.sound.play('chime');
} else {
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)';
vText = 'Неверно — это ' + label;
vBg = 'rgba(239,71,111,0.12)';
vFg = '#EF476F';
vBorder = '1px solid rgba(239,71,111,0.35)';
}
verdict.textContent = vText;
verdict.style.background = vBg;
verdict.style.color = vFg;
verdict.style.border = vBorder;
verdict.style.display = 'block';
/* exam: remember verdict per tube; allow switching to next */
if (this._mode === 'exam') {
this._examAnswered = this._examAnswered || {};
this._examAnswered[this._activeTube] = { text: vText, bg: vBg, fg: vFg, border: vBorder, correct };
}
}
@@ -1663,12 +1707,20 @@ class QualAnalysisSim {
ctx.fillText('(?)', tx + tubeW / 2, labelY + 14);
}
} else {
ctx.fillStyle = isActive ? '#4CC9F0' : 'rgba(255,255,255,0.65)';
ctx.fillStyle = isActive ? '#4CC9F0' : 'rgba(255,255,255,0.78)';
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.fillStyle = isActive ? 'rgba(76,201,240,0.85)' : 'rgba(255,255,255,0.45)';
ctx.fillText('(?)', tx + tubeW / 2, labelY + 14);
} else {
/* show known ion label in free / train modes */
const knownIon = (this._helperIons || [])[i];
if (knownIon) {
ctx.font = '700 11px Manrope,sans-serif';
ctx.fillStyle = isActive ? '#4CC9F0' : 'rgba(255,255,255,0.6)';
ctx.fillText(knownIon.label, tx + tubeW / 2, labelY + 14);
}
}
}
ctx.restore();
@@ -1678,6 +1730,7 @@ class QualAnalysisSim {
const isSample = tubeIdx === this._tubeCount;
if (this._mode === 'train' && isSample) return this._targetIon;
if (this._mode === 'exam' && !isSample) return this._examIons[tubeIdx] || null;
if (this._mode !== 'exam' && !isSample) return (this._helperIons || [])[tubeIdx] || null;
return null;
}