Files
Learn_System/frontend/js/labs/flask.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

1157 lines
44 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;
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;
}
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; }
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;
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);
}
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--;
}
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,
});
}
}
/* ════════════════════════════════════════════════════════════════
РЕНДЕРИНГ
════════════════════════════════════════════════════════════════ */
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;
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();
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);
}
/* ── Тень/отражение колбы на столе ── */
_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(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();
}