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