'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 + ' шт' : '—'); }