Files
Learn_System/frontend/js/labs/redox.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

513 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ====================================================================
RedoxSim — Окислительно-восстановительные реакции
==================================================================== */
class RedoxSim {
/* ── Данные реакций ──────────────────────────────────────────────── */
static RXN = {
fe_cu: {
name: 'Fe + CuSO₄',
reducer: { f: 'Fe', name: 'Железо', color: '#A0856A', ox: 0 },
oxidizer: { f: 'Cu²⁺', name: 'Ион меди', color: '#29B6F6', ox: 2 },
prod_r: { f: 'Fe²⁺', color: '#66BB6A', ox: 2 },
prod_o: { f: 'Cu', color: '#C87840', ox: 0, solid: true },
e: 2,
half_r: 'Fe⁰ 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ окисление',
half_o: 'Cu²⁺ + 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu⁰ восстановление',
eq_ion: 'Fe + Cu²⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
eq_mol: 'Fe + CuSO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeSO₄ + Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
sol_a: '#1565C040', sol_b: '#2E7D3230',
precip: true, pcolor: '#C87840', pname: 'медь Cu',
},
zn_hcl: {
name: 'Zn + HCl',
reducer: { f: 'Zn', name: 'Цинк', color: '#90A4AE', ox: 0 },
oxidizer: { f: 'H⁺', name: 'Ион H⁺', color: '#EF5350', ox: 1 },
prod_r: { f: 'Zn²⁺', color: '#80CBC4', ox: 2 },
prod_o: { f: 'H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', color: '#EEEEEE', ox: 0, gas: true },
e: 2,
half_r: 'Zn⁰ 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ окисление',
half_o: '2H⁺ + 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> восстановление',
eq_ion: 'Zn + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
eq_mol: 'Zn + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
sol_a: '#EF525228', sol_b: '#E0F2F118',
gas: true, gcolor: '#CFD8DC', gname: 'водород H₂',
},
cl2_ki: {
name: 'Cl₂ + KI',
reducer: { f: 'I⁻', name: 'Иодид-ион', color: '#CE93D8', ox: -1 },
oxidizer: { f: 'Cl₂', name: 'Хлор', color: '#D4E157', ox: 0 },
prod_r: { f: 'I₂', color: '#6A1B9A', ox: 0, solid: true },
prod_o: { f: 'Cl⁻', color: '#AED581', ox: -1 },
e: 1,
half_r: '2I⁻ 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> I₂ окисление',
half_o: 'Cl₂ + 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Cl⁻ восстановление',
eq_ion: 'Cl₂ + 2I⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> I₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Cl⁻',
eq_mol: 'Cl₂ + 2KI <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> I₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2KCl',
sol_a: '#7B1FA230', sol_b: '#F9A82520',
precip: true, pcolor: '#6A1B9A', pname: 'йод I₂',
},
kmno4: {
name: 'KMnO₄ + FeSO₄',
reducer: { f: 'Fe²⁺', name: 'Ион Fe²⁺', color: '#66BB6A', ox: 2 },
oxidizer: { f: 'MnO₄⁻', name: 'Перманганат', color: '#AB47BC', ox: 7 },
prod_r: { f: 'Fe³⁺', color: '#FFA726', ox: 3 },
prod_o: { f: 'Mn²⁺', color: '#FFF9C4', ox: 2 },
e: 5,
half_r: 'Fe²⁺ e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe³⁺ (×5) окисление',
half_o: 'MnO₄⁻+8H⁺+5e⁻<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>Mn²⁺+4H₂O восстановление',
eq_ion: 'MnO₄⁻ + 5Fe²⁺ + 8H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mn²⁺ + 5Fe³⁺ + 4H₂O',
eq_mol: '2KMnO₄ + 10FeSO₄ + 8H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2MnSO₄ + 5Fe₂(SO₄)₃ + K₂SO₄ + 8H₂O',
sol_a: '#7B1FA250', sol_b: '#F9A82515',
colorChange: true, newSolColor: '#FFF9C428', newName: 'бесцветный MnSO₄',
},
cu_fecl3: {
name: 'Cu + FeCl₃',
reducer: { f: 'Cu', name: 'Медь', color: '#C87840', ox: 0 },
oxidizer: { f: 'Fe³⁺', name: 'Ион Fe³⁺', color: '#FFA726', ox: 3 },
prod_r: { f: 'Cu²⁺', color: '#29B6F6', ox: 2 },
prod_o: { f: 'Fe²⁺', color: '#66BB6A', ox: 2 },
e: 1,
half_r: 'Cu⁰ 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu²⁺ окисление',
half_o: '2Fe³⁺ + 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Fe²⁺ восстановление',
eq_ion: 'Cu + 2Fe³⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu²⁺ + 2Fe²⁺',
eq_mol: 'Cu + 2FeCl₃ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CuCl₂ + 2FeCl₂',
sol_a: '#E6510018', sol_b: '#1565C018',
colorChange: true, newSolColor: '#1565C030', newName: 'синий CuCl₂',
},
};
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.rxnId = 'fe_cu';
this._raf = null;
this._last = 0;
this._t = 0;
this._phase = 'idle'; // idle | mixing | reacting | done
this._prog = 0;
this._colorT = 0;
this._stepIdx = 0;
this._stepTimer = 0;
this._eParts = [];
this._rParts = [];
this._oParts = [];
this._precip = [];
this._gas = [];
this.W = 0; this.H = 0;
this.onUpdate = null;
this.fit();
this._initParts();
}
fit() {
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.offsetWidth || 600;
const H = this.canvas.offsetHeight || 400;
this.canvas.width = Math.round(W * dpr);
this.canvas.height = Math.round(H * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._initParts();
}
setReaction(id) {
if (!RedoxSim.RXN[id]) return;
this.rxnId = id;
this.reset();
}
reset() {
this._phase = 'idle'; this._prog = 0; this._colorT = 0;
this._stepIdx = 0; this._stepTimer = 0;
this._eParts = []; this._precip = []; this._gas = [];
this._initParts();
this.draw();
}
_initParts() {
const { W, H } = this;
const N = 16;
this._rParts = Array.from({ length: N }, () => ({
x: W * 0.22 + (Math.random() - 0.5) * W * 0.22,
y: H * 0.42 + (Math.random() - 0.5) * H * 0.34,
vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6,
r: 11 + Math.random() * 4,
phase: Math.random() * Math.PI * 2,
trans: false, flashT: 0,
}));
this._oParts = Array.from({ length: N }, () => ({
x: W * 0.78 + (Math.random() - 0.5) * W * 0.22,
y: H * 0.42 + (Math.random() - 0.5) * H * 0.34,
vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6,
r: 11 + Math.random() * 4,
phase: Math.random() * Math.PI * 2,
trans: false, flashT: 0,
}));
}
start() {
if (this._phase !== 'idle') this.reset();
this._phase = 'mixing'; this._prog = 0;
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.8 });
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
this._raf = requestAnimationFrame(loop);
}
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
/* ── Физика ─────────────────────────────────────────────────────── */
_tick(t) {
const dt = Math.min((t - this._last) / 1000, 0.05);
this._last = t; this._t += dt;
if (window.LabFX) LabFX.particles.update(dt);
const { W, H } = this;
const rxn = RedoxSim.RXN[this.rxnId];
if (this._phase === 'mixing') {
this._prog = Math.min(1, this._prog + dt * 0.38);
const all = [...this._rParts, ...this._oParts];
all.forEach(p => {
const tx = W * 0.5 + (Math.random() - 0.5) * W * 0.52;
const ty = H * 0.44 + (Math.random() - 0.5) * H * 0.34;
p.vx += (tx - p.x) * 0.003 * this._prog;
p.vy += (ty - p.y) * 0.003 * this._prog;
p.vx += (Math.random() - 0.5) * 0.5;
p.vy += (Math.random() - 0.5) * 0.5;
p.vx *= 0.90; p.vy *= 0.90;
p.x += p.vx; p.y += p.vy;
p.phase += dt * 1.5;
this._clamp(p);
});
if (this._prog >= 1) { this._phase = 'reacting'; this._prog = 0; }
}
if (this._phase === 'reacting') {
this._prog = Math.min(1, this._prog + dt * 0.14);
this._colorT = this._prog;
this._stepTimer += dt;
if (this._stepTimer > 1.6 && this._stepIdx < 3) { this._stepIdx++; this._stepTimer = 0; }
const all = [...this._rParts, ...this._oParts];
all.forEach(p => {
p.vx += (Math.random() - 0.5) * 0.9;
p.vy += (Math.random() - 0.5) * 0.9;
p.vx *= 0.87; p.vy *= 0.87;
p.x += p.vx; p.y += p.vy;
p.phase += dt * 2;
p.flashT = Math.max(0, p.flashT - dt * 3);
this._clamp(p);
});
/* Transform particles proportional to progress */
const rT = Math.floor(this._prog * this._rParts.length);
const oT = Math.floor(this._prog * this._oParts.length);
this._rParts.forEach((p, i) => {
if (i < rT && !p.trans) { p.trans = true; p.flashT = 1; }
});
this._oParts.forEach((p, i) => {
if (i < oT && !p.trans) {
p.trans = true; p.flashT = 1;
if (rxn.precip) this._precip.push({ x: p.x, y: p.y, vy: 0, r: 3 + Math.random() * 3, settled: false });
if (rxn.gas) this._gas.push({ x: p.x, y: p.y, vy: -(1.5 + Math.random()), vx: (Math.random() - 0.5) * 0.5, r: 2 + Math.random() * 3, alpha: 1 });
}
});
if (Math.random() < 0.22 && this._prog > 0.05) this._spawnE();
if (this._prog >= 1) { this._phase = 'done'; this._stepIdx = 3; }
}
if (this._phase === 'done') {
const all = [...this._rParts, ...this._oParts];
all.forEach(p => {
p.vx += (Math.random() - 0.5) * 0.45;
p.vy += (Math.random() - 0.5) * 0.45;
p.vx *= 0.92; p.vy *= 0.92;
p.x += p.vx; p.y += p.vy;
p.phase += dt;
this._clamp(p);
});
}
/* Electrons — quadratic bezier arc */
this._eParts = this._eParts.filter(e => e.t < 1);
this._eParts.forEach(e => {
e.t = Math.min(1, e.t + dt * e.spd);
const u = e.t;
e.x = (1-u)*(1-u)*e.x0 + 2*(1-u)*u*e.mx + u*u*e.x1;
e.y = (1-u)*(1-u)*e.y0 + 2*(1-u)*u*e.my + u*u*e.y1;
e.alpha = u < 0.1 ? u * 10 : u > 0.85 ? (1 - u) / 0.15 : 1;
});
/* Precipitate */
this._precip.forEach(p => {
if (!p.settled) {
p.vy = Math.min(p.vy + 0.15, 5);
p.y += p.vy;
if (p.y >= H * 0.78) { p.y = H * 0.78; p.vy = 0; p.settled = true; }
}
});
/* Gas */
this._gas.forEach(b => { b.y += b.vy; b.x += b.vx; b.vy -= 0.01; b.alpha -= 0.005; });
this._gas = this._gas.filter(b => b.alpha > 0 && b.y > 10);
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
_clamp(p) {
const { W, H } = this;
const bot = H * 0.78;
if (p.x < p.r + 6) { p.x = p.r + 6; p.vx *= -0.5; }
if (p.x > W - p.r - 6) { p.x = W - p.r - 6; p.vx *= -0.5; }
if (p.y < p.r + 6) { p.y = p.r + 6; p.vy *= -0.5; }
if (p.y > bot - p.r) { p.y = bot - p.r; p.vy *= -0.5; }
}
_spawnE() {
const freeR = this._rParts.filter(p => !p.trans);
const freeO = this._oParts.filter(p => !p.trans);
if (!freeR.length || !freeO.length) return;
const rp = freeR[Math.floor(Math.random() * freeR.length)];
const op = freeO[Math.floor(Math.random() * freeO.length)];
const mx = (rp.x + op.x) / 2;
const my = Math.min(rp.y, op.y) - 45 - Math.random() * 40;
this._eParts.push({
x0: rp.x, y0: rp.y, x1: op.x, y1: op.y,
mx, my, x: rp.x, y: rp.y,
t: 0, spd: 0.65 + Math.random() * 0.45, alpha: 0,
});
// LabFX: electron transfer spark at the midpoint
if (window.LabFX) {
LabFX.particles.emit({ ctx: this.ctx, x: mx, y: my, count: 2,
color: '#06D6E0', speed: 30, spread: 3.14, angle: 0,
gravity: 0, life: 200, shape: 'spark', glow: true });
}
}
/* ── Рендеринг ──────────────────────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
const rxn = RedoxSim.RXN[this.rxnId];
/* Background */
ctx.fillStyle = '#07071A';
ctx.fillRect(0, 0, W, H);
/* Dot grid */
ctx.fillStyle = 'rgba(255,255,255,0.07)';
for (let x = 0; x < W; x += 28) {
for (let y = 0; y < H; y += 28) {
ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill();
}
}
/* Solution tint */
const bx = W * 0.04, bw = W * 0.92, bTop = H * 0.08, bBot = H * 0.80;
if (this._phase === 'idle') {
if (rxn.sol_a) { ctx.save(); ctx.fillStyle = rxn.sol_a; ctx.fillRect(bx, bTop, bw / 2, bBot - bTop); ctx.restore(); }
if (rxn.sol_b) { ctx.save(); ctx.fillStyle = rxn.sol_b; ctx.fillRect(bx + bw / 2, bTop, bw / 2, bBot - bTop); ctx.restore(); }
/* Dashed divider */
ctx.save();
ctx.setLineDash([6, 5]); ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(W / 2, bTop + 4); ctx.lineTo(W / 2, bBot - 4); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
/* Zone labels */
ctx.save();
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = 'bold 12px sans-serif';
ctx.fillText(rxn.reducer.name, W * 0.22, bTop + 8);
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.font = '10px sans-serif';
ctx.fillText('восстановитель', W * 0.22, bTop + 26);
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = 'bold 12px sans-serif';
ctx.fillText(rxn.oxidizer.name, W * 0.78, bTop + 8);
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.font = '10px sans-serif';
ctx.fillText('окислитель', W * 0.78, bTop + 26);
ctx.restore();
} else if (this._colorT > 0) {
if (rxn.sol_a) {
ctx.save(); ctx.globalAlpha = 1 - this._colorT * 0.7;
ctx.fillStyle = rxn.sol_a; ctx.fillRect(bx, bTop, bw, bBot - bTop); ctx.restore();
}
if (rxn.colorChange && rxn.newSolColor) {
ctx.save(); ctx.globalAlpha = this._colorT * 0.55;
ctx.fillStyle = rxn.newSolColor; ctx.fillRect(bx, bTop, bw, bBot - bTop); ctx.restore();
}
}
this._drawBeaker(ctx, W, H);
this._drawParticles(ctx, rxn);
this._drawElectrons(ctx);
if (rxn.precip) this._drawPrecip(ctx, rxn);
if (rxn.gas) this._drawGas(ctx, rxn);
this._drawPanel(ctx, W, H, rxn);
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
_drawBeaker(ctx, W, H) {
const bx = W * 0.04, by = H * 0.08, bw = W * 0.92, bh = H * 0.73;
ctx.save();
ctx.strokeStyle = 'rgba(120,185,255,0.60)'; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(bx, by); ctx.lineTo(bx, by + bh);
ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw, by);
ctx.stroke();
ctx.beginPath(); ctx.moveTo(bx - 5, by); ctx.lineTo(bx + bw + 5, by); ctx.stroke();
/* Left highlight */
const hlg = ctx.createLinearGradient(bx, by, bx + 18, by + bh);
hlg.addColorStop(0, 'rgba(200,230,255,0.18)');
hlg.addColorStop(1, 'rgba(200,230,255,0.02)');
ctx.strokeStyle = hlg; ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(bx + 8, by + 8); ctx.lineTo(bx + 8, by + bh - 8); ctx.stroke();
ctx.restore();
}
_drawParticles(ctx, rxn) {
const draw1 = (p, spec, prod) => {
const s = p.trans ? prod : spec;
ctx.save();
ctx.shadowColor = p.flashT > 0 ? '#FFFFFF' : s.color;
ctx.shadowBlur = p.flashT > 0 ? 28 * p.flashT : 8 + Math.sin(p.phase) * 3;
ctx.globalAlpha = 0.88;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.flashT > 0 ? `rgba(255,255,255,${p.flashT * 0.9})` : s.color;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1; ctx.stroke();
ctx.shadowBlur = 0; ctx.globalAlpha = 1;
/* Oxidation state */
const ox = p.trans ? prod.ox : spec.ox;
const oxStr = ox > 0 ? `+${ox}` : ox < 0 ? `${ox}` : '0';
ctx.fillStyle = p.trans ? '#FFD166' : 'rgba(255,255,255,0.88)';
ctx.font = `bold ${Math.round(p.r * 0.78)}px monospace`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(oxStr, p.x, p.y);
ctx.restore();
};
this._rParts.forEach(p => draw1(p, rxn.reducer, rxn.prod_r));
this._oParts.forEach(p => draw1(p, rxn.oxidizer, rxn.prod_o));
}
_drawElectrons(ctx) {
this._eParts.forEach(e => {
ctx.save();
ctx.globalAlpha = e.alpha;
ctx.shadowColor = '#4FC3F7'; ctx.shadowBlur = 16;
ctx.beginPath(); ctx.arc(e.x, e.y, 5.5, 0, Math.PI * 2);
ctx.fillStyle = '#4FC3F7'; ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('e⁻', e.x, e.y);
ctx.restore();
});
}
_drawPrecip(ctx, rxn) {
if (!this._precip.length) return;
ctx.save();
this._precip.forEach(p => {
ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 4;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = rxn.pcolor; ctx.fill();
});
ctx.restore();
/* Label when settled */
const settled = this._precip.filter(p => p.settled);
if (settled.length > 3) {
ctx.save();
ctx.fillStyle = rxn.pcolor; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 6;
ctx.fillText(`↓ ${rxn.pname}`, this.W / 2, this.H * 0.80 - 4);
ctx.restore();
}
}
_drawGas(ctx, rxn) {
this._gas.forEach(b => {
ctx.save(); ctx.globalAlpha = b.alpha * 0.75;
ctx.shadowColor = rxn.gcolor; ctx.shadowBlur = 5;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.strokeStyle = rxn.gcolor; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
});
const count = this._gas.length;
if (count > 2) {
ctx.save();
ctx.fillStyle = rxn.gcolor; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.shadowColor = rxn.gcolor; ctx.shadowBlur = 6;
ctx.fillText(`↑ ${rxn.gname}`, this.W / 2, this.H * 0.12);
ctx.restore();
}
}
_drawPanel(ctx, W, H, rxn) {
const py = H * 0.82;
ctx.fillStyle = 'rgba(7,7,26,0.94)';
ctx.fillRect(0, py, W, H - py);
ctx.strokeStyle = 'rgba(100,165,255,0.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
if (this._phase === 'idle') {
ctx.fillStyle = '#37474F'; ctx.font = '11px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('← Нажми «Начать» для запуска реакции →', W / 2, py + (H - py) / 2);
return;
}
const steps = [
{ lbl: 'Молекулярное:', txt: rxn.eq_mol, col: '#B0BEC5' },
{ lbl: 'Окисление:', txt: rxn.half_r, col: '#EF476F' },
{ lbl: 'Восстановление:', txt: rxn.half_o, col: '#4CC9F0' },
{ lbl: 'Ионное:', txt: rxn.eq_ion, col: '#FFD166' },
];
const panH = H - py;
const n = Math.min(this._stepIdx + 1, steps.length);
for (let i = 0; i < n; i++) {
const s = steps[i];
const y = py + 11 + i * (panH * 0.22);
ctx.save();
if (i === this._stepIdx && this._phase !== 'done') {
ctx.fillStyle = 'rgba(255,255,255,0.04)';
ctx.fillRect(8, y - 9, W - 16, 20);
}
ctx.fillStyle = s.col; ctx.font = 'bold 9.5px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(s.lbl, 14, y);
ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)';
ctx.font = '9.5px monospace';
ctx.fillText(s.txt, 14 + ctx.measureText(s.lbl).width + 8, y);
ctx.restore();
}
if (this._phase === 'done') {
ctx.save();
ctx.fillStyle = '#7BF5A4'; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.shadowColor = '#7BF5A4'; ctx.shadowBlur = 8;
ctx.fillText('✓ Реакция завершена', W - 14, py + 3);
ctx.restore();
}
}
info() {
const rxn = RedoxSim.RXN[this.rxnId];
return {
rxn: rxn.name,
phase: this._phase,
prog: Math.round((this._phase === 'reacting' ? this._prog : this._phase === 'done' ? 1 : 0) * 100),
e: rxn.e,
};
}
}