6afe928c0d
ФУНДАМЕНТ (4 новых файла): - _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake - _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust) - _fx_motion.js: tween + 12 easings + critically-damped spring - _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API - Sound toggle в шапке lab.html с localStorage-persist UX МИКРО (CSS + JS): - Button states: hover scale+brightness, active scale-down, disabled grayscale - Slider polish: custom thumb с тенью, filled-track gradient, hover/active - Focus rings через :focus-visible - Tooltip system .tt-host data-tt= с 400ms hover, fade-in - Marching ants для selection - Loading skeleton с shimmer - Empty state .sim-empty-* паттерн - Toast: progress bar внизу, icons по типу - Cursor states utility classes - View Transitions API для smooth sim-switch, fallback на CSS fade PHASE 2 — визуальные эффекты для 33 симуляций: Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks) Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds) Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow) Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click) Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow) Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям) Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
623 lines
23 KiB
JavaScript
623 lines
23 KiB
JavaScript
'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();
|
||
}
|
||
|
||
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 ── */
|