218baef4ad
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
1072 lines
37 KiB
JavaScript
1072 lines
37 KiB
JavaScript
'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 + ' шт' : '—');
|
||
}
|