Files
Learn_System/frontend/js/labs/redox.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

574 lines
25 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';
/* ====================================================================
RedoxSim — Окислительно-восстановительные реакции
==================================================================== */
class RedoxSim {
/* ── Данные реакций ──────────────────────────────────────────────── */
static RXN = {
fe_cu: {
name: 'Fe + CuSO₄',
reducer: { f: 'Fe', name: 'Железо', color: '#A0856A', ox: 0 },
oxidizer: { f: 'Cu²⁺', name: 'Ион меди', color: '#29B6F6', ox: 2 },
prod_r: { f: 'Fe²⁺', color: '#66BB6A', ox: 2 },
prod_o: { f: 'Cu', color: '#C87840', ox: 0, solid: true },
e: 2,
half_r: 'Fe⁰ 2e⁻ <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> Fe²⁺ окисление',
half_o: 'Cu²⁺ + 2e⁻ <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> Cu⁰ восстановление',
eq_ion: 'Fe + Cu²⁺ <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> Fe²⁺ + Cu<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>',
eq_mol: 'Fe + CuSO₄ <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> FeSO₄ + Cu<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>',
sol_a: '#1565C040', sol_b: '#2E7D3230',
precip: true, pcolor: '#C87840', pname: 'медь Cu',
},
zn_hcl: {
name: 'Zn + HCl',
reducer: { f: 'Zn', name: 'Цинк', color: '#90A4AE', ox: 0 },
oxidizer: { f: 'H⁺', name: 'Ион H⁺', color: '#EF5350', ox: 1 },
prod_r: { f: 'Zn²⁺', color: '#80CBC4', ox: 2 },
prod_o: { f: '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>', color: '#EEEEEE', ox: 0, gas: true },
e: 2,
half_r: 'Zn⁰ 2e⁻ <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> Zn²⁺ окисление',
half_o: '2H⁺ + 2e⁻ <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> 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> восстановление',
eq_ion: 'Zn + 2H⁺ <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> Zn²⁺ + 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>',
eq_mol: '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>',
sol_a: '#EF525228', sol_b: '#E0F2F118',
gas: true, gcolor: '#CFD8DC', gname: 'водород H₂',
},
cl2_ki: {
name: 'Cl₂ + KI',
reducer: { f: 'I⁻', name: 'Иодид-ион', color: '#CE93D8', ox: -1 },
oxidizer: { f: 'Cl₂', name: 'Хлор', color: '#D4E157', ox: 0 },
prod_r: { f: 'I₂', color: '#6A1B9A', ox: 0, solid: true },
prod_o: { f: 'Cl⁻', color: '#AED581', ox: -1 },
e: 1,
half_r: '2I⁻ 2e⁻ <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> I₂ окисление',
half_o: 'Cl₂ + 2e⁻ <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> 2Cl⁻ восстановление',
eq_ion: 'Cl₂ + 2I⁻ <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> I₂<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> + 2Cl⁻',
eq_mol: 'Cl₂ + 2KI <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> I₂<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> + 2KCl',
sol_a: '#7B1FA230', sol_b: '#F9A82520',
precip: true, pcolor: '#6A1B9A', pname: 'йод I₂',
},
kmno4: {
name: 'KMnO₄ + FeSO₄',
reducer: { f: 'Fe²⁺', name: 'Ион Fe²⁺', color: '#66BB6A', ox: 2 },
oxidizer: { f: 'MnO₄⁻', name: 'Перманганат', color: '#AB47BC', ox: 7 },
prod_r: { f: 'Fe³⁺', color: '#FFA726', ox: 3 },
prod_o: { f: 'Mn²⁺', color: '#FFF9C4', ox: 2 },
e: 5,
half_r: 'Fe²⁺ e⁻ <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> Fe³⁺ (×5) окисление',
half_o: 'MnO₄⁻+8H⁺+5e⁻<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>Mn²⁺+4H₂O восстановление',
eq_ion: 'MnO₄⁻ + 5Fe²⁺ + 8H⁺ <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> Mn²⁺ + 5Fe³⁺ + 4H₂O',
eq_mol: '2KMnO₄ + 10FeSO₄ + 8H₂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> 2MnSO₄ + 5Fe₂(SO₄)₃ + K₂SO₄ + 8H₂O',
sol_a: '#7B1FA250', sol_b: '#F9A82515',
colorChange: true, newSolColor: '#FFF9C428', newName: 'бесцветный MnSO₄',
},
cu_fecl3: {
name: 'Cu + FeCl₃',
reducer: { f: 'Cu', name: 'Медь', color: '#C87840', ox: 0 },
oxidizer: { f: 'Fe³⁺', name: 'Ион Fe³⁺', color: '#FFA726', ox: 3 },
prod_r: { f: 'Cu²⁺', color: '#29B6F6', ox: 2 },
prod_o: { f: 'Fe²⁺', color: '#66BB6A', ox: 2 },
e: 1,
half_r: 'Cu⁰ 2e⁻ <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> Cu²⁺ окисление',
half_o: '2Fe³⁺ + 2e⁻ <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> 2Fe²⁺ восстановление',
eq_ion: 'Cu + 2Fe³⁺ <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> Cu²⁺ + 2Fe²⁺',
eq_mol: 'Cu + 2FeCl₃ <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> CuCl₂ + 2FeCl₂',
sol_a: '#E6510018', sol_b: '#1565C018',
colorChange: true, newSolColor: '#1565C030', newName: 'синий CuCl₂',
},
};
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.rxnId = 'fe_cu';
this._raf = null;
this._last = 0;
this._t = 0;
this._phase = 'idle'; // idle | mixing | reacting | done
this._prog = 0;
this._colorT = 0;
this._stepIdx = 0;
this._stepTimer = 0;
this._eParts = [];
this._rParts = [];
this._oParts = [];
this._precip = [];
this._gas = [];
/* edu-tooltip + product labels */
this._eduTooltipAge = -1;
this._eduTooltipLines = [];
this._prodLabelAge = -1;
this._prodLabelText = '';
this._prodLabelType = 'precip';
this.W = 0; this.H = 0;
this.onUpdate = null;
this.fit();
this._initParts();
}
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._initParts();
}
setReaction(id) {
if (!RedoxSim.RXN[id]) return;
this.rxnId = id;
this.reset();
}
reset() {
this._phase = 'idle'; this._prog = 0; this._colorT = 0;
this._stepIdx = 0; this._stepTimer = 0;
this._eParts = []; this._precip = []; this._gas = [];
this._initParts();
this.draw();
}
_initParts() {
const { W, H } = this;
const N = 16;
this._rParts = Array.from({ length: N }, () => ({
x: W * 0.22 + (Math.random() - 0.5) * W * 0.22,
y: H * 0.42 + (Math.random() - 0.5) * H * 0.34,
vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6,
r: 11 + Math.random() * 4,
phase: Math.random() * Math.PI * 2,
trans: false, flashT: 0,
}));
this._oParts = Array.from({ length: N }, () => ({
x: W * 0.78 + (Math.random() - 0.5) * W * 0.22,
y: H * 0.42 + (Math.random() - 0.5) * H * 0.34,
vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6,
r: 11 + Math.random() * 4,
phase: Math.random() * Math.PI * 2,
trans: false, flashT: 0,
}));
}
start() {
if (this._phase !== 'idle') this.reset();
this._phase = 'mixing'; this._prog = 0;
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.8 });
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; }
/* ── Физика ─────────────────────────────────────────────────────── */
_tick(t) {
const dt = Math.min((t - this._last) / 1000, 0.05);
this._last = t; this._t += dt;
if (window.LabFX) LabFX.particles.update(dt);
const { W, H } = this;
const rxn = RedoxSim.RXN[this.rxnId];
if (this._phase === 'mixing') {
this._prog = Math.min(1, this._prog + dt * 0.38);
const all = [...this._rParts, ...this._oParts];
all.forEach(p => {
const tx = W * 0.5 + (Math.random() - 0.5) * W * 0.52;
const ty = H * 0.44 + (Math.random() - 0.5) * H * 0.34;
p.vx += (tx - p.x) * 0.003 * this._prog;
p.vy += (ty - p.y) * 0.003 * this._prog;
p.vx += (Math.random() - 0.5) * 0.5;
p.vy += (Math.random() - 0.5) * 0.5;
p.vx *= 0.90; p.vy *= 0.90;
p.x += p.vx; p.y += p.vy;
p.phase += dt * 1.5;
this._clamp(p);
});
if (this._prog >= 1) { this._phase = 'reacting'; this._prog = 0; }
}
if (this._phase === 'reacting') {
this._prog = Math.min(1, this._prog + dt * 0.14);
this._colorT = this._prog;
this._stepTimer += dt;
if (this._stepTimer > 1.6 && this._stepIdx < 3) { this._stepIdx++; this._stepTimer = 0; }
const all = [...this._rParts, ...this._oParts];
all.forEach(p => {
p.vx += (Math.random() - 0.5) * 0.9;
p.vy += (Math.random() - 0.5) * 0.9;
p.vx *= 0.87; p.vy *= 0.87;
p.x += p.vx; p.y += p.vy;
p.phase += dt * 2;
p.flashT = Math.max(0, p.flashT - dt * 3);
this._clamp(p);
});
/* Transform particles proportional to progress */
const rT = Math.floor(this._prog * this._rParts.length);
const oT = Math.floor(this._prog * this._oParts.length);
this._rParts.forEach((p, i) => {
if (i < rT && !p.trans) { p.trans = true; p.flashT = 1; }
});
this._oParts.forEach((p, i) => {
if (i < oT && !p.trans) {
p.trans = true; p.flashT = 1;
if (rxn.precip) this._precip.push({ x: p.x, y: p.y, vy: 0, r: 3 + Math.random() * 3, settled: false });
if (rxn.gas) this._gas.push({ x: p.x, y: p.y, vy: -(1.5 + Math.random()), vx: (Math.random() - 0.5) * 0.5, r: 2 + Math.random() * 3, alpha: 1 });
}
});
if (Math.random() < 0.22 && this._prog > 0.05) this._spawnE();
if (this._prog >= 1) { this._phase = 'done'; this._stepIdx = 3; }
}
if (this._phase === 'done') {
const all = [...this._rParts, ...this._oParts];
all.forEach(p => {
p.vx += (Math.random() - 0.5) * 0.45;
p.vy += (Math.random() - 0.5) * 0.45;
p.vx *= 0.92; p.vy *= 0.92;
p.x += p.vx; p.y += p.vy;
p.phase += dt;
this._clamp(p);
});
/* trigger product label + edu-tooltip once */
if (window.ChemVisuals && this._prodLabelAge < 0) {
const rxn = RedoxSim.RXN[this.rxnId];
if (rxn.precip) {
this._prodLabelText = (rxn.pname || '') + ' ';
this._prodLabelType = 'precip';
this._prodLabelAge = 0;
} else if (rxn.gas) {
this._prodLabelText = (rxn.gname || '') + ' ';
this._prodLabelType = 'gas';
this._prodLabelAge = 0;
}
if (this._eduTooltipAge < 0 && rxn.eq_mol) {
const stripSVG = s => (s || '').replace(/<[^>]+>/g, '->');
const eqClean = stripSVG(rxn.eq_ion || rxn.eq_mol).slice(0, 38);
this._eduTooltipLines = [
(rxn.name || '').slice(0, 34),
'e⁻: от восстановителя к окислителю',
eqClean,
].filter(Boolean).slice(0, 4);
this._eduTooltipAge = 0;
}
}
}
/* advance ages */
if (this._eduTooltipAge >= 0) {
this._eduTooltipAge += dt / 4.0;
if (this._eduTooltipAge >= 1.0) this._eduTooltipAge = -1;
}
if (this._prodLabelAge >= 0) {
this._prodLabelAge += dt / 3.0;
if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1;
}
/* Electrons — quadratic bezier arc */
this._eParts = this._eParts.filter(e => e.t < 1);
this._eParts.forEach(e => {
e.t = Math.min(1, e.t + dt * e.spd);
const u = e.t;
e.x = (1-u)*(1-u)*e.x0 + 2*(1-u)*u*e.mx + u*u*e.x1;
e.y = (1-u)*(1-u)*e.y0 + 2*(1-u)*u*e.my + u*u*e.y1;
e.alpha = u < 0.1 ? u * 10 : u > 0.85 ? (1 - u) / 0.15 : 1;
});
/* Precipitate */
this._precip.forEach(p => {
if (!p.settled) {
p.vy = Math.min(p.vy + 0.15, 5);
p.y += p.vy;
if (p.y >= H * 0.78) { p.y = H * 0.78; p.vy = 0; p.settled = true; }
}
});
/* Gas */
this._gas.forEach(b => { b.y += b.vy; b.x += b.vx; b.vy -= 0.01; b.alpha -= 0.005; });
this._gas = this._gas.filter(b => b.alpha > 0 && b.y > 10);
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
_clamp(p) {
const { W, H } = this;
const bot = H * 0.78;
if (p.x < p.r + 6) { p.x = p.r + 6; p.vx *= -0.5; }
if (p.x > W - p.r - 6) { p.x = W - p.r - 6; p.vx *= -0.5; }
if (p.y < p.r + 6) { p.y = p.r + 6; p.vy *= -0.5; }
if (p.y > bot - p.r) { p.y = bot - p.r; p.vy *= -0.5; }
}
_spawnE() {
const freeR = this._rParts.filter(p => !p.trans);
const freeO = this._oParts.filter(p => !p.trans);
if (!freeR.length || !freeO.length) return;
const rp = freeR[Math.floor(Math.random() * freeR.length)];
const op = freeO[Math.floor(Math.random() * freeO.length)];
const mx = (rp.x + op.x) / 2;
const my = Math.min(rp.y, op.y) - 45 - Math.random() * 40;
this._eParts.push({
x0: rp.x, y0: rp.y, x1: op.x, y1: op.y,
mx, my, x: rp.x, y: rp.y,
t: 0, spd: 0.65 + Math.random() * 0.45, alpha: 0,
});
// LabFX: electron transfer spark at the midpoint
if (window.LabFX) {
LabFX.particles.emit({ ctx: this.ctx, x: mx, y: my, count: 2,
color: '#06D6E0', speed: 30, spread: 3.14, angle: 0,
gravity: 0, life: 200, shape: 'spark', glow: true });
}
}
/* ── Рендеринг ──────────────────────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
const rxn = RedoxSim.RXN[this.rxnId];
/* Background */
ctx.fillStyle = '#07071A';
ctx.fillRect(0, 0, W, H);
/* Dot grid */
ctx.fillStyle = 'rgba(255,255,255,0.07)';
for (let x = 0; x < W; x += 28) {
for (let y = 0; y < H; y += 28) {
ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill();
}
}
/* Solution tint */
const bx = W * 0.04, bw = W * 0.92, bTop = H * 0.08, bBot = H * 0.80;
if (this._phase === 'idle') {
if (rxn.sol_a) { ctx.save(); ctx.fillStyle = rxn.sol_a; ctx.fillRect(bx, bTop, bw / 2, bBot - bTop); ctx.restore(); }
if (rxn.sol_b) { ctx.save(); ctx.fillStyle = rxn.sol_b; ctx.fillRect(bx + bw / 2, bTop, bw / 2, bBot - bTop); ctx.restore(); }
/* Dashed divider */
ctx.save();
ctx.setLineDash([6, 5]); ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(W / 2, bTop + 4); ctx.lineTo(W / 2, bBot - 4); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
/* Zone labels */
ctx.save();
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = 'bold 12px sans-serif';
ctx.fillText(rxn.reducer.name, W * 0.22, bTop + 8);
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.font = '10px sans-serif';
ctx.fillText('восстановитель', W * 0.22, bTop + 26);
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = 'bold 12px sans-serif';
ctx.fillText(rxn.oxidizer.name, W * 0.78, bTop + 8);
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.font = '10px sans-serif';
ctx.fillText('окислитель', W * 0.78, bTop + 26);
ctx.restore();
} else if (this._colorT > 0) {
if (rxn.sol_a) {
ctx.save(); ctx.globalAlpha = 1 - this._colorT * 0.7;
ctx.fillStyle = rxn.sol_a; ctx.fillRect(bx, bTop, bw, bBot - bTop); ctx.restore();
}
if (rxn.colorChange && rxn.newSolColor) {
ctx.save(); ctx.globalAlpha = this._colorT * 0.55;
ctx.fillStyle = rxn.newSolColor; ctx.fillRect(bx, bTop, bw, bBot - bTop); ctx.restore();
}
}
/* desk */
if (window.ChemVisuals) {
ChemVisuals.drawDeskBackground(ctx, W, H, H * 0.82);
ChemVisuals.drawVesselShadow(ctx, W / 2, H * 0.82, W * 0.38);
}
this._drawBeaker(ctx, W, H);
this._drawParticles(ctx, rxn);
this._drawElectrons(ctx);
if (rxn.precip) this._drawPrecip(ctx, rxn);
if (rxn.gas) this._drawGas(ctx, rxn);
this._drawPanel(ctx, W, H, rxn);
if (window.LabFX) LabFX.particles.draw(this.ctx);
/* animated product label */
if (window.ChemVisuals && this._prodLabelAge >= 0) {
const labelY = this._prodLabelType === 'gas' ? H * 0.12 : H * 0.76;
ChemVisuals.drawProductLabel(ctx, W / 2, labelY, this._prodLabelText, this._prodLabelType, this._prodLabelAge);
if (this._prodLabelType === 'gas') {
ChemVisuals.animateGasBubbles(ctx, W / 2, H * 0.16, rxn.gcolor || 'rgba(200,235,255,0.8)', this._t);
} else {
ChemVisuals.animatePrecipitateFall(ctx, W / 2, H * 0.72, rxn.pcolor || '#CCC', this._t);
}
}
/* edu tooltip */
if (window.ChemVisuals && this._eduTooltipAge >= 0 && this._eduTooltipLines.length > 0) {
ChemVisuals.drawEduTooltip(ctx, W / 2, H * 0.10, 210, this._eduTooltipLines, this._eduTooltipAge);
}
}
_drawBeaker(ctx, W, H) {
const bx = W * 0.04, by = H * 0.08, bw = W * 0.92, bh = H * 0.73;
ctx.save();
ctx.strokeStyle = 'rgba(120,185,255,0.60)'; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(bx, by); ctx.lineTo(bx, by + bh);
ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw, by);
ctx.stroke();
ctx.beginPath(); ctx.moveTo(bx - 5, by); ctx.lineTo(bx + bw + 5, by); ctx.stroke();
/* Left highlight */
const hlg = ctx.createLinearGradient(bx, by, bx + 18, by + bh);
hlg.addColorStop(0, 'rgba(200,230,255,0.18)');
hlg.addColorStop(1, 'rgba(200,230,255,0.02)');
ctx.strokeStyle = hlg; ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(bx + 8, by + 8); ctx.lineTo(bx + 8, by + bh - 8); ctx.stroke();
ctx.restore();
}
_drawParticles(ctx, rxn) {
const draw1 = (p, spec, prod) => {
const s = p.trans ? prod : spec;
ctx.save();
ctx.shadowColor = p.flashT > 0 ? '#FFFFFF' : s.color;
ctx.shadowBlur = p.flashT > 0 ? 28 * p.flashT : 8 + Math.sin(p.phase) * 3;
ctx.globalAlpha = 0.88;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.flashT > 0 ? `rgba(255,255,255,${p.flashT * 0.9})` : s.color;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1; ctx.stroke();
ctx.shadowBlur = 0; ctx.globalAlpha = 1;
/* Oxidation state */
const ox = p.trans ? prod.ox : spec.ox;
const oxStr = ox > 0 ? `+${ox}` : ox < 0 ? `${ox}` : '0';
ctx.fillStyle = p.trans ? '#FFD166' : 'rgba(255,255,255,0.88)';
ctx.font = `bold ${Math.round(p.r * 0.78)}px monospace`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(oxStr, p.x, p.y);
ctx.restore();
};
this._rParts.forEach(p => draw1(p, rxn.reducer, rxn.prod_r));
this._oParts.forEach(p => draw1(p, rxn.oxidizer, rxn.prod_o));
}
_drawElectrons(ctx) {
this._eParts.forEach(e => {
ctx.save();
ctx.globalAlpha = e.alpha;
ctx.shadowColor = '#4FC3F7'; ctx.shadowBlur = 16;
ctx.beginPath(); ctx.arc(e.x, e.y, 5.5, 0, Math.PI * 2);
ctx.fillStyle = '#4FC3F7'; ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('e⁻', e.x, e.y);
ctx.restore();
});
}
_drawPrecip(ctx, rxn) {
if (!this._precip.length) return;
ctx.save();
this._precip.forEach(p => {
ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 4;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = rxn.pcolor; ctx.fill();
});
ctx.restore();
/* Label when settled */
const settled = this._precip.filter(p => p.settled);
if (settled.length > 3) {
ctx.save();
ctx.fillStyle = rxn.pcolor; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 6;
ctx.fillText(`↓ ${rxn.pname}`, this.W / 2, this.H * 0.80 - 4);
ctx.restore();
}
}
_drawGas(ctx, rxn) {
this._gas.forEach(b => {
ctx.save(); ctx.globalAlpha = b.alpha * 0.75;
ctx.shadowColor = rxn.gcolor; ctx.shadowBlur = 5;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.strokeStyle = rxn.gcolor; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
});
const count = this._gas.length;
if (count > 2) {
ctx.save();
ctx.fillStyle = rxn.gcolor; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.shadowColor = rxn.gcolor; ctx.shadowBlur = 6;
ctx.fillText(`↑ ${rxn.gname}`, this.W / 2, this.H * 0.12);
ctx.restore();
}
}
_drawPanel(ctx, W, H, rxn) {
const py = H * 0.82;
ctx.fillStyle = 'rgba(7,7,26,0.94)';
ctx.fillRect(0, py, W, H - py);
ctx.strokeStyle = 'rgba(100,165,255,0.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
if (this._phase === 'idle') {
ctx.fillStyle = '#37474F'; ctx.font = '11px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('← Нажми «Начать» для запуска реакции →', W / 2, py + (H - py) / 2);
return;
}
const steps = [
{ lbl: 'Молекулярное:', txt: rxn.eq_mol, col: '#B0BEC5' },
{ lbl: 'Окисление:', txt: rxn.half_r, col: '#EF476F' },
{ lbl: 'Восстановление:', txt: rxn.half_o, col: '#4CC9F0' },
{ lbl: 'Ионное:', txt: rxn.eq_ion, col: '#FFD166' },
];
const panH = H - py;
const n = Math.min(this._stepIdx + 1, steps.length);
for (let i = 0; i < n; i++) {
const s = steps[i];
const y = py + 11 + i * (panH * 0.22);
ctx.save();
if (i === this._stepIdx && this._phase !== 'done') {
ctx.fillStyle = 'rgba(255,255,255,0.04)';
ctx.fillRect(8, y - 9, W - 16, 20);
}
ctx.fillStyle = s.col; ctx.font = 'bold 9.5px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(s.lbl, 14, y);
ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)';
ctx.font = '9.5px monospace';
ctx.fillText(s.txt, 14 + ctx.measureText(s.lbl).width + 8, y);
ctx.restore();
}
if (this._phase === 'done') {
ctx.save();
ctx.fillStyle = '#7BF5A4'; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.shadowColor = '#7BF5A4'; ctx.shadowBlur = 8;
ctx.fillText('✓ Реакция завершена', W - 14, py + 3);
ctx.restore();
}
}
info() {
const rxn = RedoxSim.RXN[this.rxnId];
return {
rxn: rxn.name,
phase: this._phase,
prog: Math.round((this._phase === 'reacting' ? this._prog : this._phase === 'done' ? 1 : 0) * 100),
e: rxn.e,
};
}
}