Files
Maxim Dolgolyov 218baef4ad refactor(labs): полная переработка симуляции Электролиз
electrolysis.js (556 → 1072 строк):
- База электролитов с 3 до 6: NaCl/CuSO4/H2SO4 + KI/ZnSO4/AgNO3
- Стеклянный сосуд с бликами, волнующаяся поверхность раствора (sin-анимация)
- Ионы со стробоскопным шлейфом (4 точки) и радиальным свечением
- Пузырьки: растут при подъёме + pop-эффект на поверхности (LabFX)
- Осадок: градиент по металлу (Cu медь / Zn серый / Ag серебро) + метка
- Электроды с bevel и polarity badge (− / +)
- Внешняя цепь: батарея + провода + анимированные жёлтые электроны
- Закон Фарадея: панель с живой подстановкой U/I/t/Q/m/V в формулу
- Графики m(t)/V(t): мини-холст 200×75 с двумя трендами
- info() добавлены Q (Кл) и n_электронов

lab.html (sim-electrolysis):
- Панель 220 → 280px, класс elec-panel-modern
- elec-quick-bar: Старт/Сброс всегда видны
- 4 collapsible-секции elec-acc: Электролит / Скорость / Отображение / Уравнения
- Stats bar 4 → 6: добавлены Q и e⁻

lab.css: стили .elec-panel-modern, .elec-quick-bar, .elec-acc по паттерну geo-acc/dyn-acc
2026-05-26 19:31:00 +03:00

