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
+164 -39
View File
@@ -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;
}
}