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

630 lines
23 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';
/**
* ElectrolysisSim v2 — Электролиз водных растворов
* Закон Фарадея: m = M·I·t / (n·F), F = 96485 Кл/моль
* Чистый рерайт: стабильная физика, ионная анимация, пузырьки, осадок.
*/
class ElectrolysisSim {
static F = 96485;
static BG = '#0b0b1a';
static FONT = 'Manrope, system-ui, sans-serif';
static ELECTROLYTES = {
NaCl: {
name: 'NaCl', displayName: 'NaCl (водный р-р)',
cation: 'Na\u207A', anion: 'Cl\u207B',
M: 2, n: 2, R: 8,
solColor: [160, 200, 230],
cathodeProduct: 'H\u2082', anodeProduct: 'Cl\u2082',
depositColor: null,
cathodeBubColor: 'rgba(160,210,255,0.55)',
anodeBubColor: 'rgba(180,255,140,0.50)',
cathodeEq: '2H\u2082O + 2e\u207B \u2192 H\u2082 + 2OH\u207B',
anodeEq: '2Cl\u207B \u2212 2e\u207B \u2192 Cl\u2082',
},
CuSO4: {
name: 'CuSO\u2084', displayName: 'CuSO\u2084 (водный р-р)',
cation: 'Cu\u00B2\u207A', anion: 'SO\u2084\u00B2\u207B',
M: 63.546, n: 2, R: 12,
solColor: [55, 120, 210],
cathodeProduct: 'Cu\u2193', anodeProduct: 'O\u2082',
depositColor: '#b87333',
cathodeBubColor: null,
anodeBubColor: 'rgba(200,210,255,0.50)',
cathodeEq: 'Cu\u00B2\u207A + 2e\u207B \u2192 Cu\u2193',
anodeEq: '2H\u2082O \u2212 4e\u207B \u2192 O\u2082 + 4H\u207A',
},
H2SO4: {
name: 'H\u2082SO\u2084', displayName: 'H\u2082SO\u2084 (водный р-р)',
cation: 'H\u207A', anion: 'SO\u2084\u00B2\u207B',
M: 2, n: 2, R: 6,
solColor: [200, 200, 215],
cathodeProduct: 'H\u2082', anodeProduct: 'O\u2082',
depositColor: null,
cathodeBubColor: 'rgba(160,210,255,0.55)',
anodeBubColor: 'rgba(200,210,255,0.50)',
cathodeEq: '2H\u207A + 2e\u207B \u2192 H\u2082',
anodeEq: '2H\u2082O \u2212 4e\u207B \u2192 O\u2082 + 4H\u207A',
},
};
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.voltage = 6;
this.electrolyte = 'NaCl';
this.speed = 1;
this._time = 0;
this._massDeposit = 0;
this._gasVolume = 0;
this._depositH = 0;
this._ions = [];
this._bubbles = [];
this._electronPhase = 0;
this.playing = false;
this._raf = null;
this._lastTs = null;
this.onUpdate = null;
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
// ── public API ────────────────────────────────────────────────
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 640;
const h = this.canvas.offsetHeight || 420;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
this._initIons();
}
getParams() { return { voltage: this.voltage, electrolyte: this.electrolyte }; }
setParams({ voltage, electrolyte } = {}) {
if (voltage !== undefined) this.voltage = Math.max(1, Math.min(12, +voltage));
if (electrolyte !== undefined) {
// accept both 'nacl' (from lab.html) and 'NaCl' (canonical)
const keyMap = { nacl: 'NaCl', cuso4: 'CuSO4', h2so4: 'H2SO4' };
const key = keyMap[String(electrolyte).toLowerCase()] || electrolyte;
if (ElectrolysisSim.ELECTROLYTES[key] && this.electrolyte !== key) {
this.electrolyte = key;
this.reset(); return;
}
}
this.draw(); this._emit();
}
preset(name) {
const map = { nacl: ['NaCl', 6], cuso4: ['CuSO4', 4], h2so4: ['H2SO4', 3] };
const [el, v] = map[name] || map.nacl;
this.voltage = v; this.electrolyte = el;
this.reset();
}
reset() {
this.pause();
this._time = 0; this._massDeposit = 0;
this._gasVolume = 0; this._depositH = 0;
this._bubbles = []; this._electronPhase = 0;
this._initIons();
this.draw(); this._emit();
}
play() { if (this.playing) return; this.playing = true; this._lastTs = null; this._tick(); }
pause() { this.playing = false; if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } }
start() { this.play(); }
stop() { this.pause(); }
info() {
return {
voltage: this.voltage,
current: +this._current().toFixed(3),
electrolyte: this.electrolyte,
massDeposited: +this._massDeposit.toFixed(4),
gasVolume: +this._gasVolume.toFixed(2),
time: +this._time.toFixed(1),
};
}
// ── internals ─────────────────────────────────────────────────
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_el() { return ElectrolysisSim.ELECTROLYTES[this.electrolyte]; }
_current() { return this.voltage / this._el().R; }
_cell() {
const { W, H } = this;
const cw = Math.min(W * 0.50, 300);
const ch = Math.min(H * 0.48, 210);
return { cx: (W - cw) / 2, cy: H * 0.28, cw, ch };
}
_electrodes() {
const { cx, cy, cw, ch } = this._cell();
const ew = 13, eh = ch * 0.70, gap = cw * 0.12;
const ey = cy + ch - eh - 8;
return {
cathode: { x: cx + gap, y: ey, w: ew, h: eh },
anode: { x: cx + cw - gap - ew, y: ey, w: ew, h: eh },
};
}
_initIons() {
this._ions = [];
const { cx, cy, cw, ch } = this._cell();
if (!cw || !ch) return;
const el = this._el();
for (let i = 0; i < 30; i++) {
const isCat = i < 15;
this._ions.push({
x: cx + 18 + Math.random() * (cw - 36),
y: cy + 12 + Math.random() * (ch - 24),
vx: (Math.random() - 0.5) * 0.7,
vy: (Math.random() - 0.5) * 0.5,
charge: isCat ? 1 : -1,
label: isCat ? el.cation : el.anion,
color: isCat ? '#EF476F' : '#06D6E0',
});
}
}
_spawnIon(charge) {
const { cx, cy, cw, ch } = this._cell();
const el = this._el();
this._ions.push({
x: charge > 0 ? cx + 8 : cx + cw - 8,
y: cy + 12 + Math.random() * (ch - 24),
vx: charge > 0 ? 0.55 : -0.55,
vy: (Math.random() - 0.5) * 0.4,
charge,
label: charge > 0 ? el.cation : el.anion,
color: charge > 0 ? '#EF476F' : '#06D6E0',
});
}
_spawnBubble(x, y, color) {
this._bubbles.push({
x, y,
r: 1.5 + Math.random() * 2.5,
vx: (Math.random() - 0.5) * 0.3,
vy: -(0.5 + Math.random() * 0.9),
life: 1,
decay: 0.005 + Math.random() * 0.007,
color,
});
}
// ── simulation tick ────────────────────────────────────────────
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this._lastTs) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
const dt = rawDt * this.speed;
this._lastTs = ts;
if (window.LabFX) LabFX.particles.update(rawDt);
this._step(dt); this.draw(); this._emit(); this._tick();
});
}
_step(dt) {
const el = this._el(), I = this._current();
const { cx, cy, cw, ch } = this._cell();
const elec = this._electrodes();
this._time += dt;
this._electronPhase = (this._electronPhase + dt * I * 1.2) % 1;
// Faraday's law
const molesPS = I / (el.n * ElectrolysisSim.F);
if (el.depositColor) {
this._massDeposit += el.M * molesPS * dt;
this._depositH = Math.min(elec.cathode.h * 0.72, this._depositH + dt * 0.14 * I);
}
this._gasVolume += molesPS * 22400 * dt;
// Ion drift + thermal jitter
const drift = I * 0.45;
this._fxIonTrailAcc = (this._fxIonTrailAcc || 0) + dt;
const doTrail = this._fxIonTrailAcc >= 0.1;
if (doTrail) this._fxIonTrailAcc = 0;
for (const ion of this._ions) {
ion.vx += (ion.charge > 0 ? -drift : drift) * dt + (Math.random() - 0.5) * 0.18;
ion.vy += (Math.random() - 0.5) * 0.14;
ion.vx = Math.max(-3.5, Math.min(3.5, ion.vx * 0.96));
ion.vy = Math.max(-3.5, Math.min(3.5, ion.vy * 0.96));
ion.x += ion.vx; ion.y += ion.vy;
ion.x = Math.max(cx + 4, Math.min(cx + cw - 4, ion.x));
ion.y = Math.max(cy + 4, Math.min(cy + ch - 4, ion.y));
// LabFX: ion trail dot
if (window.LabFX && doTrail && Math.random() < 0.3) {
LabFX.particles.emit({ ctx: this.ctx, x: ion.x, y: ion.y, count: 1,
color: '#FFD166', speed: 4, spread: 3.14, angle: 0,
gravity: 0, life: 300, shape: 'dot', glow: true });
}
}
// Ions reaching electrodes → discharge + bubbles
const rm = new Set();
for (let i = 0; i < this._ions.length; i++) {
const ion = this._ions[i];
if (ion.charge > 0 && ion.x <= elec.cathode.x + elec.cathode.w + 5) {
rm.add(i);
if (el.cathodeBubColor) {
for (let b = 0; b < 2; b++)
this._spawnBubble(
elec.cathode.x + elec.cathode.w + 2 + Math.random() * 4,
elec.cathode.y + Math.random() * elec.cathode.h,
el.cathodeBubColor);
// LabFX: rising ring bubble from cathode
if (window.LabFX) {
LabFX.particles.emit({ ctx: this.ctx,
x: elec.cathode.x + elec.cathode.w + 3,
y: elec.cathode.y + Math.random() * elec.cathode.h,
count: 1, color: '#FFFFFF', speed: 20, spread: 0.4, angle: -Math.PI / 2,
gravity: -60, life: 1500, shape: 'ring' });
}
}
}
if (ion.charge < 0 && ion.x >= elec.anode.x - 5) {
rm.add(i);
if (el.anodeBubColor) {
for (let b = 0; b < 2; b++)
this._spawnBubble(
elec.anode.x - 2 - Math.random() * 4,
elec.anode.y + Math.random() * elec.anode.h,
el.anodeBubColor);
// LabFX: rising ring bubble from anode
if (window.LabFX) {
LabFX.particles.emit({ ctx: this.ctx,
x: elec.anode.x - 3,
y: elec.anode.y + Math.random() * elec.anode.h,
count: 1, color: '#FFFFFF', speed: 20, spread: 0.4, angle: -Math.PI / 2,
gravity: -60, life: 1500, shape: 'ring' });
}
}
}
}
// Periodic fizz sound when current is flowing
if (window.LabFX && I > 0.01) {
this._fxFizzAcc = (this._fxFizzAcc || 0) + dt;
if (this._fxFizzAcc >= 2.0) {
this._fxFizzAcc = 0;
LabFX.sound.play('fizz', { volume: 0.2 });
}
}
this._ions = this._ions.filter((_, i) => !rm.has(i));
// Replenish ions to keep count ~15 each
let cat = 0, an = 0;
for (const ion of this._ions) ion.charge > 0 ? cat++ : an++;
while (cat < 15) { this._spawnIon(1); cat++; }
while (an < 15) { this._spawnIon(-1); an++; }
// Bubble physics
this._bubbles = this._bubbles.filter(b => {
b.x += b.vx + Math.sin(b.life * 22) * 0.12;
b.y += b.vy;
b.life -= b.decay;
return b.life > 0 && b.y > cy + 2;
});
}
// ── draw ──────────────────────────────────────────────────────
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
// Background
ctx.fillStyle = ElectrolysisSim.BG; ctx.fillRect(0, 0, W, H);
// Dot grid
ctx.fillStyle = 'rgba(255,255,255,0.018)';
for (let x = 22; x < W; x += 22)
for (let y = 22; y < H; y += 22) {
ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill();
}
/* desk surface */
if (window.ChemVisuals) {
const { cx, cy, cw, ch } = this._cell();
ChemVisuals.drawDeskBackground(ctx, W, H, cy + ch + 6);
ChemVisuals.drawVesselShadow(ctx, cx + cw / 2, cy + ch + 4, cw * 0.55);
}
this._drawWiresAndBattery();
this._drawCellBody();
this._drawSolution();
this._drawDeposit();
this._drawElectrodes();
this._drawBubbles();
this._drawIons();
this._drawLabels();
this._drawInfoPanel();
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
_drawCellBody() {
const { ctx } = this;
const { cx, cy, cw, ch } = this._cell();
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.stroke();
// glass shimmer
const gg = ctx.createLinearGradient(cx, cy, cx + 14, cy);
gg.addColorStop(0, 'rgba(255,255,255,0.05)');
gg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = gg; ctx.fillRect(cx + 1, cy + 1, 14, ch - 2);
ctx.restore();
}
_drawSolution() {
const { ctx } = this;
const { cx, cy, cw, ch } = this._cell();
const [r, g, b] = this._el().solColor;
ctx.save();
ctx.beginPath(); ctx.roundRect(cx + 2, cy + 2, cw - 4, ch - 4, 4); ctx.clip();
const sg = ctx.createLinearGradient(cx, cy, cx, cy + ch);
sg.addColorStop(0, `rgba(${r},${g},${b},0.06)`);
sg.addColorStop(1, `rgba(${r},${g},${b},0.22)`);
ctx.fillStyle = sg; ctx.fillRect(cx + 2, cy + 2, cw - 4, ch - 4);
ctx.restore();
}
_drawElectrodes() {
const { ctx } = this;
const e = this._electrodes();
const FN = ElectrolysisSim.FONT;
ctx.fillStyle = '#42425a';
ctx.beginPath(); ctx.roundRect(e.cathode.x, e.cathode.y, e.cathode.w, e.cathode.h, 3); ctx.fill();
ctx.strokeStyle = 'rgba(6,214,224,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(e.cathode.x, e.cathode.y, e.cathode.w, e.cathode.h, 3); ctx.stroke();
ctx.fillStyle = '#525268';
ctx.beginPath(); ctx.roundRect(e.anode.x, e.anode.y, e.anode.w, e.anode.h, 3); ctx.fill();
ctx.strokeStyle = 'rgba(239,71,111,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(e.anode.x, e.anode.y, e.anode.w, e.anode.h, 3); ctx.stroke();
ctx.font = `bold 16px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillStyle = '#06D6E0';
ctx.fillText('\u2212', e.cathode.x + e.cathode.w / 2, e.cathode.y - 3);
ctx.fillStyle = '#EF476F';
ctx.fillText('+', e.anode.x + e.anode.w / 2, e.anode.y - 3);
}
_drawDeposit() {
const el = this._el();
if (!el.depositColor || this._depositH < 1) return;
const { ctx } = this;
const c = this._electrodes().cathode;
const dh = Math.min(this._depositH, c.h * 0.72);
ctx.save();
const dg = ctx.createLinearGradient(c.x + c.w, c.y + c.h - dh, c.x + c.w + 10, c.y + c.h);
dg.addColorStop(0, 'rgba(184,115,51,0.35)');
dg.addColorStop(1, 'rgba(184,115,51,0.85)');
ctx.fillStyle = dg;
ctx.beginPath(); ctx.roundRect(c.x + c.w, c.y + c.h - dh, 10, dh, [2, 2, 0, 0]); ctx.fill();
ctx.shadowColor = '#b87333'; ctx.shadowBlur = 6;
ctx.fillStyle = 'rgba(210,150,80,0.5)';
ctx.beginPath(); ctx.roundRect(c.x + c.w, c.y + c.h - dh, 10, 3, [2, 2, 0, 0]); ctx.fill();
ctx.restore();
}
_drawIons() {
const { ctx } = this;
const FN = ElectrolysisSim.FONT;
ctx.save();
for (const ion of this._ions) {
const g = ctx.createRadialGradient(ion.x, ion.y, 0, ion.x, ion.y, 11);
g.addColorStop(0, ion.color + '2a'); g.addColorStop(1, ion.color + '00');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(ion.x, ion.y, 11, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = ion.color;
ctx.beginPath(); ctx.arc(ion.x, ion.y, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.68)';
ctx.font = `8px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(ion.label, ion.x, ion.y - 5);
}
ctx.restore();
}
_drawBubbles() {
const { ctx } = this;
ctx.save();
for (const b of this._bubbles) {
ctx.globalAlpha = b.life * 0.65;
ctx.strokeStyle = b.color; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = `rgba(255,255,255,${b.life * 0.18})`;
ctx.beginPath(); ctx.arc(b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
_drawWiresAndBattery() {
const { ctx } = this;
const { cx, cy, cw } = this._cell();
const e = this._electrodes();
const FN = ElectrolysisSim.FONT;
const cXt = e.cathode.x + e.cathode.w / 2; // cathode top center
const aXt = e.anode.x + e.anode.w / 2; // anode top center
const bx = cx + cw / 2; // battery center x
const by = cy - Math.max(42, this.H * 0.09); // battery y
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1.5;
// Cathode wire: up then right to battery
ctx.beginPath();
ctx.moveTo(cXt, e.cathode.y); ctx.lineTo(cXt, by); ctx.lineTo(bx - 22, by);
ctx.stroke();
// Anode wire: up then left to battery +
ctx.beginPath();
ctx.moveTo(aXt, e.anode.y); ctx.lineTo(aXt, by); ctx.lineTo(bx + 22, by);
ctx.stroke();
// Electron flow dots (cathode side: from battery toward cathode)
const dist = (bx - 22) - cXt;
for (let i = 0; i < 4; i++) {
const t = ((this._electronPhase + i / 4) % 1);
const ex = (bx - 22) - t * dist;
const ey = by;
if (ex >= cXt - 1 && ex <= bx - 22 + 1) {
ctx.fillStyle = '#4CC9F0'; ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 5;
ctx.beginPath(); ctx.arc(ex, ey, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
}
}
// Battery symbol — two plates
ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(bx + 22, by - 14); ctx.lineTo(bx + 22, by + 14); ctx.stroke();
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.8;
ctx.beginPath(); ctx.moveTo(bx - 22, by - 8); ctx.lineTo(bx - 22, by + 8); ctx.stroke();
// Connecting wire between battery plates
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(bx - 22, by); ctx.lineTo(bx + 22, by); ctx.stroke();
// Voltage label
ctx.fillStyle = '#FFD166';
ctx.font = `bold 12px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(this.voltage.toFixed(1) + ' V', bx, by - 18);
// +/ labels on battery
ctx.fillStyle = '#EF476F'; ctx.font = `bold 10px ${FN}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('+', bx + 26, by);
ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right';
ctx.fillText('\u2212', bx - 26, by);
ctx.restore();
}
_drawLabels() {
const { ctx } = this;
const el = this._el(), e = this._electrodes();
const { cx, cy, cw, ch } = this._cell();
const FN = ElectrolysisSim.FONT;
ctx.save();
ctx.font = `10px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = '#06D6E0';
ctx.fillText('\u041A\u0430\u0442\u043E\u0434 (\u2212)', e.cathode.x + e.cathode.w / 2, cy + ch + 6);
ctx.fillStyle = '#EF476F';
ctx.fillText('\u0410\u043D\u043E\u0434 (+)', e.anode.x + e.anode.w / 2, cy + ch + 6);
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.fillText(el.cathodeProduct, e.cathode.x + e.cathode.w / 2, cy + ch + 20);
ctx.fillText(el.anodeProduct, e.anode.x + e.anode.w / 2, cy + ch + 20);
ctx.fillStyle = '#9B5DE5'; ctx.font = `bold 11px ${FN}`;
ctx.fillText(el.displayName, cx + cw / 2, cy + ch + 36);
ctx.font = `8px ${FN}`;
ctx.fillStyle = 'rgba(6,214,224,0.48)';
ctx.fillText(el.cathodeEq, e.cathode.x + e.cathode.w / 2, cy + ch + 52);
ctx.fillStyle = 'rgba(239,71,111,0.48)';
ctx.fillText(el.anodeEq, e.anode.x + e.anode.w / 2, cy + ch + 52);
ctx.restore();
}
_drawInfoPanel() {
const { ctx } = this;
const inf = this.info(), el = this._el();
const FN = ElectrolysisSim.FONT;
const px = 12, py = 10, pw = 170, lh = 17;
const rows = [
['U', inf.voltage.toFixed(1) + ' \u0412'],
['I', this._current().toFixed(3) + ' \u0410'],
['\u0422\u0432\u0440\u0435\u043C\u044F', this._fmtTime(inf.time)],
];
if (el.depositColor) rows.push(['m(Cu)', inf.massDeposited.toFixed(4) + ' \u0433']);
rows.push(['V(\u0433\u0430\u0437)', inf.gasVolume.toFixed(2) + ' \u043C\u043B']);
const ph = 12 + rows.length * lh + 8;
ctx.save();
ctx.fillStyle = 'rgba(5,5,20,0.86)';
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.stroke();
ctx.font = `10px ${FN}`; ctx.textBaseline = 'middle';
rows.forEach(([k, v], i) => {
const ry = py + 10 + i * lh + lh / 2;
ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.textAlign = 'left'; ctx.fillText(k, px + 10, ry);
ctx.fillStyle = 'rgba(255,255,255,0.88)'; ctx.textAlign = 'right'; ctx.fillText(v, px + pw - 10, ry);
});
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.font = `italic 8px ${FN}`; ctx.textAlign = 'left';
ctx.fillText('m = M\u00B7I\u00B7t / (n\u00B7F)', px + 10, py + ph + 10);
ctx.restore();
}
_fmtTime(s) {
if (s < 60) return s.toFixed(1) + ' \u0441';
return Math.floor(s / 60) + ' \u043C\u0438\u043D ' + (s % 60).toFixed(0) + ' \u0441';
}
}
if (typeof module !== 'undefined') module.exports = ElectrolysisSim;
/* ─── lab UI init ─────────────────────────────────── */
function _openElectrolysis() {
document.getElementById('sim-topbar-title').textContent = 'Электролиз';
_simShow('sim-electrolysis');
_registerSimState('electrolysis', () => elecSim?.getParams(), st => elecSim?.setParams(st));
if (_embedMode) _startStateEmit('electrolysis');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!elecSim) {
elecSim = new ElectrolysisSim(document.getElementById('electrolysis-canvas'));
elecSim.onUpdate = _elecUpdateUI;
}
elecSim.fit();
elecSim.reset();
elecSim.play();
}));
}
function elecParam(name, val) {
const v = parseFloat(val);
if (name === 'voltage') {
document.getElementById('elec-V-val').textContent = v;
if (window.LabFX) LabFX.sound.play('spark', { volume: 0.3 });
}
if (elecSim) elecSim.setParams({ [name]: v });
}
function elecPreset(name, btn) {
document.querySelectorAll('.elec-type-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
const voltages = { nacl: 6, cuso4: 4, h2so4: 3 };
const vt = voltages[name] || 6;
document.getElementById('sl-elec-V').value = vt; document.getElementById('elec-V-val').textContent = vt;
if (elecSim) { elecSim.setParams({ electrolyte: name, voltage: vt }); elecSim.reset(); elecSim.play(); }
}
function _elecUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('elecbar-v1', typeof info.current === 'number' ? info.current.toFixed(2) : '—');
v('elecbar-v2', typeof info.massDeposited === 'number' ? info.massDeposited.toFixed(3) + ' г' : '—');
v('elecbar-v3', typeof info.gasVolume === 'number' ? info.gasVolume.toFixed(1) : '—');
v('elecbar-v4', typeof info.time === 'number' ? info.time.toFixed(0) + ' с' : '—');
}
/* ── waves ── */