'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 dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed; this._lastTs = ts; 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; 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)); } // 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); } } 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); } } } 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(); } _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;