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>
546 lines
19 KiB
JavaScript
546 lines
19 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* EquilibriumSim — Chemical equilibrium simulation.
|
|
* A + B ⇌ C + D with Arrhenius kinetics, Le Chatelier principle.
|
|
* Left: particle animation with collisions & reactions.
|
|
* Right (30%): live concentration graph over time.
|
|
*/
|
|
class EquilibriumSim {
|
|
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, nD}]
|
|
this._nextId = 0;
|
|
|
|
/* parameters */
|
|
this.T = 300; // temperature K
|
|
this.nA = 20; // initial A count
|
|
this.nB = 20; // initial B count
|
|
this.Ea_f = 50; // forward activation energy
|
|
this.Ea_r = 55; // reverse activation energy
|
|
|
|
/* runtime */
|
|
this._steps = 0;
|
|
this._raf = null;
|
|
this._dpr = 1;
|
|
this.playing = false;
|
|
this.onUpdate = null;
|
|
|
|
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
|
|
}
|
|
|
|
/* ═══════════════════════ public API ═══════════════════════ */
|
|
|
|
fit() {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
this._dpr = dpr;
|
|
const w = this.canvas.offsetWidth || 600;
|
|
const h = this.canvas.offsetHeight || 400;
|
|
this.canvas.width = w * dpr;
|
|
this.canvas.height = h * dpr;
|
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
this.W = w; this.H = h;
|
|
this.reset();
|
|
}
|
|
|
|
getParams() { return { T: this.T, nA: this.nA, nB: this.nB, Ea_f: this.Ea_f, Ea_r: this.Ea_r }; }
|
|
setParams({ T, nA, nB, Ea_f, Ea_r } = {}) {
|
|
let needReset = false;
|
|
if (T !== undefined) this.T = Math.max(200, Math.min(500, +T));
|
|
if (Ea_f !== undefined) this.Ea_f = +Ea_f;
|
|
if (Ea_r !== undefined) this.Ea_r = +Ea_r;
|
|
if (nA !== undefined) { this.nA = Math.max(10, Math.min(40, +nA)); needReset = true; }
|
|
if (nB !== undefined) { this.nB = Math.max(10, Math.min(40, +nB)); needReset = true; }
|
|
if (needReset) this.reset();
|
|
this.draw();
|
|
this._emit();
|
|
}
|
|
|
|
preset(name) {
|
|
const presets = {
|
|
default: { T: 300, nA: 20, nB: 20, Ea_f: 50, Ea_r: 55 },
|
|
exothermic: { T: 280, nA: 20, nB: 20, Ea_f: 35, Ea_r: 65 },
|
|
endothermic: { T: 350, nA: 20, nB: 20, Ea_f: 65, Ea_r: 35 },
|
|
excess_A: { T: 300, nA: 35, nB: 15, Ea_f: 50, Ea_r: 55 },
|
|
};
|
|
const p = presets[name] || presets.default;
|
|
Object.assign(this, p);
|
|
this.reset();
|
|
}
|
|
|
|
reset() {
|
|
this.pause();
|
|
const { W, H } = this;
|
|
if (!W || !H) return;
|
|
this.particles = [];
|
|
this.flashes = [];
|
|
this._history = [];
|
|
this._steps = 0;
|
|
this._nextId = 0;
|
|
|
|
const simW = W * 0.7;
|
|
this._spawnType('A', this.nA, simW);
|
|
this._spawnType('B', this.nB, simW);
|
|
this._recordHistory();
|
|
this.draw();
|
|
this._emit();
|
|
}
|
|
|
|
play() {
|
|
if (this.playing) return;
|
|
this.playing = true;
|
|
this._tick();
|
|
}
|
|
|
|
pause() {
|
|
this.playing = false;
|
|
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
|
|
}
|
|
|
|
start() { this.play(); }
|
|
stop() { this.pause(); }
|
|
|
|
info() {
|
|
let nA = 0, nB = 0, nC = 0, nD = 0;
|
|
for (const p of this.particles) {
|
|
if (p.type === 'A') nA++;
|
|
else if (p.type === 'B') nB++;
|
|
else if (p.type === 'C') nC++;
|
|
else nD++;
|
|
}
|
|
const cA = nA || 0.001, cB = nB || 0.001;
|
|
const cC = nC || 0.001, cD = nD || 0.001;
|
|
const Q = (cC * cD) / (cA * cB);
|
|
const keq = Math.exp((this.Ea_f - this.Ea_r) / (this.T * 0.05));
|
|
const direction = Q < keq * 0.95 ? '\u2192' : Q > keq * 1.05 ? '\u2190' : '\u21CC';
|
|
return { keq: +keq.toFixed(3), Q: +Q.toFixed(3), direction, nA, nB, nC, nD };
|
|
}
|
|
|
|
/* ═══════════════════════ internals ═══════════════════════ */
|
|
|
|
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
|
|
|
|
_tick() {
|
|
if (!this.playing) return;
|
|
this._raf = requestAnimationFrame((ts) => {
|
|
const dt = 0.016; // ~60fps fixed step for LabFX
|
|
if (window.LabFX) LabFX.particles.update(dt);
|
|
for (let i = 0; i < 3; i++) this._step();
|
|
this.draw();
|
|
this._tick();
|
|
});
|
|
}
|
|
|
|
_color(type) {
|
|
return { A: '#EF476F', B: '#9B5DE5', C: '#7BF5A4', D: '#FFD166' }[type] || '#aaa';
|
|
}
|
|
|
|
_radius() { return 5; }
|
|
|
|
_spawnType(type, count, maxX) {
|
|
const { H } = this;
|
|
const r = this._radius();
|
|
const margin = 10;
|
|
let placed = 0, att = 0;
|
|
while (placed < count && att < count * 60) {
|
|
att++;
|
|
const x = margin + r + Math.random() * (maxX - 2 * r - margin * 2);
|
|
const y = margin + r + Math.random() * (H - 2 * r - margin * 2);
|
|
let overlap = false;
|
|
for (const p of this.particles) {
|
|
if ((p.x - x) ** 2 + (p.y - y) ** 2 < (p.r + r + 1) ** 2) { overlap = true; break; }
|
|
}
|
|
if (overlap) continue;
|
|
const a = Math.random() * Math.PI * 2;
|
|
const spd = 1.5 + Math.random() * 1.5;
|
|
this.particles.push({ x, y, vx: Math.cos(a) * spd, vy: Math.sin(a) * spd, r, type, id: this._nextId++ });
|
|
placed++;
|
|
}
|
|
}
|
|
|
|
_step() {
|
|
const { W, H } = this;
|
|
const simW = W * 0.7;
|
|
const dt = 0.6;
|
|
|
|
/* move + walls */
|
|
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 > simW - p.r) { p.x = simW - 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); }
|
|
}
|
|
|
|
/* spatial grid */
|
|
const cs = 18;
|
|
const cols = Math.ceil(simW / cs) + 1;
|
|
const grid = new Map();
|
|
for (let i = 0; i < this.particles.length; i++) {
|
|
const p = this.particles[i];
|
|
const k = Math.floor(p.x / cs) + Math.floor(p.y / cs) * cols;
|
|
if (!grid.has(k)) grid.set(k, []);
|
|
grid.get(k).push(i);
|
|
}
|
|
|
|
const toRemove = new Set();
|
|
const toAdd = [];
|
|
|
|
/* collisions + reactions */
|
|
for (let i = 0; i < this.particles.length; i++) {
|
|
const p1 = this.particles[i];
|
|
if (toRemove.has(p1.id)) continue;
|
|
const cx = Math.floor(p1.x / cs), cy = Math.floor(p1.y / cs);
|
|
for (let dcx = -1; dcx <= 1; dcx++) for (let dcy = -1; dcy <= 1; dcy++) {
|
|
const cell = grid.get((cx + dcx) + (cy + dcy) * cols);
|
|
if (!cell) continue;
|
|
for (const j of cell) {
|
|
if (j <= i) continue;
|
|
const p2 = this.particles[j];
|
|
if (toRemove.has(p2.id)) continue;
|
|
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
|
const dist2 = dx * dx + dy * dy;
|
|
const minD = p1.r + p2.r;
|
|
if (dist2 >= minD * minD) continue;
|
|
const dist = Math.sqrt(dist2);
|
|
|
|
/* forward: 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 + D */
|
|
const isAB = (p1.type === 'A' && p2.type === 'B') || (p1.type === 'B' && p2.type === 'A');
|
|
if (isAB) {
|
|
const kf = Math.exp(-this.Ea_f / (this.T * 0.08)) * 0.35;
|
|
if (Math.random() < kf) {
|
|
toRemove.add(p1.id); toRemove.add(p2.id);
|
|
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
|
|
const a1 = Math.random() * Math.PI * 2;
|
|
const spd = 1.2 + Math.random();
|
|
toAdd.push({ x: mx + Math.cos(a1) * 4, y: my + Math.sin(a1) * 4, vx: Math.cos(a1) * spd, vy: Math.sin(a1) * spd, r: 5, type: 'C', id: this._nextId++ });
|
|
toAdd.push({ x: mx - Math.cos(a1) * 4, y: my - Math.sin(a1) * 4, vx: -Math.cos(a1) * spd, vy: -Math.sin(a1) * spd, r: 5, type: 'D', id: this._nextId++ });
|
|
this.flashes.push({ x: mx, y: my, t: 0, maxT: 18, color: '123,245,164' });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
/* reverse: C + D <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 */
|
|
const isCD = (p1.type === 'C' && p2.type === 'D') || (p1.type === 'D' && p2.type === 'C');
|
|
if (isCD) {
|
|
const kr = Math.exp(-this.Ea_r / (this.T * 0.08)) * 0.35;
|
|
if (Math.random() < kr) {
|
|
toRemove.add(p1.id); toRemove.add(p2.id);
|
|
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
|
|
const a1 = Math.random() * Math.PI * 2;
|
|
const spd = 1.2 + Math.random();
|
|
toAdd.push({ x: mx + Math.cos(a1) * 4, y: my + Math.sin(a1) * 4, vx: Math.cos(a1) * spd, vy: Math.sin(a1) * spd, r: 5, type: 'A', id: this._nextId++ });
|
|
toAdd.push({ x: mx - Math.cos(a1) * 4, y: my - Math.sin(a1) * 4, vx: -Math.cos(a1) * spd, vy: -Math.sin(a1) * spd, r: 5, type: 'B', id: this._nextId++ });
|
|
this.flashes.push({ x: mx, y: my, t: 0, maxT: 18, color: '239,71,111' });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
/* elastic bounce */
|
|
if (dist > 0.001) {
|
|
const nx = dx / dist, ny = dy / dist;
|
|
const dvn = (p1.vx - p2.vx) * nx + (p1.vy - p2.vy) * ny;
|
|
if (dvn > 0) {
|
|
p1.vx -= dvn * nx; p1.vy -= dvn * ny;
|
|
p2.vx += dvn * nx; p2.vy += dvn * ny;
|
|
}
|
|
const ov = (minD - dist) * 0.5;
|
|
p1.x -= nx * ov; p1.y -= ny * ov;
|
|
p2.x += nx * ov; p2.y += ny * ov;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (toRemove.size) {
|
|
// LabFX: throttled tick sound + spark on each collision
|
|
if (window.LabFX && toRemove.size > 0) {
|
|
const now = performance.now();
|
|
if (!this._lastFxTick || now - this._lastFxTick > 200) {
|
|
this._lastFxTick = now;
|
|
LabFX.sound.play('tick', { volume: 0.1 });
|
|
}
|
|
for (const id of toRemove) {
|
|
const hit = this.particles.find(p => p.id === id);
|
|
if (hit) {
|
|
LabFX.particles.emit({ ctx: this.ctx, x: hit.x, y: hit.y, count: 2,
|
|
color: '#FFD166', speed: 30, spread: 3.14, angle: 0,
|
|
gravity: 0, life: 250, shape: 'spark', glow: true });
|
|
}
|
|
}
|
|
}
|
|
this.particles = this.particles.filter(p => !toRemove.has(p.id));
|
|
}
|
|
for (const p of toAdd) this.particles.push(p);
|
|
this.flashes = this.flashes.filter(f => ++f.t < f.maxT);
|
|
|
|
this._steps++;
|
|
if (this._steps % 20 === 0) {
|
|
this._recordHistory();
|
|
this._emit();
|
|
}
|
|
}
|
|
|
|
_recordHistory() {
|
|
let nA = 0, nB = 0, nC = 0, nD = 0;
|
|
for (const p of this.particles) {
|
|
if (p.type === 'A') nA++;
|
|
else if (p.type === 'B') nB++;
|
|
else if (p.type === 'C') nC++;
|
|
else nD++;
|
|
}
|
|
this._history.push({ step: this._steps, nA, nB, nC, nD });
|
|
if (this._history.length > 300) this._history.shift();
|
|
}
|
|
|
|
/* ═══════════════════════ rendering ═══════════════════════ */
|
|
|
|
draw() {
|
|
const { ctx, W, H } = this;
|
|
if (!W || !H) return;
|
|
const simW = W * 0.7;
|
|
|
|
/* background */
|
|
ctx.fillStyle = '#0D0D1A';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
/* dot grid */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.025)';
|
|
for (let x = 30; x < simW; x += 30)
|
|
for (let y = 30; y < H; y += 30) {
|
|
ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
|
|
/* divider */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
|
ctx.fillRect(simW - 1, 0, 2, H);
|
|
|
|
/* flashes */
|
|
for (const f of this.flashes) {
|
|
const prog = f.t / f.maxT;
|
|
const radius = prog * 38 + 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.5})`);
|
|
g.addColorStop(0.4, `rgba(${f.color},${alpha * 0.4})`);
|
|
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);
|
|
|
|
/* right panel: concentration graph */
|
|
this._drawGraph(ctx, simW, W, H);
|
|
|
|
/* stats overlay */
|
|
this._drawStats(ctx);
|
|
|
|
/* equation label */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.28)';
|
|
ctx.font = "bold 11px 'Manrope', system-ui, sans-serif";
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('A + B \u21CC C + D', simW / 2, H - 12);
|
|
|
|
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.2);
|
|
glow.addColorStop(0, col + '44');
|
|
glow.addColorStop(1, col + '00');
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath(); ctx.arc(x, y, r * 3.2, 0, Math.PI * 2); ctx.fill();
|
|
|
|
/* body gradient */
|
|
const body = ctx.createRadialGradient(x - r * 0.25, y - r * 0.25, r * 0.05, x, y, r);
|
|
body.addColorStop(0, col + 'ff');
|
|
body.addColorStop(0.6, col + 'cc');
|
|
body.addColorStop(1, col + '88');
|
|
ctx.fillStyle = body;
|
|
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
|
|
|
/* specular */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.38)';
|
|
ctx.beginPath(); ctx.arc(x - r * 0.28, y - r * 0.28, r * 0.28, 0, Math.PI * 2); ctx.fill();
|
|
|
|
/* label */
|
|
ctx.fillStyle = 'rgba(0,0,0,0.65)';
|
|
ctx.font = `bold ${Math.round(r * 1.1)}px sans-serif`;
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
ctx.fillText(p.type, x, y + 0.5);
|
|
ctx.textBaseline = 'alphabetic';
|
|
}
|
|
|
|
_drawGraph(ctx, x0, W, H) {
|
|
const gW = W - x0, pad = { l: 36, r: 10, t: 32, b: 28 };
|
|
const px = x0 + pad.l, py = pad.t;
|
|
const pw = gW - pad.l - pad.r;
|
|
const ph = H - pad.t - pad.b;
|
|
|
|
/* panel bg */
|
|
ctx.fillStyle = 'rgba(5,5,20,0.85)';
|
|
ctx.fillRect(x0, 0, gW, H);
|
|
|
|
/* title */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
|
ctx.font = "10px 'Manrope', system-ui, sans-serif";
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText('\u041A\u043E\u043D\u0446\u0435\u043D\u0442\u0440\u0430\u0446\u0438\u044F', x0 + 10, 16);
|
|
|
|
/* grid */
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 0.5;
|
|
for (let i = 0; i <= 4; i++) {
|
|
const yl = py + ph * (i / 4);
|
|
ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke();
|
|
}
|
|
|
|
/* y-axis labels */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
|
ctx.font = "8px 'Manrope', system-ui, sans-serif";
|
|
ctx.textAlign = 'right';
|
|
const maxN = Math.max(this.nA, this.nB) * 1.2 + 2;
|
|
for (let i = 0; i <= 4; i++) {
|
|
const v = Math.round(maxN * (4 - i) / 4);
|
|
ctx.fillText(v, px - 4, py + ph * (i / 4) + 3);
|
|
}
|
|
|
|
if (this._history.length < 2) return;
|
|
const n = this._history.length;
|
|
|
|
const lines = [
|
|
{ key: 'nA', color: '#EF476F', label: 'A' },
|
|
{ key: 'nB', color: '#9B5DE5', label: 'B' },
|
|
{ key: 'nC', color: '#7BF5A4', label: 'C' },
|
|
{ key: 'nD', color: '#FFD166', label: 'D' },
|
|
];
|
|
|
|
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 */
|
|
lines.forEach(({ color, label }, i) => {
|
|
const lx = x0 + 10 + i * 38;
|
|
const ly = H - 14;
|
|
ctx.fillStyle = color;
|
|
ctx.fillRect(lx, ly, 10, 2.5);
|
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
|
ctx.font = "9px 'Manrope', system-ui, sans-serif";
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(label, lx + 13, ly + 3);
|
|
});
|
|
|
|
/* current values */
|
|
const last = this._history[n - 1];
|
|
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} D:${last.nD}`, x0 + gW - 8, H - 14);
|
|
}
|
|
|
|
_drawStats(ctx) {
|
|
const info = this.info();
|
|
const px = 10, py = 10, pw = 160, ph = 82;
|
|
|
|
ctx.fillStyle = 'rgba(5,5,20,0.82)';
|
|
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
|
|
|
|
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
|
ctx.font = "10px 'Manrope', system-ui, sans-serif";
|
|
const lh = 16;
|
|
|
|
ctx.fillStyle = '#7BF5A4';
|
|
ctx.fillText(`K\u2091\u2071 = ${info.keq}`, px + 10, py + 8);
|
|
|
|
ctx.fillStyle = '#FFD166';
|
|
ctx.fillText(`Q = ${info.Q}`, px + 10, py + 8 + lh);
|
|
|
|
ctx.fillStyle = '#06D6E0';
|
|
ctx.fillText(`\u041D\u0430\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435: ${info.direction}`, px + 10, py + 8 + lh * 2);
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
|
ctx.fillText(`T = ${this.T} K`, px + 10, py + 8 + lh * 3);
|
|
}
|
|
|
|
/* ═══════════════════════ utility ═══════════════════════ */
|
|
|
|
_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();
|
|
}
|
|
}
|
|
|
|
if (typeof module !== 'undefined') module.exports = EquilibriumSim;
|
|
|
|
/* ─── lab UI init ─────────────────────────────────── */
|
|
function _openEquilibrium() {
|
|
document.getElementById('sim-topbar-title').textContent = 'Химическое равновесие';
|
|
_simShow('sim-equilibrium');
|
|
_registerSimState('equilibrium', () => eqSim?.getParams(), st => eqSim?.setParams(st));
|
|
if (_embedMode) _startStateEmit('equilibrium');
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
if (!eqSim) {
|
|
eqSim = new EquilibriumSim(document.getElementById('equilibrium-canvas'));
|
|
eqSim.onUpdate = _eqUpdateUI;
|
|
}
|
|
eqSim.fit();
|
|
eqSim.reset();
|
|
eqSim.play();
|
|
}));
|
|
}
|
|
|
|
function eqParam(name, val) {
|
|
const v = parseFloat(val);
|
|
const ids = { T: 'eq-T-val', Ea_f: 'eq-Eaf-val', Ea_r: 'eq-Ear-val' };
|
|
const el = document.getElementById(ids[name]);
|
|
if (el) el.textContent = v;
|
|
if (name === 'T' && window.LabFX) LabFX.sound.play('whoosh', { pitch: v / 300, volume: 0.3 });
|
|
if (eqSim) eqSim.setParams({ [name]: v });
|
|
}
|
|
|
|
function eqPreset(name) {
|
|
if (window.LabFX) LabFX.sound.play('pour', { volume: 0.3 });
|
|
if (eqSim) { eqSim.preset(name); eqSim.play(); }
|
|
const defs = { default: [300,50,55], exothermic: [280,35,65], endothermic: [350,65,35], excess_A: [300,50,55] };
|
|
const d = defs[name] || defs.default;
|
|
document.getElementById('sl-eq-T').value = d[0]; document.getElementById('eq-T-val').textContent = d[0];
|
|
document.getElementById('sl-eq-Eaf').value = d[1]; document.getElementById('eq-Eaf-val').textContent = d[1];
|
|
document.getElementById('sl-eq-Ear').value = d[2]; document.getElementById('eq-Ear-val').textContent = d[2];
|
|
}
|
|
|
|
function _eqUpdateUI(info) {
|
|
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
|
v('eqbar-v1', info.keq);
|
|
v('eqbar-v2', info.Q);
|
|
v('eqbar-v3', info.direction);
|
|
v('eqbar-v4', info.nA + '|' + info.nB + '|' + info.nC + '|' + info.nD);
|
|
}
|
|
|
|
/* ── thin lens ── */
|
|
|