fix(labs): таблица Менделеева + UX качественных реакций + анимации стехиометрии
- Менделеев: clamp() для font-size символа элемента (2.4rem..4.4rem) + padding-top 28px → символ не обрезается на узких панелях - Качественные реакции: в Свободно/Тренировке Проб1-4 содержат известные ионы (видна подпись), в Тренировке Образец — отдельный неизвестный; в Экзамене можно переключаться между пробирками и ответить отдельно для каждой (verdict сохраняется) - Стехиометрия: непрерывный анимационный цикл — волна на поверхности жидкости, пузырьки в газах/растворах, пульсирующая красная рамка + ЛИМИТ-лейбл у лимитирующего реагента, искры вдоль стрелки реакции, glow на стрелке во время реакции
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user