diff --git a/frontend/css/lab.css b/frontend/css/lab.css index 2de00b2..b4471c9 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -1634,25 +1634,28 @@ canvas[data-draggable]:active { cursor: grabbing; } .ptbl-hero { position: relative; text-align: center; - padding: 14px 10px 10px; + padding: 28px 10px 12px; border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0; + overflow: hidden; } .ptbl-hero-z { position: absolute; top: 8px; left: 10px; - font-size: .72rem; + font-size: .82rem; font-weight: 700; - color: rgba(255,255,255,0.35); + color: rgba(255,255,255,0.55); line-height: 1; } .ptbl-hero-sym { - font-size: 6rem; + font-size: clamp(2.4rem, 14vw, 4.4rem); font-weight: 900; color: var(--el-col, #7B8EF7); - line-height: 1; - letter-spacing: -2px; + line-height: 1.05; + letter-spacing: -1px; + display: block; + overflow: visible; } .ptbl-hero-name { font-size: 1rem; diff --git a/frontend/js/labs/qualanalysis.js b/frontend/js/labs/qualanalysis.js index 76500bf..7cb1e17 100644 --- a/frontend/js/labs/qualanalysis.js +++ b/frontend/js/labs/qualanalysis.js @@ -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; } diff --git a/frontend/js/labs/stoichiometry.js b/frontend/js/labs/stoichiometry.js index a1c7e7b..df5d829 100644 --- a/frontend/js/labs/stoichiometry.js +++ b/frontend/js/labs/stoichiometry.js @@ -137,6 +137,10 @@ class StoichSim { this._animState = 'idle'; this._animT = 0; this._raf = null; + this._idleRaf = null; + this._idleT = 0; // continuous time for wobble/bubbles + this._lastIdleTs = 0; + this._sliderPulse = 0; // 0..1, decays after slider change this._canvas = null; this._ctx = null; this._ro = null; @@ -145,6 +149,25 @@ class StoichSim { this._initAmounts(); this._build(); + this._startIdleLoop(); + } + + /* ── Непрерывный анимационный цикл (волны, пузырьки) ───────────── */ + _startIdleLoop() { + if (this._idleRaf) return; + this._lastIdleTs = performance.now(); + const loop = (now) => { + const dt = Math.min((now - this._lastIdleTs) / 1000, 0.05); + this._lastIdleTs = now; + this._idleT += dt; + if (this._sliderPulse > 0) this._sliderPulse = Math.max(0, this._sliderPulse - dt * 2); + /* skip redraw if reaction animation owns RAF */ + if (this._animState !== 'reacting' && this._ctx) { + this._draw(); + } + this._idleRaf = requestAnimationFrame(loop); + }; + this._idleRaf = requestAnimationFrame(loop); } /* ── Инициализация начальных количеств ───────────────────────────── */ @@ -880,19 +903,39 @@ class StoichSim { if (k === sepIdx) { const arrowX = x - gap * 0.5; const midY = topY + boxH / 2; + const t = this._idleT || 0; + const reacting = this._animState === 'reacting'; ctx.save(); - ctx.strokeStyle = `rgba(255,255,255,${0.3 + animT * 0.5})`; - ctx.lineWidth = 2.5; + if (reacting) { + ctx.shadowColor = '#9B5DE5'; + ctx.shadowBlur = 12 + Math.sin(t * 12) * 6; + } + const alpha = reacting ? 0.85 + 0.15 * Math.sin(t * 12) : 0.35 + animT * 0.5; + ctx.strokeStyle = `rgba(255,255,255,${alpha})`; + ctx.lineWidth = reacting ? 3.2 : 2.5; ctx.beginPath(); - ctx.moveTo(arrowX - 14, midY); + ctx.moveTo(arrowX - 18, midY); ctx.lineTo(arrowX + 2, midY); ctx.stroke(); ctx.beginPath(); - ctx.moveTo(arrowX - 6, midY - 6); + ctx.moveTo(arrowX - 7, midY - 7); ctx.lineTo(arrowX + 2, midY); - ctx.lineTo(arrowX - 6, midY + 6); + ctx.lineTo(arrowX - 7, midY + 7); ctx.stroke(); ctx.restore(); + /* sparks travelling along the arrow during reaction */ + if (reacting) { + for (let s = 0; s < 3; s++) { + const sp = ((t * 1.5 + s * 0.33) % 1); + const sx = arrowX - 18 + sp * 20; + ctx.save(); + ctx.fillStyle = `rgba(255,209,102,${1 - sp})`; + ctx.beginPath(); + ctx.arc(sx, midY, 2.4, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + } } const isLimit = isReactant && i === comp.limitIdx; @@ -903,74 +946,153 @@ class StoichSim { _drawBeaker(ctx, x, y, bw, bh, sub, q, isReactant, isLimit, animT) { ctx.save(); + const t = this._idleT || 0; + + /* Pulsing border for limit */ + const pulse = isLimit ? (0.5 + 0.5 * Math.sin(t * 3.2)) : 0; const borderColor = isLimit - ? `rgba(239,71,111,${0.45 + animT * 0.4})` - : 'rgba(255,255,255,0.12)'; + ? `rgba(239,71,111,${0.55 + 0.35 * pulse + animT * 0.3})` + : 'rgba(255,255,255,0.14)'; ctx.strokeStyle = borderColor; - ctx.lineWidth = isLimit ? 2 : 1; + ctx.lineWidth = isLimit ? 2 + pulse * 0.6 : 1; ctx.beginPath(); _stRoundRect(ctx, x, y, bw, bh, 7); ctx.stroke(); + /* Glow for limit */ + if (isLimit) { + ctx.save(); + ctx.shadowColor = '#EF476F'; + ctx.shadowBlur = 14 + pulse * 8; + ctx.strokeStyle = 'rgba(239,71,111,0)'; + ctx.beginPath(); + _stRoundRect(ctx, x, y, bw, bh, 7); + ctx.stroke(); + ctx.restore(); + } + ctx.fillStyle = 'rgba(255,255,255,0.03)'; ctx.fill(); + /* Header symbol */ ctx.fillStyle = sub.color; ctx.font = 'bold 13px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText(sub.sym, x + bw / 2, y + 18); - 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(Math.min(q.n / 0.2, 1) * maxParticles)); - const areaX = x + 8; const areaY = y + 26; const areaW = bw - 16; const areaH = bh - 46; - const seed = sub.sym.split('').reduce((a, ch) => a + ch.charCodeAt(0), 0); - const pts = []; - for (let p = 0; p < maxParticles; p++) { - pts.push([ - areaX + _stLcg(seed + p * 7) * areaW, - areaY + _stLcg(seed + p * 7 + 3) * areaH, - ]); + /* Liquid level: scales with amount (reactants: m; products: n) */ + let fillFrac; + if (isReactant) { + fillFrac = Math.min(1, q.n / 0.25 + 0.18); + fillFrac *= Math.max(0.15, 1 - animT); + } else { + fillFrac = Math.min(1, animT * 1.2) * Math.min(1, q.n / 0.25 + 0.18); } + fillFrac = Math.max(0, Math.min(1, fillFrac)); - const alpha = isReactant - ? Math.max(0, 1 - animT * 1.2) - : Math.min(1, animT * 1.5); + const isGas = sub.phase === 'g'; + const liquidH = areaH * fillFrac; + const liquidTop = areaY + areaH - liquidH; + + if (liquidH > 1) { + /* wavy surface */ + ctx.save(); + const grad = ctx.createLinearGradient(0, liquidTop, 0, areaY + areaH); + grad.addColorStop(0, sub.color + 'aa'); + grad.addColorStop(1, sub.color + '55'); + ctx.fillStyle = grad; - 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; ctx.beginPath(); - ctx.arc(px + jx, py + jy, 4.5, 0, Math.PI * 2); - ctx.fillStyle = sub.color; + ctx.moveTo(areaX, liquidTop); + const waveSeed = sub.sym.charCodeAt(0); + for (let xi = 0; xi <= areaW; xi += 2) { + const wave = Math.sin((xi / areaW) * Math.PI * 3 + t * 2 + waveSeed) * 1.4 + + Math.sin((xi / areaW) * Math.PI * 5 + t * 1.3) * 0.8; + ctx.lineTo(areaX + xi, liquidTop + wave); + } + ctx.lineTo(areaX + areaW, areaY + areaH); + ctx.lineTo(areaX, areaY + areaH); + ctx.closePath(); ctx.fill(); - ctx.globalAlpha = alpha * 0.5; - ctx.strokeStyle = '#fff'; - ctx.lineWidth = 0.5; - ctx.stroke(); - ctx.globalAlpha = alpha; - } - ctx.globalAlpha = 1; + /* highlight on surface */ + ctx.strokeStyle = sub.color + 'cc'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let xi = 0; xi <= areaW; xi += 2) { + const wave = Math.sin((xi / areaW) * Math.PI * 3 + t * 2 + waveSeed) * 1.4 + + Math.sin((xi / areaW) * Math.PI * 5 + t * 1.3) * 0.8; + if (xi === 0) ctx.moveTo(areaX + xi, liquidTop + wave); + else ctx.lineTo(areaX + xi, liquidTop + wave); + } + ctx.stroke(); + ctx.restore(); + + /* bubbles for gas products / aq solutions */ + if (isGas || sub.phase === 'aq') { + const seed2 = sub.sym.split('').reduce((a, ch) => a + ch.charCodeAt(0), 0); + const nBub = isGas ? 7 : 3; + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + for (let b = 0; b < nBub; b++) { + const phase = (t * (isGas ? 0.9 : 0.4) + b * 0.7 + seed2 * 0.13) % 1; + const bx = areaX + areaW * (0.15 + 0.7 * _stLcg(seed2 + b * 11)); + const by = areaY + areaH - phase * liquidH; + const br = isGas ? (1.4 + (1 - phase) * 1.6) : (1.0 + (1 - phase) * 0.8); + ctx.beginPath(); + ctx.arc(bx, by, br, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + } + + /* particles flowing on reaction (kept from original logic, simplified) */ + if (animT > 0 && animT < 1) { + const maxParticles = 16; + const nParticles = Math.max(1, Math.round(Math.min(q.n / 0.2, 1) * maxParticles)); + const seedP = sub.sym.split('').reduce((a, ch) => a + ch.charCodeAt(0), 0); + const alpha = isReactant ? Math.max(0, 1 - animT * 1.5) : Math.min(1, animT * 1.8); + ctx.globalAlpha = alpha; + ctx.fillStyle = sub.color; + for (let p = 0; p < nParticles; p++) { + const px = areaX + _stLcg(seedP + p * 7) * areaW; + const py = areaY + _stLcg(seedP + p * 7 + 3) * areaH; + const jx = isReactant ? (x + bw / 2 - px) * animT : 0; + const jy = isReactant ? (y + bh / 2 - py) * animT * 0.5 : 0; + ctx.beginPath(); + ctx.arc(px + jx, py + jy, 3, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + + /* Phase label */ const phaseText = sub.phase === 'g' ? '(г)' : sub.phase === 'aq' ? '(р-р)' : sub.phase === 'l' ? '(ж)' : '(тв)'; - ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.font = '9px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText(phaseText, x + bw / 2, y + bh - 18); - ctx.fillStyle = 'rgba(255,214,102,0.9)'; + /* Mass label */ + ctx.fillStyle = 'rgba(255,214,102,0.95)'; ctx.font = 'bold 9px Manrope,sans-serif'; ctx.textAlign = 'right'; ctx.fillText(q.m.toFixed(2) + 'г', x + bw - 4, y + bh - 6); + /* LIMIT label */ + if (isLimit) { + ctx.fillStyle = `rgba(239,71,111,${0.7 + 0.3 * pulse})`; + ctx.font = 'bold 8.5px Manrope,sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('ЛИМИТ', x + 4, y + bh - 6); + } + ctx.restore(); } @@ -1041,9 +1163,12 @@ class StoichSim { destroy() { if (this._raf) cancelAnimationFrame(this._raf); + if (this._idleRaf) cancelAnimationFrame(this._idleRaf); if (this._ro) this._ro.disconnect(); this._canvas = null; this._ctx = null; + this._idleRaf = null; + this._raf = null; } }