diff --git a/frontend/css/lab.css b/frontend/css/lab.css index 3b5128b..fe82b49 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -2206,3 +2206,90 @@ canvas[data-draggable]:active { cursor: grabbing; } border-color: #06D6E0 !important; box-shadow: 0 0 8px rgba(6,214,224,0.3); } + +/* ═══════════════════════════════════════════════════════════ + ELECTROLYSIS PANEL — modern layout + ═══════════════════════════════════════════════════════════ */ +.elec-panel-modern { + font-size: .82rem; + padding: 8px 10px 12px; +} +.elec-panel-modern .param-name { font-size: .82rem; } +.elec-panel-modern .param-val { font-size: .85rem; } + +/* Quick bar — play/reset row */ +.elec-quick-bar { + display: flex; + gap: 6px; + margin-bottom: 8px; +} +.elec-quick-bar .mag-mode-btn { + flex: 1; + font-size: .82rem; + padding: 8px 6px; + font-weight: 600; +} + +/* Accordion sections */ +.elec-acc { + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 6px; + background: rgba(255,255,255,0.02); + flex: 0 0 auto; +} +.elec-acc > summary { + cursor: pointer; + list-style: none; + padding: 9px 12px 9px 30px; + font-family: 'Unbounded', sans-serif; + font-size: .78rem; + font-weight: 700; + color: var(--text); + letter-spacing: .04em; + text-transform: uppercase; + position: relative; + user-select: none; + transition: background .15s; + border-radius: 10px; +} +.elec-acc[open] > summary { border-radius: 10px 10px 0 0; } +.elec-acc > summary:hover { background: rgba(255,255,255,0.04); } +.elec-acc > summary::-webkit-details-marker { display: none; } +.elec-acc > summary::before { + content: ''; + position: absolute; + left: 12px; + top: 50%; + width: 0; + height: 0; + border-left: 5px solid currentColor; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + transform: translateY(-50%); + transition: transform .18s; + opacity: .65; +} +.elec-acc[open] > summary::before { + transform: translateY(-50%) rotate(90deg); +} +.elec-acc-body { + padding: 10px 12px 12px; + border-top: 1px solid var(--border); +} +.elec-acc-body .mag-mode-btn { + font-size: .80rem; + padding: 7px 4px; +} +.elec-acc-body .mag-mode-btn.active { + background: rgba(155,93,229,0.18); + color: var(--violet); + border: 1px solid rgba(155,93,229,0.45); +} + +/* Graph active button state */ +#btn-elec-graphs.active { + background: rgba(6,214,224,0.22) !important; + border-color: #06D6E0 !important; + box-shadow: 0 0 8px rgba(6,214,224,0.3); +} diff --git a/frontend/js/labs/electrolysis.js b/frontend/js/labs/electrolysis.js index 68224df..e5bd0f5 100644 --- a/frontend/js/labs/electrolysis.js +++ b/frontend/js/labs/electrolysis.js @@ -1,8 +1,9 @@ -'use strict'; +'use strict'; /** - * ElectrolysisSim v2 — Электролиз водных растворов + * ElectrolysisSim v3 — Электролиз водных растворов * Закон Фарадея: m = M·I·t / (n·F), F = 96485 Кл/моль - * Чистый рерайт: стабильная физика, ионная анимация, пузырьки, осадок. + * 6 электролитов, визуальная переработка, графики m(t)/V(t), + * внешняя цепь с анимированными электронами, стеклянный сосуд. */ class ElectrolysisSim { static F = 96485; @@ -12,39 +13,90 @@ class ElectrolysisSim { static ELECTROLYTES = { NaCl: { name: 'NaCl', displayName: 'NaCl (водный р-р)', - cation: 'Na\u207A', anion: 'Cl\u207B', + cation: 'Na⁺', anion: 'Cl⁻', 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', + 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\u2084', displayName: 'CuSO\u2084 (водный р-р)', - cation: 'Cu\u00B2\u207A', anion: 'SO\u2084\u00B2\u207B', + name: 'CuSO₄', displayName: 'CuSO₄ (водный р-р)', + cation: 'Cu²⁺', anion: 'SO₄²⁻', M: 63.546, n: 2, R: 12, - solColor: [55, 120, 210], - cathodeProduct: 'Cu\u2193', anodeProduct: 'O\u2082', - depositColor: '#b87333', + 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,210,255,0.50)', - cathodeEq: 'Cu\u00B2\u207A + 2e\u207B \u2192 Cu\u2193', - anodeEq: '2H\u2082O \u2212 4e\u207B \u2192 O\u2082 + 4H\u207A', + anodeBubColor: 'rgba(200,215,255,0.60)', + cathodeEq: 'Cu²⁺ + 2e⁻ → Cu↓', + anodeEq: '2H₂O − 4e⁻ → O₂ + 4H⁺', + voltage: 4, }, H2SO4: { - name: 'H\u2082SO\u2084', displayName: 'H\u2082SO\u2084 (водный р-р)', - cation: 'H\u207A', anion: 'SO\u2084\u00B2\u207B', + name: 'H₂SO₄', displayName: 'H₂SO₄ (водный р-р)', + cation: 'H⁺', anion: 'SO₄²⁻', 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', + 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, }, }; @@ -53,17 +105,34 @@ class ElectrolysisSim { this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; - this.voltage = 6; - this.electrolyte = 'NaCl'; - this.speed = 1; + 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 = []; + // 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; @@ -86,12 +155,22 @@ class ElectrolysisSim { this._initIons(); } - getParams() { return { voltage: this.voltage, electrolyte: this.electrolyte }; } - setParams({ voltage, electrolyte } = {}) { + 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) { - // accept both 'nacl' (from lab.html) and 'NaCl' (canonical) - const keyMap = { nacl: 'NaCl', cuso4: 'CuSO4', h2so4: 'H2SO4' }; + 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; @@ -102,17 +181,28 @@ class ElectrolysisSim { } 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; + 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._depositH = 0; - this._bubbles = []; this._electronPhase = 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(); } @@ -123,12 +213,15 @@ class ElectrolysisSim { stop() { this.pause(); } info() { + const I = this._current(); return { voltage: this.voltage, - current: +this._current().toFixed(3), + 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), }; } @@ -141,18 +234,18 @@ class ElectrolysisSim { _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 }; + 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 = 13, eh = ch * 0.70, gap = cw * 0.12; - const ey = cy + ch - eh - 8; + 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 }, + cathode: { x: cx + gap, y: ey, w: ew, h: eh }, + anode: { x: cx + cw - gap - ew, y: ey, w: ew, h: eh }, }; } @@ -161,16 +254,20 @@ class ElectrolysisSim { 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; + 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 + 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, + 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', + label: isCat ? el.cation : el.anion, + color: isCat ? '#EF476F' : '#06D6E0', + trail: [], + r: 5, }); } } @@ -178,26 +275,30 @@ class ElectrolysisSim { _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 + 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, + 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', + label: charge > 0 ? el.cation : el.anion, + color: charge > 0 ? '#EF476F' : '#06D6E0', + trail: [], + r: 5, }); } - _spawnBubble(x, y, color) { + _spawnBubble(x, y, color, baseR) { 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), + 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.005 + Math.random() * 0.007, + decay: 0.004 + Math.random() * 0.006, color, + born: y, }); } @@ -208,7 +309,7 @@ class ElectrolysisSim { 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; + const dt = rawDt * this.speed; this._lastTs = ts; if (window.LabFX) LabFX.particles.update(rawDt); this._step(dt); this.draw(); this._emit(); this._tick(); @@ -216,39 +317,58 @@ class ElectrolysisSim { } _step(dt) { - const el = this._el(), I = this._current(); + const el = this._el(); + const 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; + 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.72, this._depositH + dt * 0.14 * I); + 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.45; - this._fxIonTrailAcc = (this._fxIonTrailAcc || 0) + dt; - const doTrail = this._fxIonTrailAcc >= 0.1; + 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.18; - ion.vy += (Math.random() - 0.5) * 0.14; - ion.vx = Math.max(-3.5, Math.min(3.5, ion.vx * 0.96)); + 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 + 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) { + 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: 4, spread: 3.14, angle: 0, - gravity: 0, life: 300, shape: 'dot', glow: true }); + color: '#FFD166', speed: 3, spread: Math.PI * 2, angle: 0, + gravity: 0, life: 280, shape: 'dot', glow: true }); } } @@ -256,65 +376,79 @@ class ElectrolysisSim { 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) { + 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++) + 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, + elec.cathode.x + elec.cathode.w + 3 + Math.random() * 5, + elec.cathode.y + 10 + Math.random() * (elec.cathode.h - 20), 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, + x: elec.cathode.x + elec.cathode.w + 4, 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' }); + 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 - 5) { + if (ion.charge < 0 && ion.x >= elec.anode.x - 6) { rm.add(i); if (el.anodeBubColor) { - for (let b = 0; b < 2; b++) + for (let b = 0; b < 2; b++) { this._spawnBubble( - elec.anode.x - 2 - Math.random() * 4, - elec.anode.y + Math.random() * elec.anode.h, + elec.anode.x - 3 - Math.random() * 5, + elec.anode.y + 10 + Math.random() * (elec.anode.h - 20), el.anodeBubColor); - // LabFX: rising ring bubble from anode + } if (window.LabFX) { LabFX.particles.emit({ ctx: this.ctx, - x: elec.anode.x - 3, + x: elec.anode.x - 4, 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' }); + count: 1, color: '#FFFFFF', speed: 18, spread: 0.5, angle: -Math.PI / 2, + gravity: -55, life: 1400, 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 += dt; + if (this._fxFizzAcc >= 2.2) { this._fxFizzAcc = 0; - LabFX.sound.play('fizz', { volume: 0.2 }); + LabFX.sound.play('fizz', { volume: 0.18 }); } } + this._ions = this._ions.filter((_, i) => !rm.has(i)); - // Replenish ions to keep count ~15 each + // Replenish ions 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++; } + while (cat < 17) { this._spawnIon(1); cat++; } + while (an < 17) { this._spawnIon(-1); an++; } - // Bubble physics + // 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 * 22) * 0.12; - b.y += b.vy; + 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; - return b.life > 0 && b.y > cy + 2; + 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; }); } @@ -334,36 +468,83 @@ class ElectrolysisSim { ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill(); } - /* desk surface */ 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); } - this._drawWiresAndBattery(); + if (this.showElectrons) this._drawWiresAndBattery(); this._drawCellBody(); this._drawSolution(); this._drawDeposit(); this._drawElectrodes(); - this._drawBubbles(); - this._drawIons(); + if (this.showBubbles) this._drawBubbles(); + if (this.showIons) this._drawIons(); this._drawLabels(); - this._drawInfoPanel(); + 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(); - 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); + + // 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(); } @@ -371,52 +552,127 @@ class ElectrolysisSim { 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(); - ctx.beginPath(); ctx.roundRect(cx + 2, cy + 2, cw - 4, ch - 4, 4); ctx.clip(); + + // 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},0.06)`); - sg.addColorStop(1, `rgba(${r},${g},${b},0.22)`); - ctx.fillStyle = sg; ctx.fillRect(cx + 2, cy + 2, cw - 4, ch - 4); + 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 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(); + // 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(); - 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(); + // 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(); - 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); + // 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('+', e.anode.x + e.anode.w / 2, e.anode.y - 3); + ctx.fillText('+', anBx, anBy + 1); } _drawDeposit() { const el = this._el(); - if (!el.depositColor || this._depositH < 1) return; + 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.72); + const c = this._electrodes().cathode; + const dh = Math.min(this._depositH, c.h * 0.74); + const FN = ElectrolysisSim.FONT; + 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)'); + 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, 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.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(); } @@ -425,15 +681,36 @@ class ElectrolysisSim { 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'); + // 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, 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.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(); } @@ -442,188 +719,353 @@ class ElectrolysisSim { 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.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(); - 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(); + // 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 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 + 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(); - ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1.5; - // Cathode wire: up then right to battery − + // 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 - 22, by); + 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(); - // 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; + // 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 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(); + // 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(); - // Voltage label + // 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 12px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; - ctx.fillText(this.voltage.toFixed(1) + ' V', bx, by - 18); + 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 on battery - ctx.fillStyle = '#EF476F'; ctx.font = `bold 10px ${FN}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; - ctx.fillText('+', bx + 26, by); + // +/− 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('\u2212', bx - 26, by); + ctx.fillText('−', bx - bw / 2 - 4, by); ctx.restore(); } _drawLabels() { const { ctx } = this; - const el = this._el(), e = this._electrodes(); + const el = this._el(), e = this._electrodes(); const { cx, cy, cw, ch } = this._cell(); - const FN = ElectrolysisSim.FONT; + const FN = ElectrolysisSim.FONT; + const bot = cy + ch + 7; ctx.save(); - ctx.font = `10px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.textAlign = 'center'; + // Electrode labels + ctx.font = `bold 11px ${FN}`; 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.fillText('Катод (−)', e.cathode.x + e.cathode.w / 2, bot); ctx.fillStyle = '#EF476F'; - ctx.fillText('\u0410\u043D\u043E\u0434 (+)', e.anode.x + e.anode.w / 2, cy + ch + 6); + ctx.fillText('Анод (+)', e.anode.x + e.anode.w / 2, bot); - 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); + // 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); - ctx.fillStyle = '#9B5DE5'; ctx.font = `bold 11px ${FN}`; - ctx.fillText(el.displayName, cx + cw / 2, cy + ch + 36); + // Electrolyte name + ctx.font = `bold 12px ${FN}`; ctx.fillStyle = '#9B5DE5'; + ctx.fillText(el.displayName, cx + cw / 2, bot + 32); - 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); + // 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(); } - _drawInfoPanel() { - const { ctx } = this; - const inf = this.info(), el = this._el(); - const FN = ElectrolysisSim.FONT; - const px = 12, py = 10, pw = 170, lh = 17; + _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', inf.voltage.toFixed(1) + ' \u0412'], - ['I', this._current().toFixed(3) + ' \u0410'], - ['\u0422\u0432\u0440\u0435\u043C\u044F', this._fmtTime(inf.time)], + ['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(Cu)', inf.massDeposited.toFixed(4) + ' \u0433']); - rows.push(['V(\u0433\u0430\u0437)', inf.gasVolume.toFixed(2) + ' \u043C\u043B']); + if (el.depositColor) { + rows.push(['m(' + (el.depositLabel || '?') + ')', + inf.massDeposited.toFixed(4) + ' г', '#c47a30']); + } + rows.push(['V(газ)', inf.gasVolume.toFixed(2) + ' мл', '#06D6E0']); - const ph = 12 + rows.length * lh + 8; + const ph = 14 + rows.length * lh + 26; 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.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], 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); + 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); }); - ctx.fillStyle = 'rgba(255,255,255,0.16)'; + // 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\u00B7I\u00B7t / (n\u00B7F)', px + 10, py + ph + 10); + 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) + ' \u0441'; - return Math.floor(s / 60) + ' \u043C\u0438\u043D ' + (s % 60).toFixed(0) + ' \u0441'; + 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(); - })); - } +/* ─── lab UI init ─────────────────────────────────────────────── */ - 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 }); +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; } - if (elecSim) elecSim.setParams({ [name]: v }); - } + elecSim.fit(); + elecSim.reset(); + elecSim.play(); + // sync display toggle checkboxes + _elecSyncToggles(); + })); +} - 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 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 _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) + ' с' : '—'); +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(); +} - /* ── waves ── */ +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 + ' шт' : '—'); +} diff --git a/frontend/lab.html b/frontend/lab.html index 82a1d33..de63ad2 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -3557,21 +3557,91 @@