Files
Learn_System/frontend/js/labs/flask.js
T
Maxim Dolgolyov ea2526dc73 feat(labs): 4 школьные хим. симы + визуальная прокачка лаборатории
4 НОВЫЕ СИМЫ (школьная программа 8-11 классов):

Органика (organic.js, 1545 строк):
- Конструктор молекул: drag атомов C/H/O/N/Cl/S, валентности, click-pair bonds
- Авто-определение класса: алкан/алкен/алкин/спирт/альдегид/кислота/эфир/амин/аромат
- IUPAC-имена для C1-C10
- Гомологические ряды: 7 рядов с slider количества углеродов, M, T_кип, T_пл
- 6 качественных реакций: Br₂ вода, KMnO₄, Ag₂O/NH₃ (серебряное зеркало), Cu(OH)₂, FeCl₃, I₂

Периодическая таблица (periodic.js, 118 элементов):
- Стандартный вид 18×9 + лантаноиды/актиноиды
- Карточка элемента: Z, M, конфигурация, степени окисления, ЭО, ρ, T_пл/T_кип
- Боровская модель электронных оболочек (анимированная)
- Подсветка: 11 типов / s/p/d/f-блоки / без подсветки
- Графики свойств по периоду/группе (ЭО, M, плотность, T_пл/T_кип)
- Поиск по символу/имени/Z/массе

Качественный анализ (qualanalysis.js, 24 иона):
- 15 катионов: Na/K/NH₄/Mg/Ca/Ba/Al/Fe²⁺/Fe³⁺/Cu/Ag/Pb/Zn/H/OH
- 10 анионов: Cl/Br/I/SO₄/SO₃/CO₃/NO₃/PO₄/S²/CH₃COO
- 9 реактивов + пламя
- 2 режима: «определи ион» и «неизвестное вещество» с логом наблюдений
- Анимация капли, осадка с цветом, газовых пузырей, пламени

Растворы (solutions.js, 4 режима):
- Калькулятор: m_в, m_р-ра, ρ, T → ω, ν, C_М, C_Н с понятной логикой пересчёта
- Разбавление с before/after визуализацией
- Смешивание двух растворов с правилом рычага
- Кривые растворимости 8 веществ + задача перекристаллизации
- 15 пресетов веществ (NaCl, NaOH, H₂SO₄, CuSO₄·5H₂O, глюкоза, сахароза, ...)

ВИЗУАЛЬНАЯ ПРОКАЧКА (_chem_visuals.js, helper file):

12 функций школьной лабораторной графики:
- drawErlenmeyer / drawBeaker / drawBurette / drawTube — proper SVG-paths со шкалой
- drawSpiritLamp — стеклянный резервуар + фитиль + анимированное пламя
- animateGasBubbles / animatePrecipitateFall — анимация продуктов
- drawProductLabel — fade-in/out стрелка ↑/↓ с подписью
- drawEduTooltip — bubble с пояснением реакции
- drawDeskBackground / drawVesselShadow — лабораторный фон
- drawPHStrip — pH-индикаторная полоса с маркером

Прокачено 6 chem-сим: chemsandbox, flask, titration, electrolysis, ionexchange, redox
Каждая получила: фон парты, тени под колбами, анимированные стрелки продуктов,
educational tooltips из поля 'why' реакции. Спиртовка с пламенем в flask.
pH-полоса в titration.

Каталог теперь: 39 симуляций (было 35 + 4 новых).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:08: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(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();
}