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>
911 lines
33 KiB
JavaScript
911 lines
33 KiB
JavaScript
'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.2–4.0
|
||
this.Ea = 2.0; // activation energy 0.5–5.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)
|
||
══════════════════════════════ */
|
||
|