Files
Learn_System/frontend/js/labs/flask.js
T
Maxim Dolgolyov 8303d483cc fix(labs): SVG-стрелки уравнений рисовались как сырой текст на canvas
Уравнения реакций содержат inline <svg class=ic> стрелки. На canvas
(fillText) разметка показывалась буквально. Добавлен общий хелпер
ChemVisuals.cleanIcons (SVG→Unicode →/↑/↓), применён в flask (eq),
redox (s.txt) и chemsandbox (ответ квиза — был единственный незакрытый
путь мимо _csClean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:31:35 +03:00

1212 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ════════════════════════════════════════════════════════════════
FlaskSim v2 — «Химия в колбе»
• Реалистичная вода: 3 слоя волн, каустики, мениск, SSS
• Пар при нагреве, всплески пузырьков
• Толстое стекло, пульсирующий glow реакции
• Specular highlight металла, dissolution edge
════════════════════════════════════════════════════════════════ */
class FlaskSim {
/* ── Реагенты ─────────────────────────────────────────────────── */
static METALS = {
Zn: { name: 'Цинк', color: '#9BB8CC', k: 0.50, Ea: 0.9, rho: 7.13, dH: 155, h2: 1, acids: ['HCl','H2SO4'] },
Fe: { name: 'Железо', color: '#A08060', k: 0.08, Ea: 1.4, rho: 7.87, dH: 87, h2: 1, acids: ['HCl'], rust: true },
Mg: { name: 'Магний', color: '#D6D6D6', k: 1.50, Ea: 0.5, rho: 1.74, dH: 467, h2: 1, acids: ['HCl','H2SO4','H2O'] },
Cu: { name: 'Медь', color: '#C87840', k: 0, Ea: 99, rho: 8.96, dH: 0, h2: 0, acids: [] },
Na: { name: 'Натрий', color: '#F5F0C8', k: 6.00, Ea: 0.05, rho: 0.97, dH: 883, h2: 0.5, acids: ['HCl','H2SO4','H2O'], boom: true },
Al: { name: 'Алюминий', color: '#C0C0C0', k: 0.60, Ea: 1.0, rho: 2.70, dH: 300, h2: 1.5, acids: ['HCl','H2SO4'] },
};
static ACIDS = {
HCl: { name: 'Соляная кислота HCl', rgb: [120, 210, 120], pHf: 1.0, label: 'HCl' },
H2SO4: { name: 'Серная кислота H₂SO₄', rgb: [210, 195, 120], pHf: 1.2, label: 'H₂SO₄' },
H2O: { name: 'Вода H₂O', rgb: [110, 180, 215], pHf: 0.0, label: 'H₂O' },
};
static EQ = {
Zn_HCl: 'Zn + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Zn_H2SO4: 'Zn + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Fe_HCl: 'Fe + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Mg_HCl: 'Mg + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> MgCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Mg_H2SO4: 'Mg + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> MgSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Mg_H2O: 'Mg + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Al_HCl: '2Al + 6HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2AlCl₃ + 3H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Al_H2SO4: '2Al + 3H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Al₂(SO₄)₃ + 3H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Na_HCl: '2Na + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaCl + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Na_H2SO4: '2Na + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na₂SO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Na_H2O: '2Na + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaOH + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
Cu_HCl: 'Cu + HCl — реакция не идёт',
Cu_H2SO4: 'Cu + H₂SO₄(разб.) — реакция не идёт',
Cu_H2O: 'Cu + H₂O — реакция не идёт',
Fe_H2SO4: 'Fe + H₂SO₄(конц.) — пассивация!',
Fe_H2O: 'Fe + H₂O — реакция не идёт при 20°C',
Al_H2O: 'Al + H₂O — не реагирует (оксидная плёнка)',
Zn_H2O: 'Zn + H₂O — реакция не идёт',
};
/* ── Конструктор ──────────────────────────────────────────────── */
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.metalType = 'Zn';
this.acidType = 'HCl';
this.concLevel = 0.35;
this.envTemp = 20;
/* Частицы и волны */
this._metal = null;
this._bubbles = [];
this._dusts = [];
this._sparks = [];
this._steam = [];
this._splashes = [];
this._caustics = [];
/* Фазы волн (3 независимые) */
this._wave = 0;
this._wave2 = 0;
this._wave3 = 0;
/* Анимационные таймеры */
this._glowPulse = 0;
this._causticTmr = 0;
this._steamTmr = 0;
/* Физическое состояние */
this._passiv = false;
this._ignited = false;
this._flameOn = false;
this._boomCD = 0;
this._conc = this.concLevel;
this._temp = this.envTemp;
this._pH = 1.0;
this._rxRate = 0;
this._h2 = 0;
this._bubTmr = 0;
/* Анимация */
this._raf = null;
this._last = 0;
this._paused = false;
/* product label animation */
this._prodLabelAge = -1;
this._prodLabelText = '';
this._prodLabelType = 'gas';
this._time = 0;
this.onUpdate = null;
this.W = 0; this.H = 0;
this._g = {};
this.fit();
}
/* ── Геометрия ────────────────────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.offsetWidth || 600;
const H = this.canvas.offsetHeight || 400;
this.canvas.width = Math.round(W * dpr);
this.canvas.height = Math.round(H * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._calcGeom();
}
_calcGeom() {
const { W, H } = this;
const r = Math.min(W * 0.195, H * 0.285);
const cx = W * 0.50;
const cy = H * 0.615;
const nw = r * 0.26; // ширина горлышка
const nh = r * 1.05; // высота горлышка
const nt = cy - r - nh; // верх горлышка
const nb = cy - r * 0.80; // точка начала плеч (где шея переходит в колбу)
const liqTop = cy - r * 0.42;
this._g = { r, cx, cy, nw, nh, nt, nb, liqTop };
}
_flaskPath(ctx) {
const { r, cx, cy, nw, nt, nb } = this._g;
ctx.beginPath();
ctx.moveTo(cx - nw, nt);
ctx.lineTo(cx - nw, nb);
/* Левое плечо: плавная кривая Безье от шеи до экватора колбы */
ctx.bezierCurveTo(
cx - nw, cy - r * 0.42, // CP1: продолжаем вниз по шее
cx - r * 0.85, cy - r * 0.10, // CP2: выходим к экватору
cx - r, cy // конец: левый экватор окружности
);
/* Нижняя дуга колбы: слева направо через дно (anticlockwise=true в canvas = через низ) */
ctx.arc(cx, cy, r, Math.PI, 0, true);
/* Правое плечо: симметрично */
ctx.bezierCurveTo(
cx + r * 0.85, cy - r * 0.10,
cx + nw, cy - r * 0.42,
cx + nw, nb
);
ctx.lineTo(cx + nw, nt);
ctx.closePath();
}
/* ── Запуск / остановка ───────────────────────────────────────── */
start() {
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
this._raf = requestAnimationFrame(loop);
}
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
/* ── Публичный API ────────────────────────────────────────────── */
dropMetal() {
const { cx, nt } = this._g;
const md = FlaskSim.METALS[this.metalType];
const mass = 5;
this._metal = {
type: this.metalType,
mass, init: mass,
x: cx + (Math.random() - 0.5) * 8,
y: nt - 32,
vx: (Math.random() - 0.5) * 22,
vy: 0,
r: this._m2r(mass),
_v: Array.from({ length: 10 }, (_, i) => ({
a: (i / 10) * Math.PI * 2,
j: 0.68 + Math.random() * 0.32,
})),
};
this._passiv = false;
this._ignited = false;
this._h2 = 0;
this._bubTmr = 0;
this._boomCD = 0;
if (window.LabFX) {
LabFX.sound.play('bounce', { pitch: 0.6 });
// Brief delay then fizz as metal hits acid
setTimeout(() => { if (window.LabFX) LabFX.sound.play('fizz'); }, 350);
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 5, count: 6,
color: '#FFFFFF', speed: 25, spread: 1.6, angle: -Math.PI / 2,
gravity: -60, life: 1500, shape: 'ring' });
}
}
reset() {
this._metal = null;
this._bubbles = [];
this._dusts = [];
this._sparks = [];
this._steam = [];
this._splashes = [];
this._caustics = [];
this._passiv = false;
this._ignited = false;
this._flameOn = false;
this._h2 = 0;
this._rxRate = 0;
this._boomCD = 0;
this._causticTmr = 0;
this._steamTmr = 0;
this._conc = this.concLevel;
this._temp = this.envTemp;
this._pH = this._startPH();
if (this.onUpdate) this.onUpdate(this.info());
this.draw();
}
togglePause() { this._paused = !this._paused; if (window.LabFX) LabFX.sound.play('click'); }
toggleFlame() { this._flameOn = !this._flameOn; }
setMetal(t) { this.metalType = t; }
setAcid(t) { this.acidType = t; this.reset(); }
setConc(v) { this.concLevel = v; this._conc = v; if (!this._metal) this._pH = this._startPH(); }
setEnvTemp(v) { this.envTemp = v; if (!this._metal) this._temp = v; }
_startPH() {
const a = FlaskSim.ACIDS[this.acidType];
if (a.pHf === 0) return 7.0;
return Math.max(0, -Math.log10(this.concLevel * 10 * a.pHf + 1e-10));
}
_m2r(mass) { return 8 + 24 * Math.cbrt(Math.max(0, mass) / 5); }
/* ── Тик физики ───────────────────────────────────────────────── */
_tick(now) {
const dt = Math.min((now - this._last) / 1000, 0.05);
this._last = now;
this._time += dt;
if (window.LabFX) LabFX.particles.update(dt);
if (!this._paused) {
this._wave += dt * 1.7;
this._wave2 += dt * 2.3;
this._wave3 += dt * 0.88;
this._glowPulse += dt * 3.2;
this._stepMetal(dt);
this._stepBubbles(dt);
this._stepDusts(dt);
this._stepSparks(dt);
this._stepSteam(dt);
this._stepSplashes(dt);
this._stepCaustics(dt);
}
/* product label age */
if (this._prodLabelAge >= 0) {
this._prodLabelAge += dt / 3.0;
if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1;
}
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
/* ── Физика металла ───────────────────────────────────────────── */
_stepMetal(dt) {
const m = this._metal;
if (!m || m.mass <= 0.01) { if (m) m.mass = 0; return; }
const md = FlaskSim.METALS[m.type];
const { cy, r, liqTop, cx } = this._g;
const liqRho = 1.12;
const inLiq = m.y + m.r > liqTop;
const grav = 400;
const buoy = inLiq ? grav * (liqRho / md.rho) : 0;
const drag = inLiq ? 4.5 : 0.25;
m.vy += (grav - buoy) * dt;
m.vy -= drag * m.vy * dt;
m.vx -= drag * m.vx * dt;
m.y += m.vy * dt;
m.x += m.vx * dt;
const botY = cy + r - m.r;
if (m.y > botY) { m.y = botY; m.vy *= -0.22; }
const hw = Math.sqrt(Math.max(0, r * r - (m.y - cy) ** 2));
m.x = Math.max(cx - hw + m.r, Math.min(cx + hw - m.r, m.x));
if (md.rho < liqRho && inLiq) {
const sfY = liqTop - m.r;
if (m.y < sfY) { m.y = sfY; m.vy = Math.abs(m.vy) * 0.25; }
}
const reacts = md.acids.includes(this.acidType) && !this._passiv && this._conc > 4e-4;
if (!reacts) { this._rxRate = 0; return; }
const T_K = this._temp + 273.15;
const rate = md.k * this._conc * Math.exp(-md.Ea * 3000 / (8.314 * T_K));
this._rxRate = Math.min(1, rate * 4);
const surf = (m.r / 26) ** 2;
const dmdt = rate * surf * 0.95;
m.mass = Math.max(0, m.mass - dmdt * dt);
m.r = this._m2r(m.mass);
/* Слегка деформировать вершины при реакции */
if (this._rxRate > 0.1 && Math.random() < dt * 6) {
const vi = Math.floor(Math.random() * m._v.length);
m._v[vi].j = Math.max(0.45, Math.min(1.0, m._v[vi].j + (Math.random() - 0.5) * 0.08));
}
const heatW = md.dH * dmdt * 0.055;
const cool = 0.30 * (this._temp - this.envTemp);
this._temp = Math.min(150, Math.max(this.envTemp, this._temp + (heatW - cool) * dt));
this._conc = Math.max(0, this._conc - dmdt * 0.07 * dt);
const ad = FlaskSim.ACIDS[this.acidType];
if (ad.pHf > 0) {
this._pH = Math.min(7, Math.max(0, -Math.log10(this._conc * 10 * ad.pHf + 1e-10)));
} else {
this._pH = Math.min(14, 7 + Math.min(7, m.mass < 0.1 ? 7 : dmdt * 12));
}
this._h2 = Math.min(1, this._h2 + md.h2 * dmdt * 0.065 * dt);
this._bubTmr += rate * 32 * dt;
while (this._bubTmr > 1 && this._bubbles.length < 180) {
this._spawnBubble(m.x, m.y - m.r * 0.6);
this._bubTmr--;
}
/* trigger H2 product label when reaction first picks up */
if (md.h2 > 0 && this._rxRate > 0.05 && this._prodLabelAge < 0 && window.ChemVisuals) {
this._prodLabelText = 'H₂ ';
this._prodLabelType = 'gas';
this._prodLabelAge = 0;
}
if (md.rust && Math.random() < rate * dt * 14) {
this._spawnDust(m.x + (Math.random() - 0.5) * m.r, m.y + m.r * 0.3, '#8B3A0A', 0.65);
}
if (m.type === 'Fe' && this.acidType === 'H2SO4' && this.concLevel > 0.82) {
this._passiv = true;
}
if (md.boom && this._boomCD <= 0 && rate > 0.28) {
this._boom(m.x, m.y);
this._boomCD = 0.45;
}
if (this._boomCD > 0) this._boomCD -= dt;
if (this._flameOn && this._h2 > 0.22 && !this._ignited) {
this._igniteH2();
}
}
/* ── Частицы ─────────────────────────────────────────────────── */
_spawnBubble(x, y) {
this._bubbles.push({
x: x + (Math.random() - 0.5) * 14,
y,
r: 1.4 + Math.random() * 3.8,
vy: -(16 + Math.random() * 38),
vx: (Math.random() - 0.5) * 9,
wobble: Math.random() * Math.PI * 2,
wFreq: 3.5 + Math.random() * 3,
life: 1,
});
}
_stepBubbles(dt) {
const { liqTop } = this._g;
for (const b of this._bubbles) {
b.wobble += dt * b.wFreq;
b.x += b.vx * dt + Math.sin(b.wobble) * b.r * 0.35 * dt * 10;
b.y += b.vy * dt;
b.vx += (Math.random() - 0.5) * 28 * dt;
if (b.y - b.r < liqTop) {
this._spawnSplash(b.x, liqTop, b.r);
b.life = 0;
} else {
b.life -= dt * 0.14;
}
}
this._bubbles = this._bubbles.filter(b => b.life > 0);
}
_spawnSplash(x, y, r) {
if (r < 2) return;
const n = Math.floor(2 + r);
for (let i = 0; i < n; i++) {
const a = -Math.PI * 0.5 + (Math.random() - 0.5) * Math.PI * 1.2;
const s = 10 + r * 4 + Math.random() * 20;
this._splashes.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 12, r: 0.8 + Math.random() * 1.4, life: 1 });
}
}
_stepSplashes(dt) {
for (const s of this._splashes) {
s.x += s.vx * dt;
s.y += s.vy * dt;
s.vy += 55 * dt;
s.life -= dt * 4.0;
}
this._splashes = this._splashes.filter(s => s.life > 0);
}
_spawnSteam(x, y) {
this._steam.push({
x: x + (Math.random() - 0.5) * 16,
y,
vx: (Math.random() - 0.5) * 12,
vy: -(6 + Math.random() * 18),
r: 2.5 + Math.random() * 6,
life: 0.9 + Math.random() * 0.1,
});
}
_stepSteam(dt) {
if (this._temp > 70) {
this._steamTmr += (this._temp - 70) / 30 * dt * 7;
while (this._steamTmr > 1 && this._steam.length < 55) {
const { liqTop, cx, nw } = this._g;
this._spawnSteam(cx + (Math.random() - 0.5) * nw * 1.6, liqTop - 4);
this._steamTmr--;
}
}
for (const s of this._steam) {
s.x += s.vx * dt;
s.y += s.vy * dt;
s.vx += (Math.random() - 0.5) * 12 * dt;
s.r += dt * 5;
s.life -= dt * (0.7 + (1 - s.life) * 0.4);
}
this._steam = this._steam.filter(s => s.life > 0 && s.r < 50);
}
_spawnCaustic(x, y) {
this._caustics.push({
x, y,
r: 7 + Math.random() * 20,
vx: (Math.random() - 0.5) * 16,
vy: (Math.random() - 0.5) * 7,
life: 0.4 + Math.random() * 0.6,
a: 0.05 + Math.random() * 0.09,
});
}
_stepCaustics(dt) {
this._causticTmr += dt * 3.5;
while (this._causticTmr > 1 && this._caustics.length < 20) {
const { cx, cy, r, liqTop } = this._g;
const px = cx + (Math.random() - 0.5) * r * 1.5;
const py = liqTop + 8 + Math.random() * (cy + r * 0.6 - liqTop - 16);
this._spawnCaustic(px, py);
this._causticTmr--;
}
for (const c of this._caustics) {
c.x += c.vx * dt;
c.y += c.vy * dt;
c.r += dt * 4;
c.life -= dt * 0.38;
}
this._caustics = this._caustics.filter(c => c.life > 0);
}
_spawnDust(x, y, col, a) {
this._dusts.push({
x, y,
vx: (Math.random() - 0.5) * 14,
vy: 4 + Math.random() * 20,
r: 1.0 + Math.random() * 2.2,
col, a, life: 1,
});
}
_stepDusts(dt) {
for (const d of this._dusts) {
d.x += d.vx * dt;
d.y += d.vy * dt;
d.vy += 28 * dt;
d.vx *= 1 - dt * 2.2;
d.life -= dt * 0.22;
}
this._dusts = this._dusts.filter(d => d.life > 0);
if (this._dusts.length > 300) this._dusts.splice(0, 60);
}
_stepSparks(dt) {
for (const s of this._sparks) {
s.x += s.vx * dt;
s.y += s.vy * dt;
s.vy += 210 * dt;
s.vx *= 1 - dt * 0.8;
s.life -= dt * 2.0;
}
this._sparks = this._sparks.filter(s => s.life > 0);
}
_boom(x, y) {
for (let i = 0; i < 36; i++) {
const a = Math.random() * Math.PI * 2;
const s = 90 + Math.random() * 240;
this._sparks.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 90,
r: 2 + Math.random() * 4, col: Math.random() < 0.55 ? '#FFD166' : '#EF476F', life: 1 });
}
}
_igniteH2() {
this._ignited = true;
this._h2 = 0;
const { cx, nt } = this._g;
for (let i = 0; i < 60; i++) {
const a = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9;
const s = 130 + Math.random() * 360;
this._sparks.push({
x: cx + (Math.random() - 0.5) * 18, y: nt - 10,
vx: Math.cos(a) * s, vy: Math.sin(a) * s,
r: 3 + Math.random() * 5, col: i < 30 ? '#FFD166' : '#FF6B35', life: 1,
});
}
if (window.LabFX) {
LabFX.sound.play('whoosh', { pitch: 1.5 });
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 10, count: 20,
color: '#FFA500', speed: 80, spread: 2.0, angle: -Math.PI / 2,
gravity: -100, life: 300, shape: 'spark', glow: true });
}
}
/* ════════════════════════════════════════════════════════════════
РЕНДЕРИНГ
════════════════════════════════════════════════════════════════ */
draw() {
const ctx = this.ctx;
const { W, H, _g: g } = this;
ctx.clearRect(0, 0, W, H);
/* Фон */
const bg = ctx.createRadialGradient(W * 0.5, H * 0.35, 0, W * 0.5, H * 0.5, W * 0.75);
bg.addColorStop(0, '#0d1320');
bg.addColorStop(1, '#05080f');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
/* Сетка лаборатории */
ctx.strokeStyle = 'rgba(255,255,255,0.018)'; ctx.lineWidth = 1;
for (let x = 0; x < W; x += 28) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y < H; y += 28) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
/* Стол */
const tableY = g.cy + g.r + 8;
if (window.ChemVisuals) {
ChemVisuals.drawDeskBackground(ctx, W, H, tableY);
ChemVisuals.drawVesselShadow(ctx, g.cx, tableY + 2, g.r);
} else {
const tg = ctx.createLinearGradient(0, tableY, 0, tableY + 48);
tg.addColorStop(0, '#19223a'); tg.addColorStop(1, '#0c101e');
ctx.fillStyle = tg; ctx.fillRect(0, tableY, W, H - tableY);
ctx.strokeStyle = 'rgba(90,120,200,0.20)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, tableY); ctx.lineTo(W, tableY); ctx.stroke();
}
/* Spirit lamp under flask when flame is on */
if (window.ChemVisuals) {
const lampX = g.cx;
const lampY = tableY + 18;
ChemVisuals.drawSpiritLamp(ctx, lampX, lampY, this._flameOn, this._time);
}
this._drawFlaskShadow(ctx, tableY);
this._drawLiquid(ctx);
this._drawCaustics(ctx);
this._drawDusts(ctx);
this._drawBubbles(ctx);
this._drawSplashes(ctx);
this._drawMetal(ctx);
this._drawFlaskGlass(ctx);
this._drawSteam(ctx);
this._drawSparks(ctx);
this._drawThermometer(ctx);
this._drawPHStrip(ctx);
this._drawH2Bar(ctx);
this._drawInfoPanel(ctx);
if (!this._metal || this._metal.mass <= 0.01) this._drawHint(ctx);
if (window.LabFX) LabFX.particles.draw(ctx);
/* animated product labels */
if (window.ChemVisuals && this._prodLabelAge >= 0) {
ChemVisuals.drawProductLabel(ctx, g.cx, g.nt - 10, this._prodLabelText, this._prodLabelType, this._prodLabelAge);
if (this._prodLabelType === 'gas') {
ChemVisuals.animateGasBubbles(ctx, g.cx, g.nt - 8, 'rgba(200,235,255,0.8)', this._time);
}
}
}
/* ── Тень/отражение колбы на столе ── */
_drawFlaskShadow(ctx, tableY) {
const { _g: g } = this;
ctx.save();
ctx.scale(1, 0.26);
const shadowGrad = ctx.createRadialGradient(g.cx, tableY / 0.26, 0, g.cx, tableY / 0.26, g.r * 1.15);
shadowGrad.addColorStop(0, 'rgba(0,0,0,0.50)');
shadowGrad.addColorStop(0.55, 'rgba(0,0,0,0.22)');
shadowGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = shadowGrad;
ctx.beginPath(); ctx.arc(g.cx, tableY / 0.26, g.r * 1.15, 0, Math.PI * 2); ctx.fill();
ctx.restore();
/* Реакционный glow на столе */
if (this._rxRate > 0.05) {
const ad = FlaskSim.ACIDS[this.acidType];
const [ri, gi, bi] = ad.rgb;
ctx.save();
ctx.scale(1, 0.28);
const gg = ctx.createRadialGradient(g.cx, tableY / 0.28, 0, g.cx, tableY / 0.28, g.r * 0.9);
gg.addColorStop(0, `rgba(${ri},${gi},${bi},${this._rxRate * 0.18})`);
gg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gg;
ctx.beginPath(); ctx.arc(g.cx, tableY / 0.28, g.r * 0.9, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
}
/* ── Жидкость: 3 волны + SSS + каустики + мениск ── */
_drawLiquid(ctx) {
const { _g: g } = this;
const ad = FlaskSim.ACIDS[this.acidType];
const [ri, gi, bi] = ad.rgb;
const heat = Math.min(1, (this._temp - 20) / 80);
const lr = Math.min(255, ri + heat * 80);
const lg = Math.max(0, gi - heat * 58);
const lb = Math.max(0, bi - heat * 58);
const al = 0.20 + this._conc * 0.40;
const amp = 1.8 + this._rxRate * 8;
/* Функция волновой поверхности */
const waveY = (x) => {
const wx = x - g.cx;
return g.liqTop
+ Math.sin(wx * 0.065 + this._wave) * amp
+ Math.sin(wx * 0.130 - this._wave2 * 1.38) * amp * 0.36
+ Math.sin(wx * 0.046 + this._wave3 * 0.75) * amp * 0.20;
};
const step = 2;
const x0 = g.cx - g.r - 2, x1 = g.cx + g.r + 2;
ctx.save();
this._flaskPath(ctx);
ctx.clip();
/* ── Слой 1: основное тело жидкости ── */
ctx.beginPath();
ctx.moveTo(x0, waveY(x0));
for (let x = x0; x <= x1; x += step) ctx.lineTo(x, waveY(x));
ctx.lineTo(x1, g.cy + g.r + 12);
ctx.lineTo(x0, g.cy + g.r + 12);
ctx.closePath();
const depthGrad = ctx.createLinearGradient(0, g.liqTop, 0, g.cy + g.r);
depthGrad.addColorStop(0, `rgba(${lr},${lg},${lb},${al * 0.40})`);
depthGrad.addColorStop(0.30, `rgba(${lr},${lg},${lb},${al * 0.65})`);
depthGrad.addColorStop(1, `rgba(${lr},${lg},${lb},${al})`);
ctx.fillStyle = depthGrad; ctx.fill();
/* ── Слой 2: subsurface scattering (22px полоса под поверхностью) ── */
ctx.beginPath();
ctx.moveTo(x0, waveY(x0));
for (let x = x0; x <= x1; x += step) ctx.lineTo(x, waveY(x));
for (let x = x1; x >= x0; x -= step) ctx.lineTo(x, waveY(x) + 22);
ctx.closePath();
const sssGrad = ctx.createLinearGradient(0, g.liqTop, 0, g.liqTop + 22);
sssGrad.addColorStop(0, `rgba(${Math.min(255,lr+90)},${Math.min(255,lg+80)},${Math.min(255,lb+80)},0.22)`);
sssGrad.addColorStop(1, `rgba(${lr},${lg},${lb},0)`);
ctx.fillStyle = sssGrad; ctx.fill();
/* ── Слой 3: радиальный тинт дна (имитация рассеяния) ── */
const botGrad = ctx.createRadialGradient(g.cx, g.cy + g.r * 0.55, 0, g.cx, g.cy + g.r * 0.55, g.r * 0.80);
botGrad.addColorStop(0, `rgba(${Math.min(255,lr+30)},${Math.min(255,lg+30)},${Math.min(255,lb+40)},0.14)`);
botGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = botGrad;
ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); ctx.fill();
/* ── Основной блик поверхности ── */
ctx.beginPath();
ctx.moveTo(x0, waveY(x0));
for (let x = x0; x <= x1; x += step) ctx.lineTo(x, waveY(x));
ctx.strokeStyle = `rgba(${Math.min(255,lr+100)},${Math.min(255,lg+95)},${Math.min(255,lb+95)},0.50)`;
ctx.lineWidth = 1.6; ctx.stroke();
/* ── Вторая, более тонкая волна-блик (чуть ниже) ── */
ctx.beginPath();
for (let x = x0; x <= x1; x += step) {
const wy = waveY(x) + 3
+ Math.sin((x - g.cx) * 0.11 - this._wave2 * 1.1) * amp * 0.55;
if (x === x0) ctx.moveTo(x, wy); else ctx.lineTo(x, wy);
}
ctx.strokeStyle = `rgba(${Math.min(255,lr+55)},${Math.min(255,lg+55)},${Math.min(255,lb+55)},0.18)`;
ctx.lineWidth = 1; ctx.stroke();
/* ── Мениск у стенок колбы ── */
const mY = waveY(g.cx);
ctx.beginPath();
ctx.moveTo(g.cx - g.r + 2, waveY(g.cx - g.r) + 6);
ctx.quadraticCurveTo(g.cx - g.r + 14, mY - 4, g.cx - g.r * 0.38, mY);
ctx.strokeStyle = `rgba(${Math.min(255,lr+70)},${Math.min(255,lg+70)},${Math.min(255,lb+70)},0.32)`;
ctx.lineWidth = 2.2; ctx.stroke();
ctx.beginPath();
ctx.moveTo(g.cx + g.r - 2, waveY(g.cx + g.r) + 6);
ctx.quadraticCurveTo(g.cx + g.r - 14, mY - 4, g.cx + g.r * 0.38, mY);
ctx.stroke();
ctx.restore();
}
/* ── Каустики (световые пятна в толще жидкости) ── */
_drawCaustics(ctx) {
if (this._caustics.length === 0) return;
ctx.save();
this._flaskPath(ctx); ctx.clip();
for (const c of this._caustics) {
const alpha = c.a * c.life;
const cg = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, c.r);
cg.addColorStop(0, `rgba(255,255,255,${alpha * 0.9})`);
cg.addColorStop(0.45,`rgba(210,235,255,${alpha * 0.4})`);
cg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = cg;
ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
ctx.restore();
}
/* ── Пузырьки с wobble, specular, вторичным бликом ── */
_drawBubbles(ctx) {
ctx.save();
this._flaskPath(ctx); ctx.clip();
for (const b of this._bubbles) {
const a = Math.min(1, b.life * 2.5);
/* Тело пузырька — градиент */
const bg = ctx.createRadialGradient(
b.x - b.r * 0.30, b.y - b.r * 0.30, 0,
b.x, b.y, b.r
);
bg.addColorStop(0, `rgba(220,240,255,${a * 0.18})`);
bg.addColorStop(0.65,`rgba(180,215,255,${a * 0.09})`);
bg.addColorStop(1, `rgba(130,185,255,${a * 0.04})`);
ctx.fillStyle = bg;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.fill();
/* Контур */
ctx.strokeStyle = `rgba(200,230,255,${a * 0.70})`;
ctx.lineWidth = 0.85;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.stroke();
/* Specular highlight (верхний левый) */
const hg = ctx.createRadialGradient(
b.x - b.r * 0.30, b.y - b.r * 0.32, 0,
b.x - b.r * 0.30, b.y - b.r * 0.32, b.r * 0.30
);
hg.addColorStop(0, `rgba(255,255,255,${a * 0.88})`);
hg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hg;
ctx.beginPath(); ctx.arc(b.x - b.r * 0.30, b.y - b.r * 0.32, b.r * 0.30, 0, Math.PI * 2); ctx.fill();
/* Малый блик (нижний правый) */
ctx.fillStyle = `rgba(200,225,255,${a * 0.28})`;
ctx.beginPath(); ctx.arc(b.x + b.r * 0.24, b.y + b.r * 0.30, b.r * 0.12, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
/* ── Всплески на поверхности ── */
_drawSplashes(ctx) {
if (this._splashes.length === 0) return;
ctx.save();
this._flaskPath(ctx); ctx.clip();
const ad = FlaskSim.ACIDS[this.acidType];
const [ri, gi, bi] = ad.rgb;
for (const s of this._splashes) {
ctx.globalAlpha = s.life * 0.75;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = `rgb(${Math.min(255,ri+70)},${Math.min(255,gi+70)},${Math.min(255,bi+70)})`;
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.restore();
}
/* ── Пар ── */
_drawSteam(ctx) {
if (this._steam.length === 0) return;
const { _g: g } = this;
for (const s of this._steam) {
/* Пар выходит только выше горлышка или через нагрев (внутри колбы у шейки) */
const inNeck = s.x > g.cx - g.nw - 6 && s.x < g.cx + g.nw + 6;
if (!inNeck && s.y > g.nt - 4) continue;
ctx.save();
ctx.globalAlpha = s.life * 0.38 * Math.min(1, s.r / 8);
const sg = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.r);
sg.addColorStop(0, 'rgba(200,215,255,0.9)');
sg.addColorStop(0.6,'rgba(180,200,240,0.4)');
sg.addColorStop(1, 'rgba(160,190,230,0)');
ctx.fillStyle = sg;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
}
/* ── Колба: толстое стекло, glow реакции, highlights ── */
_drawFlaskGlass(ctx) {
const { _g: g } = this;
const { r, cx, cy, nw, nt, nb } = g;
const heat = Math.min(1, (this._temp - 20) / 80);
const ad = FlaskSim.ACIDS[this.acidType];
const [ri, gi, bi] = ad.rgb;
/* ── Reaction glow (пульсирующий) ── */
if (this._rxRate > 0.04) {
ctx.save();
const pulse = 0.5 + 0.5 * Math.sin(this._glowPulse);
ctx.shadowColor = `rgb(${ri},${gi},${bi})`;
ctx.shadowBlur = 10 + this._rxRate * 30 + pulse * 10;
this._flaskPath(ctx);
ctx.strokeStyle = `rgba(${ri},${gi},${bi},${this._rxRate * 0.50 + pulse * 0.12})`;
ctx.lineWidth = 1.2;
ctx.stroke();
ctx.shadowBlur = 0;
ctx.restore();
}
/* ── Внешний контур ── */
this._flaskPath(ctx);
ctx.strokeStyle = 'rgba(100,165,255,0.68)';
ctx.lineWidth = 3.0;
ctx.stroke();
/* ── Внутренний контур (толщина стекла) ── */
const tk = 4;
const r_i = r - tk;
const nw_i = nw - tk * 0.70;
const nb_i = cy - r_i * 0.80;
ctx.save();
ctx.beginPath();
ctx.moveTo(cx - nw_i, nt + tk * 0.9);
ctx.lineTo(cx - nw_i, nb_i);
ctx.bezierCurveTo(
cx - nw_i, cy - r_i * 0.42,
cx - r_i * 0.85, cy - r_i * 0.10,
cx - r_i, cy
);
ctx.arc(cx, cy, r_i, Math.PI, 0, true);
ctx.bezierCurveTo(
cx + r_i * 0.85, cy - r_i * 0.10,
cx + nw_i, cy - r_i * 0.42,
cx + nw_i, nb_i
);
ctx.lineTo(cx + nw_i, nt + tk * 0.9);
ctx.strokeStyle = 'rgba(75,125,215,0.16)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
/* ── Большой левый блик (gradient arc) ── */
ctx.save();
ctx.beginPath();
ctx.moveTo(cx - nw * 0.50, nt + 10);
ctx.lineTo(cx - nw * 0.50, nb);
ctx.bezierCurveTo(
cx - nw * 0.50, nb + r * 0.18,
cx - r * 0.72, cy - r * 0.42,
cx - r * 0.74, cy - r * 0.05
);
const hlGrad = ctx.createLinearGradient(cx - r * 0.73, nt, cx - r * 0.73, cy);
hlGrad.addColorStop(0, 'rgba(225,242,255,0.40)');
hlGrad.addColorStop(0.40, 'rgba(215,235,255,0.22)');
hlGrad.addColorStop(0.75, 'rgba(200,225,255,0.10)');
hlGrad.addColorStop(1, 'rgba(200,225,255,0.02)');
ctx.strokeStyle = hlGrad;
ctx.lineWidth = 5;
ctx.stroke();
ctx.restore();
/* ── Правый мягкий блик ── */
ctx.save();
ctx.beginPath();
ctx.moveTo(cx + r * 0.62, cy - r * 0.56);
ctx.quadraticCurveTo(cx + r * 0.83, cy - r * 0.18, cx + r * 0.78, cy + r * 0.20);
ctx.strokeStyle = 'rgba(200,225,255,0.11)';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
/* ── Горлышко — ободок ── */
ctx.beginPath();
ctx.moveTo(cx - nw - 5, nt);
ctx.lineTo(cx + nw + 5, nt);
ctx.strokeStyle = 'rgba(100,165,255,0.68)';
ctx.lineWidth = 3.2;
ctx.stroke();
/* ── Блик горлышка ── */
ctx.save();
ctx.beginPath();
ctx.moveTo(cx - nw * 0.42, nt + 4);
ctx.lineTo(cx - nw * 0.42, nb - 4);
ctx.strokeStyle = 'rgba(220,240,255,0.26)';
ctx.lineWidth = 2.2;
ctx.stroke();
ctx.restore();
/* ── Тепловой тинт (стекло краснеет при нагреве) ── */
if (heat > 0.15) {
ctx.save();
this._flaskPath(ctx);
ctx.fillStyle = `rgba(255,${Math.round(155 - heat * 110)},40,${heat * 0.072})`;
ctx.fill();
ctx.restore();
}
}
/* ── Металл: specular, dissolution edge ── */
_drawMetal(ctx) {
const m = this._metal;
if (!m || m.mass <= 0.01) return;
const md = FlaskSim.METALS[m.type];
ctx.save();
/* Dissolution edge glow */
if (this._rxRate > 0.08) {
ctx.shadowColor = '#FFD166';
ctx.shadowBlur = 6 + this._rxRate * 22;
}
/* Тело */
ctx.beginPath();
for (let i = 0; i < m._v.length; i++) {
const v = m._v[i];
const px = m.x + Math.cos(v.a) * m.r * v.j;
const py = m.y + Math.sin(v.a) * m.r * v.j;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
const mg = ctx.createRadialGradient(m.x - m.r * 0.32, m.y - m.r * 0.30, 0, m.x, m.y, m.r);
mg.addColorStop(0, this._tint(md.color, 80));
mg.addColorStop(0.28,this._tint(md.color, 48));
mg.addColorStop(0.68,md.color);
mg.addColorStop(1, this._tint(md.color, -62));
ctx.fillStyle = mg; ctx.fill();
ctx.strokeStyle = this._tint(md.color, 32); ctx.lineWidth = 1.5; ctx.stroke();
ctx.shadowBlur = 0;
/* Specular dot */
ctx.save();
ctx.globalAlpha = 0.68;
const sg = ctx.createRadialGradient(
m.x - m.r * 0.28, m.y - m.r * 0.30, 0,
m.x - m.r * 0.28, m.y - m.r * 0.30, m.r * 0.40
);
sg.addColorStop(0, 'rgba(255,255,255,0.95)');
sg.addColorStop(0.5,'rgba(255,255,255,0.35)');
sg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = sg;
ctx.beginPath(); ctx.arc(m.x - m.r * 0.28, m.y - m.r * 0.30, m.r * 0.40, 0, Math.PI * 2); ctx.fill();
ctx.restore();
/* Dissolution edge */
if (this._rxRate > 0.12) {
ctx.save();
ctx.globalAlpha = this._rxRate * 0.55;
ctx.beginPath();
for (let i = 0; i < m._v.length; i++) {
const v = m._v[i];
const px = m.x + Math.cos(v.a) * m.r * v.j;
const py = m.y + Math.sin(v.a) * m.r * v.j;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.strokeStyle = '#FFD166';
ctx.lineWidth = 1.5 + this._rxRate * 3.5;
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 10;
ctx.stroke();
ctx.restore();
}
/* Пассивирующая плёнка */
if (this._passiv) {
ctx.beginPath();
for (let i = 0; i < m._v.length; i++) {
const v = m._v[i];
if (i === 0) ctx.moveTo(m.x + Math.cos(v.a) * m.r * v.j, m.y + Math.sin(v.a) * m.r * v.j);
else ctx.lineTo(m.x + Math.cos(v.a) * m.r * v.j, m.y + Math.sin(v.a) * m.r * v.j);
}
ctx.closePath();
ctx.fillStyle = 'rgba(55,40,25,0.65)'; ctx.fill();
}
ctx.restore();
}
_drawDusts(ctx) {
ctx.save();
this._flaskPath(ctx); ctx.clip();
for (const d of this._dusts) {
ctx.globalAlpha = d.a * d.life;
ctx.beginPath(); ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
ctx.fillStyle = d.col; ctx.fill();
}
ctx.globalAlpha = 1;
ctx.restore();
}
_drawSparks(ctx) {
for (const s of this._sparks) {
ctx.save();
ctx.globalAlpha = s.life;
ctx.shadowColor = s.col;
ctx.shadowBlur = 12;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r * s.life, 0, Math.PI * 2);
ctx.fillStyle = s.col; ctx.fill();
ctx.restore();
}
if (this._flameOn) {
const { cx, nt, nw } = this._g;
ctx.font = '22px serif';
ctx.fillText('*', cx + nw + 8, nt + 8);
}
}
/* ── Термометр ── */
_drawThermometer(ctx) {
const { _g: g } = this;
const tx = g.cx + g.r + 44;
const ty = g.nt + 8;
const th = g.cy + g.r - ty - 16;
const tw = 11;
const frac = Math.min(1, Math.max(0, (this._temp - 10) / 140));
const fillH = th * frac;
const col = `hsl(${Math.round(55 - frac * 55)},92%,56%)`;
_flask_rrect(ctx, tx - tw / 2, ty, tw, th, tw / 2);
ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill();
ctx.strokeStyle = 'rgba(120,175,255,0.38)'; ctx.lineWidth = 1.5; ctx.stroke();
if (fillH > 0) {
_flask_rrect(ctx, tx - tw / 2 + 2, ty + th - fillH, tw - 4, fillH, (tw - 4) / 2);
ctx.fillStyle = col; ctx.fill();
}
ctx.beginPath(); ctx.arc(tx, ty + th + tw * 0.68, tw * 0.74, 0, Math.PI * 2);
ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 10; ctx.fill(); ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(150,185,255,0.3)'; ctx.lineWidth = 1;
for (let deg = 20; deg <= 150; deg += 20) {
const fy = ty + th - th * (deg - 10) / 140;
ctx.beginPath(); ctx.moveTo(tx + tw / 2, fy); ctx.lineTo(tx + tw / 2 + 5, fy); ctx.stroke();
}
ctx.font = 'bold 10.5px monospace'; ctx.fillStyle = 'rgba(195,215,255,0.82)';
ctx.textAlign = 'center';
ctx.fillText(Math.round(this._temp) + '°C', tx, ty + th + tw * 2 + 17);
ctx.fillText('T', tx, ty - 5);
ctx.textAlign = 'left';
}
/* ── pH-полоска ── */
_drawPHStrip(ctx) {
const { _g: g } = this;
const px = g.cx - g.r - 44;
const py = g.liqTop - 6;
const pw = 14; const ph = 88;
const hue = Math.round(this._pH / 14 * 270);
const col = `hsl(${hue},80%,52%)`;
_flask_rrect(ctx, px - pw / 2, py, pw, ph, 3);
ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 8; ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 0.8;
for (let i = 0; i <= 14; i += 2) {
const ry = py + ph * (1 - i / 14);
ctx.beginPath(); ctx.moveTo(px - pw / 2 + 2, ry); ctx.lineTo(px + pw / 2 - 2, ry); ctx.stroke();
}
ctx.font = 'bold 10.5px monospace'; ctx.fillStyle = 'rgba(195,215,255,0.82)';
ctx.textAlign = 'center';
ctx.fillText('pH', px, py - 5);
ctx.fillText(this._pH.toFixed(1), px, py + ph + 15);
ctx.textAlign = 'left';
}
/* ── Бар H₂ ── */
_drawH2Bar(ctx) {
const { _g: g } = this;
const bx = g.cx - 46, by = g.nt - 30;
const bw = 92, bh = 10;
_flask_rrect(ctx, bx, by, bw, bh, 4);
ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill();
if (this._h2 > 0) {
const col = this._ignited ? '#EF476F' : '#4CC9F0';
ctx.shadowColor = col; ctx.shadowBlur = this._h2 > 0.45 ? 10 : 4;
_flask_rrect(ctx, bx, by, bw * this._h2, bh, 4);
ctx.fillStyle = col; ctx.fill(); ctx.shadowBlur = 0;
}
ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(190,215,255,0.72)';
ctx.textAlign = 'center'; ctx.fillText('H₂', g.cx, by - 5); ctx.textAlign = 'left';
if (this._h2 > 0.65 && !this._ignited) {
ctx.font = 'bold 10px sans-serif'; ctx.fillStyle = '#FFD166';
ctx.textAlign = 'center';
ctx.fillText('Поднести огонь!', g.cx, by - 16);
ctx.textAlign = 'left';
}
if (this._ignited) {
ctx.font = 'bold 10px sans-serif'; ctx.fillStyle = '#EF476F';
ctx.textAlign = 'center'; ctx.fillText('H₂ воспламенился!', g.cx, by - 16); ctx.textAlign = 'left';
}
}
/* ── Информационная панель ── */
_drawInfoPanel(ctx) {
const { _g: g, W } = this;
const eq = FlaskSim.EQ[`${this.metalType}_${this.acidType}`] || '—';
const eqY = g.cy + g.r + 26;
ctx.font = '12.5px monospace'; ctx.fillStyle = 'rgba(185,215,255,0.78)';
ctx.textAlign = 'center'; ctx.fillText(ChemVisuals.cleanIcons(eq), W * 0.44, eqY); ctx.textAlign = 'left';
if (this._passiv) {
ctx.font = 'bold 11px sans-serif'; ctx.fillStyle = '#FFD166';
ctx.textAlign = 'center';
ctx.fillText('Пассивация: Fe покрыт оксидной плёнкой — реакция прекратилась', W * 0.44, eqY + 19);
ctx.textAlign = 'left';
}
if (this._metal && this._metal.mass > 0.1 && this._rxRate > 0) {
const bx = g.cx - g.r, by = g.cy + g.r - 6;
const bw = g.r * 2;
_flask_rrect(ctx, bx, by, bw, 5, 2);
ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill();
const col = this._rxRate > 0.6 ? '#EF476F' : this._rxRate > 0.3 ? '#FFD166' : '#7BF5A4';
_flask_rrect(ctx, bx, by, bw * this._rxRate, 5, 2);
ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 4; ctx.fill(); ctx.shadowBlur = 0;
}
}
_drawHint(ctx) {
const { _g: g } = this;
ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.32)';
ctx.textAlign = 'center';
ctx.fillText('Нажмите «Бросить металл» для начала реакции', g.cx, g.cy + 14);
ctx.textAlign = 'left';
}
/* ── Вспомогательные ──────────────────────────────────────────── */
_tint(hex, d) {
const n = parseInt(hex.slice(1), 16);
const c = v => Math.max(0, Math.min(255, v));
return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`;
}
info() {
const m = this._metal;
const md = m ? FlaskSim.METALS[m.type] : null;
return {
metal: md?.name ?? '—',
mass: m ? m.mass.toFixed(2) : '0',
temp: this._temp.toFixed(1),
pH: this._pH.toFixed(2),
h2pct: (this._h2 * 100).toFixed(0),
rate: (this._rxRate * 100).toFixed(0),
reacts: md ? md.acids.includes(this.acidType) : false,
};
}
}
/* ── Util: скруглённый прямоугольник ─────────────────────────── */
function _flask_rrect(ctx, x, y, w, h, r) {
if (w <= 0 || h <= 0) return;
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}