1072 lines
37 KiB
JavaScript
Raw Permalink 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 v3 — Электролиз водных растворов
* Закон Фарадея: m = M·I·t / (n·F), F = 96485 Кл/моль
* 6 электролитов, визуальная переработка, графики m(t)/V(t),
* внешняя цепь с анимированными электронами, стеклянный сосуд.
*/
class ElectrolysisSim {
static F = 96485;
static BG = '#0b0b1a';
static FONT = 'Manrope, system-ui, sans-serif';
static ELECTROLYTES = {
NaCl: {
name: 'NaCl', displayName: 'NaCl (водный р-р)',
cation: 'Na⁺', anion: 'Cl⁻',
M: 2, n: 2, R: 8,
solColor: [80, 160, 220],
solAlpha: [0.08, 0.26],
cathodeProduct: 'H₂↑', anodeProduct: 'Cl₂↑',
depositColor: null, depositLabel: null,
cathodeBubColor: 'rgba(160,210,255,0.65)',
anodeBubColor: 'rgba(200,255,170,0.60)',
cathodeEq: '2H₂O + 2e⁻ → H₂ + 2OH⁻',
anodeEq: '2Cl⁻ 2e⁻ → Cl₂',
voltage: 6,
},
CuSO4: {
name: 'CuSO₄', displayName: 'CuSO₄ (водный р-р)',
cation: 'Cu²⁺', anion: 'SO₄²⁻',
M: 63.546, n: 2, R: 12,
solColor: [30, 100, 220],
solAlpha: [0.10, 0.38],
cathodeProduct: 'Cu↓', anodeProduct: 'O₂↑',
depositColor: '#c47a30', depositLabel: 'Cu',
depositGrad: ['rgba(196,122,48,0.35)', 'rgba(196,122,48,0.9)'],
cathodeBubColor: null,
anodeBubColor: 'rgba(200,215,255,0.60)',
cathodeEq: 'Cu²⁺ + 2e⁻ → Cu↓',
anodeEq: '2H₂O 4e⁻ → O₂ + 4H⁺',
voltage: 4,
},
H2SO4: {
name: 'H₂SO₄', displayName: 'H₂SO₄ (водный р-р)',
cation: 'H⁺', anion: 'SO₄²⁻',
M: 2, n: 2, R: 6,
solColor: [210, 215, 230],
solAlpha: [0.04, 0.14],
cathodeProduct: 'H₂↑', anodeProduct: 'O₂↑',
depositColor: null, depositLabel: null,
cathodeBubColor: 'rgba(160,210,255,0.65)',
anodeBubColor: 'rgba(200,215,255,0.60)',
cathodeEq: '2H⁺ + 2e⁻ → H₂',
anodeEq: '2H₂O 4e⁻ → O₂ + 4H⁺',
voltage: 3,
},
KI: {
name: 'KI', displayName: 'KI (водный р-р)',
cation: 'K⁺', anion: 'I⁻',
M: 2, n: 2, R: 9,
solColor: [190, 140, 60],
solAlpha: [0.07, 0.28],
cathodeProduct: 'H₂↑', anodeProduct: 'I₂',
depositColor: null, depositLabel: null,
cathodeBubColor: 'rgba(160,210,255,0.65)',
anodeBubColor: 'rgba(160,90,20,0.60)',
cathodeEq: '2H₂O + 2e⁻ → H₂ + 2OH⁻',
anodeEq: '2I⁻ 2e⁻ → I₂',
voltage: 5,
},
ZnSO4: {
name: 'ZnSO₄', displayName: 'ZnSO₄ (водный р-р)',
cation: 'Zn²⁺', anion: 'SO₄²⁻',
M: 65.38, n: 2, R: 10,
solColor: [200, 210, 210],
solAlpha: [0.05, 0.18],
cathodeProduct: 'Zn↓', anodeProduct: 'O₂↑',
depositColor: '#9aabb0', depositLabel: 'Zn',
depositGrad: ['rgba(154,171,176,0.35)', 'rgba(154,171,176,0.9)'],
cathodeBubColor: null,
anodeBubColor: 'rgba(200,215,255,0.60)',
cathodeEq: 'Zn²⁺ + 2e⁻ → Zn↓',
anodeEq: '2H₂O 4e⁻ → O₂ + 4H⁺',
voltage: 5,
},
AgNO3: {
name: 'AgNO₃', displayName: 'AgNO₃ (водный р-р)',
cation: 'Ag⁺', anion: 'NO₃⁻',
M: 107.87, n: 1, R: 7,
solColor: [215, 215, 225],
solAlpha: [0.04, 0.16],
cathodeProduct: 'Ag↓', anodeProduct: 'O₂↑',
depositColor: '#d8dde0', depositLabel: 'Ag',
depositGrad: ['rgba(216,221,224,0.35)', 'rgba(216,221,224,0.9)'],
cathodeBubColor: null,
anodeBubColor: 'rgba(200,215,255,0.60)',
cathodeEq: 'Ag⁺ + e⁻ → Ag↓',
anodeEq: '2H₂O 4e⁻ → O₂ + 4H⁺',
voltage: 3,
},
};
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;
// display toggles
this.showElectrons = true;
this.showIons = true;
this.showBubbles = true;
this.showGraphs = false;
this._time = 0;
this._massDeposit = 0;
this._gasVolume = 0;
this._chargeTotal = 0;
this._depositH = 0;
this._ions = [];
this._bubbles = [];
this._electronPhase = 0;
this._wavePhase = 0;
// graph history
this._graphMass = [];
this._graphGas = [];
this._graphTime = [];
this._graphLastT = 0;
this._fxIonTrailAcc = 0;
this._fxFizzAcc = 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,
speed: this.speed,
};
}
setParams({ voltage, electrolyte, speed } = {}) {
if (voltage !== undefined) this.voltage = Math.max(1, Math.min(12, +voltage));
if (speed !== undefined) this.speed = +speed;
if (electrolyte !== undefined) {
const keyMap = {
nacl: 'NaCl', cuso4: 'CuSO4', h2so4: 'H2SO4',
ki: 'KI', znso4: 'ZnSO4', agno3: 'AgNO3',
};
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],
ki: ['KI', 5],
znso4: ['ZnSO4', 5],
agno3: ['AgNO3', 3],
};
const entry = map[String(name).toLowerCase()] || map.nacl;
this.voltage = entry[1]; this.electrolyte = entry[0];
this.reset();
}
reset() {
this.pause();
this._time = 0; this._massDeposit = 0;
this._gasVolume = 0; this._chargeTotal = 0;
this._depositH = 0;
this._bubbles = []; this._electronPhase = 0; this._wavePhase = 0;
this._graphMass = []; this._graphGas = []; this._graphTime = [];
this._graphLastT = 0;
this._fxIonTrailAcc = 0; this._fxFizzAcc = 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() {
const I = this._current();
return {
voltage: this.voltage,
current: +I.toFixed(3),
electrolyte: this.electrolyte,
massDeposited: +this._massDeposit.toFixed(4),
gasVolume: +this._gasVolume.toFixed(2),
chargeTotal: +this._chargeTotal.toFixed(2),
electronCount: +(this._chargeTotal / 1.602e-19).toExponential(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.46, 290);
const ch = Math.min(H * 0.46, 205);
return { cx: (W - cw) / 2, cy: H * 0.30, cw, ch };
}
_electrodes() {
const { cx, cy, cw, ch } = this._cell();
const ew = 14, eh = ch * 0.72, gap = cw * 0.13;
const ey = cy + ch - eh - 6;
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 < 34; i++) {
const isCat = i < 17;
const angle = Math.random() * Math.PI * 2;
const spd = 0.3 + Math.random() * 0.5;
this._ions.push({
x: cx + 20 + Math.random() * (cw - 40),
y: cy + 14 + Math.random() * (ch - 28),
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd,
charge: isCat ? 1 : -1,
label: isCat ? el.cation : el.anion,
color: isCat ? '#EF476F' : '#06D6E0',
trail: [],
r: 5,
});
}
}
_spawnIon(charge) {
const { cx, cy, cw, ch } = this._cell();
const el = this._el();
const spd = 0.4 + Math.random() * 0.4;
this._ions.push({
x: charge > 0 ? cx + 10 : cx + cw - 10,
y: cy + 14 + Math.random() * (ch - 28),
vx: charge > 0 ? spd : -spd,
vy: (Math.random() - 0.5) * 0.5,
charge,
label: charge > 0 ? el.cation : el.anion,
color: charge > 0 ? '#EF476F' : '#06D6E0',
trail: [],
r: 5,
});
}
_spawnBubble(x, y, color, baseR) {
this._bubbles.push({
x, y,
r: (baseR || 1.5) + Math.random() * 2,
vx: (Math.random() - 0.5) * 0.4,
vy: -(0.4 + Math.random() * 0.8),
life: 1,
decay: 0.004 + Math.random() * 0.006,
color,
born: y,
});
}
// ── 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();
const I = this._current();
const { cx, cy, cw, ch } = this._cell();
const elec = this._electrodes();
this._time += dt;
this._chargeTotal += I * dt;
this._wavePhase += dt * 1.8;
this._electronPhase = (this._electronPhase + dt * I * 1.4) % 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.74, this._depositH + dt * 0.15 * I);
}
this._gasVolume += molesPS * 22400 * dt;
// Graph sampling every 0.5 sim-seconds
if (this._time - this._graphLastT >= 0.5) {
this._graphLastT = this._time;
this._graphMass.push(this._massDeposit);
this._graphGas.push(this._gasVolume);
this._graphTime.push(this._time);
if (this._graphTime.length > 200) {
this._graphMass.shift(); this._graphGas.shift(); this._graphTime.shift();
}
}
// Ion drift + thermal jitter
const drift = I * 0.55;
this._fxIonTrailAcc += dt;
const doTrail = this._fxIonTrailAcc >= 0.08;
if (doTrail) this._fxIonTrailAcc = 0;
for (const ion of this._ions) {
ion.vx += (ion.charge > 0 ? -drift : drift) * dt + (Math.random() - 0.5) * 0.22;
ion.vy += (Math.random() - 0.5) * 0.16;
ion.vx = Math.max(-4, Math.min(4, ion.vx * 0.96));
ion.vy = Math.max(-3.5, Math.min(3.5, ion.vy * 0.96));
if (doTrail) {
ion.trail.push({ x: ion.x, y: ion.y });
if (ion.trail.length > 4) ion.trail.shift();
}
ion.x += ion.vx; ion.y += ion.vy;
ion.x = Math.max(cx + 5, Math.min(cx + cw - 5, ion.x));
ion.y = Math.max(cy + 5, Math.min(cy + ch - 5, ion.y));
if (window.LabFX && doTrail && Math.random() < 0.25) {
LabFX.particles.emit({ ctx: this.ctx, x: ion.x, y: ion.y, count: 1,
color: '#FFD166', speed: 3, spread: Math.PI * 2, angle: 0,
gravity: 0, life: 280, 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 + 6) {
rm.add(i);
if (el.cathodeBubColor) {
for (let b = 0; b < 2; b++) {
this._spawnBubble(
elec.cathode.x + elec.cathode.w + 3 + Math.random() * 5,
elec.cathode.y + 10 + Math.random() * (elec.cathode.h - 20),
el.cathodeBubColor);
}
if (window.LabFX) {
LabFX.particles.emit({ ctx: this.ctx,
x: elec.cathode.x + elec.cathode.w + 4,
y: elec.cathode.y + Math.random() * elec.cathode.h,
count: 1, color: '#FFFFFF', speed: 18, spread: 0.5, angle: -Math.PI / 2,
gravity: -55, life: 1400, shape: 'ring' });
}
}
}
if (ion.charge < 0 && ion.x >= elec.anode.x - 6) {
rm.add(i);
if (el.anodeBubColor) {
for (let b = 0; b < 2; b++) {
this._spawnBubble(
elec.anode.x - 3 - Math.random() * 5,
elec.anode.y + 10 + Math.random() * (elec.anode.h - 20),
el.anodeBubColor);
}
if (window.LabFX) {
LabFX.particles.emit({ ctx: this.ctx,
x: elec.anode.x - 4,
y: elec.anode.y + Math.random() * elec.anode.h,
count: 1, color: '#FFFFFF', speed: 18, spread: 0.5, angle: -Math.PI / 2,
gravity: -55, life: 1400, shape: 'ring' });
}
}
}
}
if (window.LabFX && I > 0.01) {
this._fxFizzAcc += dt;
if (this._fxFizzAcc >= 2.2) {
this._fxFizzAcc = 0;
LabFX.sound.play('fizz', { volume: 0.18 });
}
}
this._ions = this._ions.filter((_, i) => !rm.has(i));
// Replenish ions
let cat = 0, an = 0;
for (const ion of this._ions) ion.charge > 0 ? cat++ : an++;
while (cat < 17) { this._spawnIon(1); cat++; }
while (an < 17) { this._spawnIon(-1); an++; }
// Bubble physics — bubble grows as it rises
const surfaceY = cy + 4;
this._bubbles = this._bubbles.filter(b => {
b.x += b.vx + Math.sin(b.life * 20) * 0.15;
b.y += b.vy;
// grow slightly as bubble rises
const risen = Math.max(0, b.born - b.y);
b.r = b.r + risen * 0.0008;
b.life -= b.decay;
if (b.y <= surfaceY + b.r) {
// pop — spawn micro-splash (LabFX)
if (window.LabFX && Math.random() < 0.5) {
LabFX.particles.emit({ ctx: this.ctx, x: b.x, y: surfaceY,
count: 2, color: b.color, speed: 15, spread: Math.PI, angle: -Math.PI / 2,
gravity: 30, life: 400, shape: 'dot', glow: false });
}
return false;
}
return b.life > 0;
});
}
// ── 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();
}
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);
}
if (this.showElectrons) this._drawWiresAndBattery();
this._drawCellBody();
this._drawSolution();
this._drawDeposit();
this._drawElectrodes();
if (this.showBubbles) this._drawBubbles();
if (this.showIons) this._drawIons();
this._drawLabels();
this._drawFaradayPanel();
if (this.showGraphs) this._drawGraphs();
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
// ── glass vessel ─────────────────────────────────────────────
_drawCellBody() {
const { ctx } = this;
const { cx, cy, cw, ch } = this._cell();
const r = 10; // corner radius
ctx.save();
// Outer glass wall (vessel silhouette)
ctx.beginPath();
ctx.moveTo(cx + r, cy);
ctx.lineTo(cx + cw - r, cy);
ctx.quadraticCurveTo(cx + cw, cy, cx + cw, cy + r);
ctx.lineTo(cx + cw, cy + ch - r);
ctx.quadraticCurveTo(cx + cw, cy + ch, cx + cw - r, cy + ch);
ctx.lineTo(cx + r, cy + ch);
ctx.quadraticCurveTo(cx, cy + ch, cx, cy + ch - r);
ctx.lineTo(cx, cy + r);
ctx.quadraticCurveTo(cx, cy, cx + r, cy);
ctx.closePath();
// glass inner fill
const glassG = ctx.createLinearGradient(cx, cy, cx + cw, cy);
glassG.addColorStop(0, 'rgba(160,200,255,0.04)');
glassG.addColorStop(0.1, 'rgba(255,255,255,0.06)');
glassG.addColorStop(0.5, 'rgba(130,170,240,0.02)');
glassG.addColorStop(1, 'rgba(255,255,255,0.05)');
ctx.fillStyle = glassG;
ctx.fill();
// glass border
ctx.strokeStyle = 'rgba(200,220,255,0.22)';
ctx.lineWidth = 1.5;
ctx.stroke();
// Left highlight streak
const hlG = ctx.createLinearGradient(cx, cy, cx + 14, cy);
hlG.addColorStop(0, 'rgba(255,255,255,0.12)');
hlG.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hlG;
ctx.fillRect(cx + 2, cy + 4, 12, ch - 8);
// Right reflection
const rrG = ctx.createLinearGradient(cx + cw - 10, cy, cx + cw, cy);
rrG.addColorStop(0, 'rgba(255,255,255,0)');
rrG.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.fillStyle = rrG;
ctx.fillRect(cx + cw - 10, cy + 4, 8, ch - 8);
// Bottom glass thickness line
ctx.strokeStyle = 'rgba(200,220,255,0.14)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(cx + r, cy + ch);
ctx.lineTo(cx + cw - r, cy + ch);
ctx.stroke();
ctx.restore();
}
_drawSolution() {
const { ctx } = this;
const { cx, cy, cw, ch } = this._cell();
const [r, g, b] = this._el().solColor;
const [a0, a1] = this._el().solAlpha;
ctx.save();
// Wave on surface
const waveH = 4;
const t = this._wavePhase;
ctx.beginPath();
ctx.moveTo(cx + 2, cy + 2 + waveH);
for (let px = 0; px <= cw - 4; px += 3) {
const wave = Math.sin((px / (cw - 4)) * Math.PI * 4 + t) * waveH * 0.5;
ctx.lineTo(cx + 2 + px, cy + 2 + waveH * 0.5 + wave);
}
ctx.lineTo(cx + cw - 2, cy + ch - 2);
ctx.lineTo(cx + 2, cy + ch - 2);
ctx.closePath();
const sg = ctx.createLinearGradient(cx, cy, cx, cy + ch);
sg.addColorStop(0, `rgba(${r},${g},${b},${a0})`);
sg.addColorStop(1, `rgba(${r},${g},${b},${a1})`);
ctx.fillStyle = sg;
ctx.fill();
// Subtle wave highlight line at surface
ctx.beginPath();
ctx.moveTo(cx + 2, cy + 2 + waveH);
for (let px = 0; px <= cw - 4; px += 3) {
const wave = Math.sin((px / (cw - 4)) * Math.PI * 4 + t) * waveH * 0.5;
ctx.lineTo(cx + 2 + px, cy + 2 + waveH * 0.5 + wave);
}
ctx.strokeStyle = `rgba(${r},${g},${b},0.4)`;
ctx.lineWidth = 1.2;
ctx.stroke();
ctx.restore();
}
_drawElectrodes() {
const { ctx } = this;
const e = this._electrodes();
const FN = ElectrolysisSim.FONT;
// Cathode () — dark with cyan tint
const catG = ctx.createLinearGradient(e.cathode.x, 0, e.cathode.x + e.cathode.w, 0);
catG.addColorStop(0, '#2a2a3e');
catG.addColorStop(0.4, '#3c3c54');
catG.addColorStop(1, '#252534');
ctx.fillStyle = catG;
ctx.beginPath(); ctx.roundRect(e.cathode.x, e.cathode.y, e.cathode.w, e.cathode.h, 4); ctx.fill();
ctx.strokeStyle = 'rgba(6,214,224,0.45)'; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.roundRect(e.cathode.x, e.cathode.y, e.cathode.w, e.cathode.h, 4); ctx.stroke();
// cathode bevel highlight
ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(e.cathode.x + 2, e.cathode.y + 4); ctx.lineTo(e.cathode.x + 2, e.cathode.y + e.cathode.h - 4); ctx.stroke();
// Anode (+) — dark with red tint
const anG = ctx.createLinearGradient(e.anode.x, 0, e.anode.x + e.anode.w, 0);
anG.addColorStop(0, '#2c2530');
anG.addColorStop(0.6, '#3e3048');
anG.addColorStop(1, '#28222e');
ctx.fillStyle = anG;
ctx.beginPath(); ctx.roundRect(e.anode.x, e.anode.y, e.anode.w, e.anode.h, 4); ctx.fill();
ctx.strokeStyle = 'rgba(239,71,111,0.45)'; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.roundRect(e.anode.x, e.anode.y, e.anode.w, e.anode.h, 4); ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(e.anode.x + 2, e.anode.y + 4); ctx.lineTo(e.anode.x + 2, e.anode.y + e.anode.h - 4); ctx.stroke();
// Polarity badges
const bdR = 9;
const catBx = e.cathode.x + e.cathode.w / 2;
const catBy = e.cathode.y - 16;
ctx.fillStyle = 'rgba(6,214,224,0.18)';
ctx.beginPath(); ctx.arc(catBx, catBy, bdR, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(catBx, catBy, bdR, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = '#06D6E0'; ctx.font = `bold 13px ${FN}`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('', catBx, catBy + 1);
const anBx = e.anode.x + e.anode.w / 2;
const anBy = e.anode.y - 16;
ctx.fillStyle = 'rgba(239,71,111,0.18)';
ctx.beginPath(); ctx.arc(anBx, anBy, bdR, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(239,71,111,0.6)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(anBx, anBy, bdR, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = '#EF476F';
ctx.fillText('+', anBx, anBy + 1);
}
_drawDeposit() {
const el = this._el();
if (!el.depositColor || this._depositH < 0.5) return;
const { ctx } = this;
const c = this._electrodes().cathode;
const dh = Math.min(this._depositH, c.h * 0.74);
const FN = ElectrolysisSim.FONT;
ctx.save();
const grad = el.depositGrad || ['rgba(180,140,80,0.4)', 'rgba(180,140,80,0.9)'];
const dg = ctx.createLinearGradient(c.x + c.w, c.y + c.h - dh, c.x + c.w + 12, c.y + c.h);
dg.addColorStop(0, grad[0]);
dg.addColorStop(1, grad[1]);
ctx.fillStyle = dg;
ctx.beginPath(); ctx.roundRect(c.x + c.w, c.y + c.h - dh, 11, dh, [3, 3, 0, 0]); ctx.fill();
// Gloss edge
ctx.shadowColor = el.depositColor; ctx.shadowBlur = 8;
const topG = ctx.createLinearGradient(c.x + c.w, c.y + c.h - dh, c.x + c.w + 11, c.y + c.h - dh + 4);
topG.addColorStop(0, 'rgba(255,255,255,0.4)');
topG.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = topG;
ctx.beginPath(); ctx.roundRect(c.x + c.w, c.y + c.h - dh, 11, 4, [3, 3, 0, 0]); ctx.fill();
ctx.shadowBlur = 0;
// Label
if (el.depositLabel && dh > 12) {
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.font = `bold 8px ${FN}`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(el.depositLabel, c.x + c.w + 5, c.y + c.h - dh / 2);
}
ctx.restore();
}
_drawIons() {
const { ctx } = this;
const FN = ElectrolysisSim.FONT;
ctx.save();
for (const ion of this._ions) {
// Trail
if (ion.trail.length >= 2) {
for (let t = 0; t < ion.trail.length; t++) {
const alpha = (t / ion.trail.length) * 0.35;
const tr = (t / ion.trail.length) * 3;
ctx.globalAlpha = alpha;
ctx.fillStyle = ion.color;
ctx.beginPath(); ctx.arc(ion.trail[t].x, ion.trail[t].y, tr, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
// Glow halo
const g = ctx.createRadialGradient(ion.x, ion.y, 0, ion.x, ion.y, 13);
g.addColorStop(0, ion.color + '28'); g.addColorStop(1, ion.color + '00');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(ion.x, ion.y, 13, 0, Math.PI * 2); ctx.fill();
// Badge background
ctx.fillStyle = ion.color + 'cc';
ctx.beginPath(); ctx.arc(ion.x, ion.y, ion.r, 0, Math.PI * 2); ctx.fill();
// Badge border
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 0.7;
ctx.beginPath(); ctx.arc(ion.x, ion.y, ion.r, 0, Math.PI * 2); ctx.stroke();
// Label
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.font = `bold 7px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(ion.label, ion.x, ion.y);
}
ctx.restore();
}
_drawBubbles() {
const { ctx } = this;
ctx.save();
for (const b of this._bubbles) {
ctx.globalAlpha = b.life * 0.7;
// bubble body
const bg = ctx.createRadialGradient(b.x - b.r * 0.3, b.y - b.r * 0.3, 0, b.x, b.y, b.r);
bg.addColorStop(0, 'rgba(255,255,255,0.22)');
bg.addColorStop(0.6, 'rgba(255,255,255,0)');
ctx.fillStyle = bg;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.fill();
// outline
ctx.strokeStyle = b.color; ctx.lineWidth = 0.9;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.stroke();
// specular dot
ctx.globalAlpha = b.life * 0.5;
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.beginPath(); ctx.arc(b.x - b.r * 0.32, b.y - b.r * 0.32, b.r * 0.28, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
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;
const aXt = e.anode.x + e.anode.w / 2;
const bx = cx + cw / 2;
const by = cy - Math.max(46, this.H * 0.10);
ctx.save();
// Wires
ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 2;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
// Cathode wire
ctx.beginPath();
ctx.moveTo(cXt, e.cathode.y); ctx.lineTo(cXt, by); ctx.lineTo(bx - 26, by);
ctx.stroke();
// Anode wire
ctx.beginPath();
ctx.moveTo(aXt, e.anode.y); ctx.lineTo(aXt, by); ctx.lineTo(bx + 26, by);
ctx.stroke();
// Animated electrons: cathode side — from battery () toward cathode
const dist = (bx - 26) - cXt;
const I = this._current();
const dotCount = Math.max(3, Math.min(6, Math.round(I * 4)));
for (let i = 0; i < dotCount; i++) {
const t = ((this._electronPhase + i / dotCount) % 1);
// electrons flow: battery (right side of left wire) → down to cathode
let ex, ey;
const totalPath = Math.abs(dist) + Math.abs(e.cathode.y - by);
const seg1frac = Math.abs(dist) / totalPath;
if (t < seg1frac) {
// horizontal segment: bx-26 → cXt
const st = t / seg1frac;
ex = (bx - 26) - st * Math.abs(dist);
ey = by;
} else {
// vertical segment: by → e.cathode.y
const st = (t - seg1frac) / (1 - seg1frac);
ex = cXt;
ey = by + st * (e.cathode.y - by);
}
ctx.fillStyle = '#4CC9F0';
ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 6;
ctx.beginPath(); ctx.arc(ex, ey, 3, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
}
// Battery body
const bw = 44, bh = 28;
const bbg = ctx.createLinearGradient(bx - bw / 2, by - bh / 2, bx + bw / 2, by + bh / 2);
bbg.addColorStop(0, 'rgba(50,50,80,0.9)');
bbg.addColorStop(1, 'rgba(30,30,55,0.95)');
ctx.fillStyle = bbg;
ctx.beginPath(); ctx.roundRect(bx - bw / 2, by - bh / 2, bw, bh, 5); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx - bw / 2, by - bh / 2, bw, bh, 5); ctx.stroke();
// Battery plates inside
// Negative plate (, left)
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(bx - 14, by - 9); ctx.lineTo(bx - 14, by + 9); ctx.stroke();
// Positive plate (+, right)
ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(bx + 14, by - 13); ctx.lineTo(bx + 14, by + 13); ctx.stroke();
// Voltage label above battery
ctx.fillStyle = '#FFD166';
ctx.font = `bold 13px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 4;
ctx.fillText(this.voltage.toFixed(1) + ' В', bx, by - bh / 2 - 5);
ctx.shadowBlur = 0;
// +/ labels outside battery
ctx.font = `bold 11px ${FN}`; ctx.textBaseline = 'middle';
ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left';
ctx.fillText('+', bx + bw / 2 + 4, by);
ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right';
ctx.fillText('', bx - bw / 2 - 4, 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;
const bot = cy + ch + 7;
ctx.save();
ctx.textAlign = 'center';
// Electrode labels
ctx.font = `bold 11px ${FN}`; ctx.textBaseline = 'top';
ctx.fillStyle = '#06D6E0';
ctx.fillText('Катод ()', e.cathode.x + e.cathode.w / 2, bot);
ctx.fillStyle = '#EF476F';
ctx.fillText('Анод (+)', e.anode.x + e.anode.w / 2, bot);
// Products
ctx.font = `10px ${FN}`; ctx.fillStyle = 'rgba(255,255,255,0.52)';
ctx.fillText(el.cathodeProduct, e.cathode.x + e.cathode.w / 2, bot + 16);
ctx.fillText(el.anodeProduct, e.anode.x + e.anode.w / 2, bot + 16);
// Electrolyte name
ctx.font = `bold 12px ${FN}`; ctx.fillStyle = '#9B5DE5';
ctx.fillText(el.displayName, cx + cw / 2, bot + 32);
// Equations
ctx.font = `9px ${FN}`;
ctx.fillStyle = 'rgba(6,214,224,0.6)';
ctx.fillText(el.cathodeEq, e.cathode.x + e.cathode.w / 2, bot + 48);
ctx.fillStyle = 'rgba(239,71,111,0.6)';
ctx.fillText(el.anodeEq, e.anode.x + e.anode.w / 2, bot + 48);
ctx.restore();
}
_drawFaradayPanel() {
const { ctx, W } = this;
const el = this._el();
const I = this._current();
const inf = this.info();
const FN = ElectrolysisSim.FONT;
const pw = Math.min(186, W * 0.28);
const px = 10, py = 10;
const lh = 16;
// Rows: U, I, t, Q, charge count, mass/gas
const rows = [
['U', this.voltage.toFixed(1) + ' В', 'rgba(255,209,102,0.9)'],
['I', I.toFixed(3) + ' А', this.playing ? '#ff8a8a' : 'rgba(255,255,255,0.85)'],
['Т', this._fmtTime(inf.time), this.playing ? '#ff8a8a' : 'rgba(255,255,255,0.85)'],
['Q', inf.chargeTotal.toFixed(1) + ' Кл', '#9B5DE5'],
];
if (el.depositColor) {
rows.push(['m(' + (el.depositLabel || '?') + ')',
inf.massDeposited.toFixed(4) + ' г', '#c47a30']);
}
rows.push(['V(газ)', inf.gasVolume.toFixed(2) + ' мл', '#06D6E0']);
const ph = 14 + rows.length * lh + 26;
ctx.save();
ctx.fillStyle = 'rgba(5,5,22,0.88)';
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 9); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 9); ctx.stroke();
ctx.font = `10px ${FN}`; ctx.textBaseline = 'middle';
rows.forEach(([k, v, clr], i) => {
const ry = py + 12 + i * lh + lh / 2;
ctx.fillStyle = 'rgba(255,255,255,0.40)'; ctx.textAlign = 'left';
ctx.fillText(k, px + 10, ry);
ctx.fillStyle = clr || 'rgba(255,255,255,0.88)'; ctx.textAlign = 'right';
ctx.fillText(v, px + pw - 10, ry);
});
// Faraday formula line
const fy = py + ph - 18;
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.font = `italic 8px ${FN}`; ctx.textAlign = 'left';
ctx.fillText('m = M·I·t / (n·F)', px + 10, fy);
ctx.restore();
}
_drawGraphs() {
const { ctx, W, H } = this;
if (this._graphTime.length < 2) return;
const FN = ElectrolysisSim.FONT;
const gw = Math.min(W * 0.38, 200);
const gh = 75;
const gx = W - gw - 10;
const gy = H - gh - 10;
ctx.save();
ctx.fillStyle = 'rgba(5,5,22,0.88)';
ctx.beginPath(); ctx.roundRect(gx, gy, gw, gh, 8); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(gx, gy, gw, gh, 8); ctx.stroke();
const pad = { l: 8, r: 8, t: 14, b: 10 };
const iw = gw - pad.l - pad.r;
const ih = gh - pad.t - pad.b;
const massMax = Math.max(...this._graphMass, 0.0001);
const gasMax = Math.max(...this._graphGas, 0.0001);
const n = this._graphTime.length;
const drawLine = (data, maxVal, color) => {
ctx.beginPath();
for (let i = 0; i < n; i++) {
const x = gx + pad.l + (i / (n - 1)) * iw;
const y = gy + pad.t + ih - (data[i] / maxVal) * ih;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = color; ctx.lineWidth = 1.5;
ctx.stroke();
};
const el = this._el();
if (el.depositColor) {
drawLine(this._graphMass, massMax, '#9B5DE5');
}
drawLine(this._graphGas, gasMax, '#06D6E0');
// Legend
ctx.font = `8px ${FN}`; ctx.textBaseline = 'top';
if (el.depositColor) {
ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'left';
ctx.fillText('m(г)', gx + pad.l, gy + 3);
}
ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right';
ctx.fillText('V(мл)', gx + gw - pad.r, gy + 3);
ctx.restore();
}
_fmtTime(s) {
if (s < 60) return s.toFixed(1) + ' с';
return Math.floor(s / 60) + ' мин ' + (s % 60).toFixed(0) + ' с';
}
}
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();
// sync display toggle checkboxes
_elecSyncToggles();
}));
}
function elecParam(name, val) {
const v = parseFloat(val);
if (name === 'voltage') {
const vEl = document.getElementById('elec-V-val');
if (vEl) vEl.textContent = v.toFixed(1);
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, ki: 5, znso4: 5, agno3: 3 };
const vt = voltages[name] || 6;
const sl = document.getElementById('sl-elec-V');
if (sl) sl.value = vt;
const vl = document.getElementById('elec-V-val');
if (vl) vl.textContent = vt.toFixed(1);
if (elecSim) {
elecSim.setParams({ electrolyte: name, voltage: vt });
elecSim.reset();
elecSim.play();
}
_elecUpdateEquations();
}
function elecToggle(name, checked) {
if (!elecSim) return;
const map = {
electrons: 'showElectrons',
ions: 'showIons',
bubbles: 'showBubbles',
graphs: 'showGraphs',
};
if (map[name]) elecSim[map[name]] = checked;
}
function elecSpeed(val, btn) {
document.querySelectorAll('.elec-speed-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
if (elecSim) elecSim.speed = +val;
}
function _elecSyncToggles() {
if (!elecSim) return;
const pairs = [
['elec-chk-electrons', 'showElectrons'],
['elec-chk-ions', 'showIons'],
['elec-chk-bubbles', 'showBubbles'],
['elec-chk-graphs', 'showGraphs'],
];
pairs.forEach(([id, prop]) => {
const el = document.getElementById(id);
if (el) el.checked = elecSim[prop];
});
_elecUpdateEquations();
}
function _elecUpdateEquations() {
const key = elecSim ? elecSim.electrolyte : 'NaCl';
const el = ElectrolysisSim.ELECTROLYTES[key];
if (!el) return;
const catEl = document.getElementById('elec-eq-cathode');
const anEl = document.getElementById('elec-eq-anode');
if (catEl) catEl.textContent = el.cathodeEq;
if (anEl) anEl.textContent = el.anodeEq;
}
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) + ' A' : '—');
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) + ' с' : '—');
v('elecbar-v5', typeof info.chargeTotal === 'number' ? info.chargeTotal.toFixed(1) + ' Кл' : '—');
v('elecbar-v6', typeof info.electronCount === 'string' ? info.electronCount + ' шт' : '—');
}