Files
Learn_System/frontend/js/labs/reactions.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

911 lines
33 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';
/**
* ReactionSim — Chemical reaction kinetics simulation.
* Particle-based A + B <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> C (and variants) with Arrhenius kinetics.
* Renders: glowing molecules, flash effects on reaction,
* live concentration graph, energy profile diagram.
*/
class ReactionSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.particles = [];
this.flashes = []; // [{x, y, t, maxT, color}]
this._history = []; // [{step, nA, nB, nC}]
this._nextId = 0;
// Parameters
this.N = 28; // initial molecules per reactive species
this.T = 1.2; // temperature 0.24.0
this.Ea = 2.0; // activation energy 0.55.0
this.mode = 'forward'; // 'forward' | 'reversible' | 'chain'
this.reactionOn = true;
// Runtime stats
this._steps = 0;
this._totalReactions = 0;
this._recentReactions = 0;
this._rate = 0; // reactions per step (ema)
this._raf = null;
this._dpr = 1;
this.onUpdate = null;
// Spatial grid
this._grid = new Map();
this._GRID_C = 22; // cell size (> max particle diameter)
}
/* ────────────────────────── Lifecycle ────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
this._dpr = dpr;
const w = this.canvas.offsetWidth;
const h = this.canvas.offsetHeight;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.W = w; this.H = h;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.reset();
}
reset() {
const { W, H } = this;
if (!W || !H) return;
this.particles = [];
this.flashes = [];
this._history = [];
this._steps = 0;
this._totalReactions = 0;
this._recentReactions = 0;
this._rate = 0;
this._nextId = 0;
// Spawn N of A and N of B
this._spawnType('A', this.N);
this._spawnType('B', this.N);
this._recordHistory();
}
_spawnType(type, count) {
const { W, H } = this;
const r = this._radius(type);
const margin = 12;
let placed = 0, attempts = 0;
while (placed < count && attempts < count * 60) {
attempts++;
const x = margin + r + Math.random() * (W - 2 * r - margin * 2);
const y = margin + r + Math.random() * (H - 2 * r - margin * 2);
let overlap = false;
for (const p of this.particles) {
const dx = p.x - x, dy = p.y - y;
if (dx * dx + dy * dy < (p.r + r + 1) ** 2) { overlap = true; break; }
}
if (overlap) continue;
const ang = Math.random() * Math.PI * 2;
const spd = this._baseSpeed(type) * (0.6 + Math.random() * 0.8);
this.particles.push({ x, y, vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd, r, type, id: this._nextId++ });
placed++;
}
}
start() {
if (this._raf) return;
this._lastTs = performance.now();
const loop = (ts) => {
this._raf = requestAnimationFrame(loop);
const dt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
if (window.LabFX) LabFX.particles.update(dt);
for (let i = 0; i < 3; i++) this._step();
this.draw();
};
this._raf = requestAnimationFrame(loop);
}
stop() {
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
/* ────────────────────────── Parameters ────────────────────────── */
setN(n) {
this.N = Math.max(5, Math.min(80, n));
this.reset();
}
setT(t) {
const ratio = Math.max(0.1, t) / Math.max(0.1, this.T);
this.T = Math.max(0.2, Math.min(4.0, t));
const scale = Math.sqrt(ratio);
for (const p of this.particles) { p.vx *= scale; p.vy *= scale; }
}
setEa(ea) {
this.Ea = Math.max(0.5, Math.min(5.0, ea));
}
setMode(mode) { this.mode = mode; }
toggleReaction() { this.reactionOn = !this.reactionOn; }
preset(name) {
this.reactionOn = true;
const presets = {
simple: { N: 28, T: 1.2, Ea: 1.8, mode: 'forward' },
reversible: { N: 22, T: 1.5, Ea: 1.5, mode: 'reversible' },
hot: { N: 25, T: 2.8, Ea: 2.0, mode: 'forward' },
cold: { N: 25, T: 0.4, Ea: 1.5, mode: 'forward' },
chain: { N: 18, T: 1.8, Ea: 0.9, mode: 'chain' },
};
Object.assign(this, presets[name] || {});
this.reset();
}
info() {
let nA = 0, nB = 0, nC = 0;
for (const p of this.particles) {
if (p.type === 'A') nA++;
else if (p.type === 'B') nB++;
else nC++;
}
return { nA, nB, nC, total: this.particles.length, reactions: this._totalReactions, rate: this._rate };
}
/* ────────────────────────── Helpers ────────────────────────── */
_radius(type) { return type === 'C' ? 7 : 5; }
_baseSpeed(type) { return (type === 'C' ? 0.55 : 1.0) * this.T * 3.2; }
_color(type) { return { A: '#06D6E0', B: '#EF476F', C: '#FFD166' }[type] || '#aaa'; }
/* ────────────────────────── Physics ────────────────────────── */
_buildGrid() {
this._grid.clear();
const cs = this._GRID_C;
for (const p of this.particles) {
const key = `${Math.floor(p.x / cs)},${Math.floor(p.y / cs)}`;
if (!this._grid.has(key)) this._grid.set(key, []);
this._grid.get(key).push(p);
}
}
_neighbors(p) {
const cs = this._GRID_C;
const gx = Math.floor(p.x / cs), gy = Math.floor(p.y / cs);
const out = [];
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++) {
const cell = this._grid.get(`${gx + dx},${gy + dy}`);
if (cell) for (const q of cell) if (q !== p) out.push(q);
}
return out;
}
_step() {
const { W, H } = this;
const dt = 0.55;
// Move + wall bounce
for (const p of this.particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); }
if (p.x > W - p.r) { p.x = W - p.r; p.vx = -Math.abs(p.vx); }
if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); }
if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); }
}
this._buildGrid();
const toRemove = new Set();
const toAdd = [];
// Pairwise: collision detection, reaction check, elastic bounce
for (const p of this.particles) {
if (toRemove.has(p.id)) continue;
for (const q of this._neighbors(p)) {
if (q.id <= p.id || toRemove.has(q.id)) continue;
const dx = q.x - p.x, dy = q.y - p.y;
const dist2 = dx * dx + dy * dy;
const minD = p.r + q.r;
if (dist2 >= minD * minD) continue;
const dist = Math.sqrt(dist2);
// Try chemical reaction
if (this.reactionOn && this._tryReact(p, q, dx, dy, dist, toRemove, toAdd)) continue;
// Elastic collision
const nx = dx / dist, ny = dy / dist;
const dvx = p.vx - q.vx, dvy = p.vy - q.vy;
const dot = dvx * nx + dvy * ny;
if (dot >= 0) {
// Just separate overlapping particles that are already moving apart
const ov = (minD - dist) * 0.5;
p.x -= nx * ov; p.y -= ny * ov;
q.x += nx * ov; q.y += ny * ov;
continue;
}
const m1 = p.r * p.r, m2 = q.r * q.r;
const imp = (2 * dot) / (m1 + m2);
p.vx -= imp * m2 * nx; p.vy -= imp * m2 * ny;
q.vx += imp * m1 * nx; q.vy += imp * m1 * ny;
const ov = (minD - dist) * 0.5;
p.x -= nx * ov; p.y -= ny * ov;
q.x += nx * ov; q.y += ny * ov;
}
}
// Spontaneous decomposition C <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> A + B (reversible mode)
if (this.mode === 'reversible') {
const prob = 0.00022 * this.T * Math.exp(-this.Ea * 0.38 / this.T);
for (const p of this.particles) {
if (p.type !== 'C' || toRemove.has(p.id)) continue;
if (Math.random() < prob) {
toRemove.add(p.id);
const ang = Math.random() * Math.PI * 2;
const spd = this._baseSpeed('A');
const mk = id => ({ x: p.x + Math.cos(ang + id * Math.PI) * 5,
y: p.y + Math.sin(ang + id * Math.PI) * 5,
vx: Math.cos(ang + id * Math.PI) * spd * (0.7 + Math.random() * 0.6),
vy: Math.sin(ang + id * Math.PI) * spd * (0.7 + Math.random() * 0.6),
r: 5, type: id === 0 ? 'A' : 'B', id: this._nextId++ });
toAdd.push(mk(0), mk(1));
this.flashes.push({ x: p.x, y: p.y, t: 0, maxT: 14, color: '100,160,255' });
}
}
}
// Apply changes
if (toRemove.size) this.particles = this.particles.filter(p => !toRemove.has(p.id));
for (const p of toAdd) this.particles.push(p);
// Age flashes
this.flashes = this.flashes.filter(f => ++f.t < f.maxT);
this._steps++;
if (this._steps % 30 === 0) {
this._rate = this._recentReactions / 30;
this._recentReactions = 0;
}
if (this._steps % 20 === 0) {
this._recordHistory();
if (this.onUpdate) this.onUpdate(this.info());
}
}
_tryReact(p, q, dx, dy, dist, toRemove, toAdd) {
const isAB = (p.type === 'A' && q.type === 'B') || (p.type === 'B' && q.type === 'A');
if (!isAB) return false;
// Arrhenius factor: k ∝ exp(-Ea / T)
if (Math.random() > Math.exp(-this.Ea / this.T) * 0.38) return false;
const m1 = p.r * p.r, m2 = q.r * q.r, mt = m1 + m2;
const cx = (p.x * m1 + q.x * m2) / mt;
const cy = (p.y * m1 + q.y * m2) / mt;
const pvx = (p.vx * m1 + q.vx * m2) / mt;
const pvy = (p.vy * m1 + q.vy * m2) / mt;
toRemove.add(p.id);
toRemove.add(q.id);
if (this.mode === 'chain') {
// Chain: A + B <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> 2 C (two fast products — cascade reaction)
const spd = Math.sqrt(pvx * pvx + pvy * pvy) * 1.35 + this._baseSpeed('C') * 0.7;
const ang = Math.atan2(pvy || 0.001, pvx || 0.001);
for (let s = 0; s < 2; s++) {
const sign = s === 0 ? 1 : -1;
toAdd.push({
x: cx + Math.cos(ang) * sign * 5,
y: cy + Math.sin(ang) * sign * 5,
vx: Math.cos(ang) * sign * spd,
vy: Math.sin(ang) * sign * spd,
r: 6, type: 'C', id: this._nextId++
});
}
this.flashes.push({ x: cx, y: cy, t: 0, maxT: 28, color: '255,140,30' });
} else {
// Forward / reversible: A + B <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> 1 C
const cSpd = Math.sqrt(pvx * pvx + pvy * pvy) * 0.62 + this._baseSpeed('C') * 0.28;
const ang = Math.atan2(pvy || 0.001, pvx || 0.001);
toAdd.push({ x: cx, y: cy, vx: Math.cos(ang) * cSpd, vy: Math.sin(ang) * cSpd, r: 7, type: 'C', id: this._nextId++ });
this.flashes.push({ x: cx, y: cy, t: 0, maxT: 22, color: '255,200,50' });
}
this._totalReactions++;
this._recentReactions++;
// LabFX: flash spark + throttled tick sound at collision point
if (window.LabFX) {
const now = performance.now();
if (!this._fxLastTick || now - this._fxLastTick > 200) {
this._fxLastTick = now;
LabFX.sound.play('tick', { volume: 0.1 });
}
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 3,
color: '#FFD166', speed: 45, spread: 3.14, angle: 0,
gravity: 0, life: 200, shape: 'spark', glow: true });
}
return true;
}
_recordHistory() {
let nA = 0, nB = 0, nC = 0;
for (const p of this.particles) {
if (p.type === 'A') nA++;
else if (p.type === 'B') nB++;
else nC++;
}
this._history.push({ step: this._steps, nA, nB, nC });
if (this._history.length > 260) this._history.shift();
}
/* ────────────────────────── Rendering ────────────────────────── */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
// ── Background ──
ctx.fillStyle = '#080818';
ctx.fillRect(0, 0, W, H);
// ── Subtle dot grid ──
ctx.fillStyle = 'rgba(255,255,255,0.033)';
for (let x = 35; x < W; x += 35)
for (let y = 35; y < H; y += 35) {
ctx.beginPath();
ctx.arc(x, y, 1, 0, Math.PI * 2);
ctx.fill();
}
// ── Reaction flashes ──
for (const f of this.flashes) {
const prog = f.t / f.maxT;
const radius = prog * 48 + 4;
const alpha = (1 - prog) * 0.55;
const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, radius);
g.addColorStop(0, `rgba(${f.color},${alpha * 1.6})`);
g.addColorStop(0.4, `rgba(${f.color},${alpha * 0.5})`);
g.addColorStop(1, `rgba(${f.color},0)`);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, radius, 0, Math.PI * 2);
ctx.fill();
}
// ── Particles ──
for (const p of this.particles) this._drawParticle(ctx, p);
// ── Overlays ──
this._drawLegend(ctx);
this._drawConcentrationGraph(ctx);
this._drawEnergyDiagram(ctx);
// ── Empty state ──
if (this.particles.length === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Все молекулы прореагировали — нажмите Сброс', W / 2, H / 2);
}
if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawParticle(ctx, p) {
const col = this._color(p.type);
const { x, y, r } = p;
// Outer glow
const glow = ctx.createRadialGradient(x, y, 0, x, y, r * 3);
glow.addColorStop(0, col + '50');
glow.addColorStop(1, col + '00');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(x, y, r * 3, 0, Math.PI * 2);
ctx.fill();
// Body (radial gradient for depth)
const body = ctx.createRadialGradient(x - r * 0.28, y - r * 0.28, r * 0.05, x, y, r);
body.addColorStop(0, col + 'ff');
body.addColorStop(0.65, col + 'cc');
body.addColorStop(1, col + '88');
ctx.fillStyle = body;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
// Specular highlight
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.beginPath();
ctx.arc(x - r * 0.27, y - r * 0.27, r * 0.3, 0, Math.PI * 2);
ctx.fill();
// Type label
ctx.fillStyle = 'rgba(0,0,0,0.72)';
ctx.font = `bold ${Math.round(r * 1.15)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(p.type, x, y + 0.5);
ctx.textBaseline = 'alphabetic';
}
_drawConcentrationGraph(ctx) {
if (this._history.length < 2) return;
const { W, H } = this;
const gW = 198, gH = 118;
const gX = W - gW - 10, gY = H - gH - 10;
// Panel
ctx.fillStyle = 'rgba(5,5,20,0.88)';
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
this._rrect(ctx, gX, gY, gW, gH, 7);
ctx.fill(); ctx.stroke();
// Title
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.font = '9px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Концентрация молекул', gX + 7, gY + 12);
const pad = { l: 8, r: 6, t: 18, b: 24 };
const px = gX + pad.l, py = gY + pad.t;
const pw = gW - pad.l - pad.r, ph = gH - pad.t - pad.b;
const maxN = this.N * 2.3;
const n = this._history.length;
// Grid lines
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const yl = py + ph * (1 - i / 4);
ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke();
}
// Data lines
const lines = [
{ key: 'nA', color: '#06D6E0', label: 'A — реагент' },
{ key: 'nB', color: '#EF476F', label: 'B — реагент' },
{ key: 'nC', color: '#FFD166', label: 'C — продукт' },
];
for (const { key, color } of lines) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.6;
for (let i = 0; i < n; i++) {
const lx = px + (i / Math.max(n - 1, 1)) * pw;
const ly = py + ph - Math.min(this._history[i][key] / maxN, 1) * ph;
i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly);
}
ctx.stroke();
}
// Legend + current values
const last = this._history[this._history.length - 1];
lines.forEach(({ color, label }, i) => {
const lx = gX + 8 + i * 58;
ctx.fillStyle = color;
ctx.fillRect(lx, gY + gH - 16, 11, 2.5);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '8px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(label.split(' ')[0], lx + 13, gY + gH - 12);
});
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '8px monospace';
ctx.textAlign = 'right';
ctx.fillText(`A:${last.nA} B:${last.nB} C:${last.nC}`, gX + gW - 6, gY + gH - 12);
}
_drawEnergyDiagram(ctx) {
const { W } = this;
const dW = 158, dH = 100;
const dX = W - dW - 10, dY = 10;
ctx.fillStyle = 'rgba(5,5,20,0.88)';
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
this._rrect(ctx, dX, dY, dW, dH, 7);
ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.font = '9px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Профиль энергии', dX + 7, dY + 12);
const pad = { l: 22, r: 10, t: 18, b: 20 };
const ex = dX + pad.l, ey_bot = dY + dH - pad.b;
const ew = dW - pad.l - pad.r, eh = dH - pad.t - pad.b;
const rE = 0.15;
const tE = 0.85;
const pE = Math.max(0.04, rE + this._diagDeltaH());
const toY = e => ey_bot - e * eh;
// Smooth reaction path
ctx.beginPath();
ctx.strokeStyle = 'rgba(255,200,60,0.78)';
ctx.lineWidth = 2;
ctx.moveTo(ex, toY(rE));
ctx.lineTo(ex + ew * 0.15, toY(rE));
ctx.bezierCurveTo(
ex + ew * 0.32, toY(rE),
ex + ew * 0.40, toY(tE),
ex + ew * 0.50, toY(tE)
);
ctx.bezierCurveTo(
ex + ew * 0.60, toY(tE),
ex + ew * 0.68, toY(pE),
ex + ew * 0.85, toY(pE)
);
ctx.lineTo(ex + ew, toY(pE));
ctx.stroke();
// Horizontal dashes at levels
ctx.setLineDash([2, 3]);
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 0.75;
[rE, pE].forEach(e => {
ctx.beginPath(); ctx.moveTo(ex, toY(e)); ctx.lineTo(ex + ew, toY(e)); ctx.stroke();
});
ctx.setLineDash([]);
// Ea bracket (left side)
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ex - 3, toY(rE)); ctx.lineTo(ex - 8, toY(rE));
ctx.moveTo(ex - 3, toY(tE)); ctx.lineTo(ex - 8, toY(tE));
ctx.moveTo(ex - 7, toY(rE)); ctx.lineTo(ex - 7, toY(tE));
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.38)';
ctx.font = '8px sans-serif';
ctx.textAlign = 'right';
ctx.fillText('Ea', ex - 9, toY((rE + tE) / 2) + 3);
// Labels
ctx.fillStyle = '#06D6E0cc';
ctx.font = '8px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('A+B', ex, toY(rE) - 4);
ctx.fillStyle = '#FFD166cc';
ctx.textAlign = 'right';
ctx.fillText('C', ex + ew, toY(pE) - 4);
// Mode label at bottom
const modeTxt = { forward: '→ A + B → C', reversible: '⇌ A + B ⇌ C', chain: 'цепная реакция' }[this.mode] || '';
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.font = '8px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(modeTxt, dX + dW / 2, dY + dH - 6);
}
_diagDeltaH() {
// Visual ΔH for energy diagram: exothermic by default
return -(0.10 + this.Ea * 0.045);
}
_drawLegend(ctx) {
const items = [
{ color: '#06D6E0', label: 'A — реагент' },
{ color: '#EF476F', label: 'B — реагент' },
{ color: '#FFD166', label: 'C — продукт' },
];
const lX = 10, lY = 10, lW = 120, lH = 14 * items.length + 14;
ctx.fillStyle = 'rgba(5,5,20,0.78)';
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
ctx.lineWidth = 1;
this._rrect(ctx, lX, lY, lW, lH, 6);
ctx.fill(); ctx.stroke();
items.forEach(({ color, label }, i) => {
const iy = lY + 14 + i * 14;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(lX + 12, iy, 4.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.52)';
ctx.font = '9px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(label, lX + 22, iy + 3.5);
});
}
_rrect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
}
/* ─── lab UI init ─────────────────────────────────── */
function _openChemistry(mode) {
document.getElementById('sim-topbar-title').textContent = 'Химические реакции';
_simShow('sim-chemistry');
_simShow('ctrl-chemistry');
if (mode) _chemMode = mode;
requestAnimationFrame(() => requestAnimationFrame(() => {
chemMode(_chemMode);
}));
}
function chemMode(mode, btn) {
_chemMode = mode;
const MODES = ['kinetics', 'flask', 'redox', 'ionex'];
const CANVASES = { kinetics: 'reactions-canvas', flask: 'flask-canvas', redox: 'redox-canvas', ionex: 'ionexchange-canvas' };
// toggle mode buttons
document.querySelectorAll('.chem-mode').forEach(b => b.classList.remove('active'));
const mb = document.getElementById('chem-mode-' + mode);
if (mb) mb.classList.add('active');
// toggle panels
MODES.forEach(m => {
const p = document.getElementById('chem-panel-' + m);
if (p) p.style.display = m === mode ? '' : 'none';
});
// toggle canvases
Object.entries(CANVASES).forEach(([m, cid]) => {
document.getElementById(cid).style.display = m === mode ? 'block' : 'none';
});
// toggle topbar tool groups
const modeToCtrl = { kinetics:'kin', flask:'flask', redox:'redox', ionex:'ionex' };
['kin', 'flask', 'redox', 'ionex'].forEach(k => {
const el = document.getElementById('ctrl-chem-' + k);
if (el) el.style.display = k === modeToCtrl[mode] ? 'contents' : 'none';
});
// stop all sims
if (reacSim) reacSim.stop();
if (flaskSim) flaskSim.stop();
if (rdxSim) rdxSim.stop();
if (ioxSim) ioxSim.stop();
// start the active one
if (mode === 'kinetics') {
const c = document.getElementById('reactions-canvas');
if (!reacSim) { reacSim = new ReactionSim(c); reacSim.onUpdate = _reacUpdateUI; }
reacSim.fit(); reacSim.start();
_reacUpdateUI(reacSim.info());
} else if (mode === 'flask') {
const c = document.getElementById('flask-canvas');
if (!flaskSim) { flaskSim = new FlaskSim(c); flaskSim.onUpdate = _flaskUpdateUI; }
flaskSim.fit(); flaskSim.start();
_flaskUpdateUI(flaskSim.info());
} else if (mode === 'redox') {
const c = document.getElementById('redox-canvas');
if (!rdxSim) { rdxSim = new RedoxSim(c); rdxSim.onUpdate = _redoxUpdateUI; }
rdxSim.fit(); rdxSim.draw();
_redoxUpdateUI(rdxSim.info());
} else if (mode === 'ionex') {
const c = document.getElementById('ionexchange-canvas');
if (!ioxSim) { ioxSim = new IonExSim(c); ioxSim.onUpdate = _ionexUpdateUI; }
ioxSim.fit(); ioxSim.draw();
_ionexUpdateUI(ioxSim.info());
}
}
function chemReset() {
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
if (_chemMode === 'kinetics' && reacSim) reacSim.reset();
if (_chemMode === 'flask' && flaskSim) flaskSim.reset();
if (_chemMode === 'redox') redoxReset();
if (_chemMode === 'ionex') ionexReset();
}
// _openReactions is now handled by _openChemistry + chemMode
function reacNChange() {
const v = +document.getElementById('sl-reacN').value;
document.getElementById('reac-N-val').textContent = v;
if (reacSim) reacSim.setN(v);
}
function reacTChange() {
const raw = +document.getElementById('sl-reacT').value;
const t = (raw / 10).toFixed(1);
document.getElementById('reac-T-val').textContent = t;
if (reacSim) reacSim.setT(+t);
}
function reacEaChange() {
const raw = +document.getElementById('sl-reacEa').value;
const ea = (raw / 10).toFixed(1);
document.getElementById('reac-Ea-val').textContent = ea;
if (reacSim) reacSim.setEa(+ea);
}
function reacMode(mode, el) {
if (window.LabFX) LabFX.sound.play('click');
if (reacSim) reacSim.setMode(mode);
document.querySelectorAll('.reac-mode-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
}
function reacPreset(name) {
if (!reacSim) return;
reacSim.preset(name);
// Sync sliders and mode buttons
document.getElementById('sl-reacN').value = reacSim.N;
document.getElementById('reac-N-val').textContent = reacSim.N;
document.getElementById('sl-reacT').value = Math.round(reacSim.T * 10);
document.getElementById('reac-T-val').textContent = reacSim.T.toFixed(1);
document.getElementById('sl-reacEa').value = Math.round(reacSim.Ea * 10);
document.getElementById('reac-Ea-val').textContent = reacSim.Ea.toFixed(1);
document.querySelectorAll('.reac-mode-btn').forEach(b => b.classList.remove('active'));
const mBtn = document.getElementById('rmode-' + reacSim.mode);
if (mBtn) mBtn.classList.add('active');
_reacUpdateUI(reacSim.info());
}
function reacTogglePause() {
if (!reacSim) return;
reacSim.toggleReaction();
const btn = document.getElementById('reac-pause-btn');
btn.innerHTML = reacSim.reactionOn ? '<svg class="ic" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> Пауза' : '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Реакции';
}
function _reacUpdateUI(info) {
if (!info) return;
document.getElementById('chbar-l1').textContent = 'A молекул';
document.getElementById('chbar-v1').textContent = info.nA;
document.getElementById('chbar-l2').textContent = 'B молекул';
document.getElementById('chbar-v2').textContent = info.nB;
document.getElementById('chbar-l3').textContent = 'C продукт';
document.getElementById('chbar-v3').textContent = info.nC;
document.getElementById('chbar-l4').textContent = 'Реакций';
document.getElementById('chbar-v4').textContent = info.reactions;
document.getElementById('chbar-l5').textContent = 'Скорость';
document.getElementById('chbar-v5').textContent = info.rate > 0
? (info.rate * 30).toFixed(1) + '/с' : '—';
}
// _openFlask is now handled by _openChemistry('flask')
function flaskMetal(type, el) {
if (flaskSim) { flaskSim.setMetal(type); flaskSim.reset(); }
document.querySelectorAll('.flask-metal-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
}
function flaskAcid(type, el) {
if (flaskSim) flaskSim.setAcid(type);
document.querySelectorAll('.flask-acid-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
}
function flaskConcChange() {
const v = +document.getElementById('sl-flask-conc').value;
document.getElementById('flask-conc-val').textContent = v + '%';
if (flaskSim) flaskSim.setConc(v / 100);
}
function flaskTempChange() {
const v = +document.getElementById('sl-flask-temp').value;
document.getElementById('flask-temp-val').textContent = v + '°C';
if (flaskSim) flaskSim.setEnvTemp(v);
}
function flaskToggleFlame() {
if (!flaskSim) return;
flaskSim.toggleFlame();
const active = flaskSim._flameOn;
document.getElementById('flask-flame-btn').style.opacity = active ? '1' : '0.5';
document.getElementById('flask-flame-panel').style.opacity = active ? '1' : '0.5';
document.getElementById('flask-flame-panel').style.background = active ? 'rgba(239,71,111,0.22)' : '';
}
function flaskTogglePause() {
if (!flaskSim) return;
flaskSim.togglePause();
document.getElementById('flask-pause-btn').innerHTML = flaskSim._paused ? '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>';
}
function _flaskUpdateUI(info) {
if (!info) return;
document.getElementById('chbar-l1').textContent = 'Металл';
document.getElementById('chbar-v1').textContent = info.metal;
document.getElementById('chbar-l2').textContent = 'Масса';
document.getElementById('chbar-v2').textContent = info.mass + ' г';
document.getElementById('chbar-l3').textContent = 'T (°C)';
document.getElementById('chbar-v3').textContent = info.temp + '°C';
document.getElementById('chbar-l4').textContent = 'pH';
document.getElementById('chbar-v4').textContent = info.pH;
document.getElementById('chbar-l5').textContent = 'H₂ (%)';
document.getElementById('chbar-v5').textContent = info.h2pct + '%';
}
// _openRedox is now handled by _openChemistry('redox')
function redoxRxn(id, el) {
document.querySelectorAll('.redox-rxn-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
if (rdxSim) { rdxSim.setReaction(id); }
}
function redoxStart() {
if (rdxSim) rdxSim.start();
}
function redoxReset() {
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
if (rdxSim) rdxSim.reset();
}
function _redoxUpdateUI(info) {
if (!info) return;
const phaseMap = { idle: 'ожидание', mixing: 'смешивание', reacting: 'реакция', done: 'завершена' };
document.getElementById('chbar-l1').textContent = 'Реакция';
document.getElementById('chbar-v1').textContent = info.rxn || '—';
document.getElementById('chbar-l2').textContent = 'Фаза';
document.getElementById('chbar-v2').textContent = phaseMap[info.phase] || info.phase;
document.getElementById('chbar-l3').textContent = 'Прогресс';
document.getElementById('chbar-v3').textContent = info.phase === 'done' ? '100%' : info.prog + '%';
document.getElementById('chbar-l4').textContent = 'Электронов';
document.getElementById('chbar-v4').textContent = info.e + ' e⁻';
document.getElementById('chbar-l5').textContent = 'Тип';
document.getElementById('chbar-v5').innerHTML = info.phase === 'done' ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : '—';
}
// _openIonExchange is now handled by _openChemistry('ionex')
function ionexRxn(id, el) {
document.querySelectorAll('.ionex-rxn-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
if (ioxSim) { ioxSim.setReaction(id); }
}
function ionexStart() {
if (ioxSim) ioxSim.start();
}
function ionexReset() {
if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 });
if (ioxSim) ioxSim.reset();
}
function _ionexUpdateUI(info) {
if (!info) return;
const phaseMap = { idle: 'ожидание', mixing: 'смешивание', pairing: 'реакция', done: 'завершена' };
const rxn = IonExSim.RXN[ioxSim.rxnId];
document.getElementById('chbar-l1').textContent = 'Реакция';
document.getElementById('chbar-v1').textContent = info.rxn || '—';
document.getElementById('chbar-l2').textContent = 'Фаза';
document.getElementById('chbar-v2').textContent = phaseMap[info.phase] || info.phase;
document.getElementById('chbar-l3').textContent = 'Прогресс';
document.getElementById('chbar-v3').textContent = info.phase === 'done' ? '100%' : info.prog + '%';
document.getElementById('chbar-l4').textContent = 'Осадок';
document.getElementById('chbar-v4').textContent = info.precip > 0 ? info.precip + ' ч.' : '—';
document.getElementById('chbar-l5').textContent = 'Продукт';
document.getElementById('chbar-v5').textContent = rxn ? (rxn.sign || '—') : '—';
}
/* ════════════════════════════════
ЗАКОНЫ НЬЮТОНА
════════════════════════════════ */
/* ══════════════════════════════
DYNAMICS (unified Newton + Sandbox)
══════════════════════════════ */