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