Files
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

567 lines
21 KiB
JavaScript
Raw Permalink 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';
/* ══════════════════════════════════════════════════════════════
IsoprocessSim — PV-diagram for 4 ideal-gas isoprocesses
n = 1, R = 0.0821 L·atm/mol·K; energies in Joules
Isothermal PV = const ΔU=0, W=nRT·ln(V2/V1), Q=W
Isochoric V = const W=0, ΔU=νCvΔT, Q=ΔU
Isobaric P = const W=PΔV, ΔU=νCvΔT, Q=ΔU+W
Adiabatic PV^γ = const Q=0, ΔU=-W, W=PΔV/(γ-1)
══════════════════════════════════════════════════════════════ */
class IsoprocessSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.n = 1;
this.R = 0.0821; // L·atm / mol·K
this.R_J = 8.314; // J / mol·K
this.gamma = 1.4; // 7/5 diatomic default
/* state */
this.P1 = 3.0; // atm
this.V1 = 10.0; // L
this._ratio = 0.5; // 0..1, maps end state position along process
/* process */
this.process = 'isothermal';
/* axis range */
this.Vmin = 1; this.Vmax = 33;
this.Pmin = 0.2; this.Pmax = 9.5;
/* margins */
this.ML = 52; this.MB = 46; this.MT = 20; this.MR = 18;
this._drag = null; // 'state1' | 'state2'
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
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;
}
setProcess(p) {
this.process = p;
if (window.LabFX) LabFX.sound.play('click');
this._dustFrame = 0;
this.draw();
this._emit();
}
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
getParams() { return { P1: this.P1, V1: this.V1, process: this.process }; }
setParams({ P1, V1 } = {}) {
if (P1 !== undefined) this.P1 = Math.max(0.4, Math.min(8, +P1));
if (V1 !== undefined) this.V1 = Math.max(2, Math.min(28, +V1));
this.draw(); this._emit();
}
setRatio(r) { this._ratio = Math.max(0.01, Math.min(0.99, +r)); this.draw(); this._emit(); }
/* ── coordinate transforms ─────────────────── */
_pw() { return this.W - this.ML - this.MR; }
_ph() { return this.H - this.MT - this.MB; }
_vx(v) { return this.ML + (v - this.Vmin) / (this.Vmax - this.Vmin) * this._pw(); }
_py(p) { return this.MT + (1 - (p - this.Pmin) / (this.Pmax - this.Pmin)) * this._ph(); }
_xv(x) { return this.Vmin + (x - this.ML) / this._pw() * (this.Vmax - this.Vmin); }
_yp(y) { return this.Pmin + (1 - (y - this.MT) / this._ph()) * (this.Pmax - this.Pmin); }
/* ── physics ───────────────────────────────── */
_T(P, V) { return P * V / (this.n * this.R); }
_state2() {
const { P1, V1, _ratio, gamma } = this;
/* ratio in [0..1] → multiplier in [0.2..3.5] for V2/V1 or P2/P1 */
const mult = 0.2 + _ratio * 3.3;
const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v));
const clampP = p => Math.max(this.Pmin + 0.05, Math.min(this.Pmax - 0.1, p));
switch (this.process) {
case 'isothermal': {
const V2 = clampV(V1 * mult);
return { P2: clampP(P1 * V1 / V2), V2 };
}
case 'isochoric': {
return { P2: clampP(P1 * mult), V2: V1 };
}
case 'isobaric': {
const V2 = clampV(V1 * mult);
return { P2: P1, V2 };
}
case 'adiabatic': {
const V2 = clampV(V1 * mult);
return { P2: clampP(P1 * Math.pow(V1 / V2, gamma)), V2 };
}
}
return { P2: P1, V2: V1 };
}
info() {
const { P1, V1, n, R_J, gamma } = this;
const T1 = this._T(P1, V1);
const { P2, V2 } = this._state2();
const T2 = this._T(P2, V2);
/* internal energy: ΔU = νCvΔT, Cv = R/(γ-1) */
const Cv_J = R_J / (gamma - 1);
const dU_J = n * Cv_J * (T2 - T1);
/* P in Pa = P_atm * 101325, V in m³ = V_L * 0.001 */
const P1Pa = P1 * 101325, P2Pa = P2 * 101325;
const V1m3 = V1 * 0.001, V2m3 = V2 * 0.001;
let W_J = 0, Q_J = 0;
switch (this.process) {
case 'isothermal':
W_J = n * R_J * T1 * Math.log(V2 / V1);
Q_J = W_J; break;
case 'isochoric':
W_J = 0; Q_J = dU_J; break;
case 'isobaric':
W_J = P1Pa * (V2m3 - V1m3);
Q_J = dU_J + W_J; break;
case 'adiabatic':
Q_J = 0;
W_J = -dU_J; break;
}
const fmt = x => (x >= 0 ? '+' : '') + Math.round(x);
return {
P1: P1.toFixed(2), V1: V1.toFixed(1), T1: Math.round(T1),
P2: P2.toFixed(2), V2: V2.toFixed(1), T2: Math.round(T2),
W: fmt(W_J), Q: fmt(Q_J), dU: fmt(Math.round(dU_J)),
W_raw: W_J, Q_raw: Q_J, dU_raw: dU_J,
process: this.process,
};
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/* ── draw ──────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
this._drawGrid(ctx);
this._drawBgCurves(ctx);
this._drawActiveCurve(ctx);
this._drawPoints(ctx);
this._drawInfoBox(ctx);
/* emit dust particle along PV curve every ~5 draw calls */
if (window.LabFX) {
this._dustFrame = (this._dustFrame || 0) + 1;
if (this._dustFrame % 5 === 0) {
const { P2, V2 } = this._state2();
/* interpolated point at 50% of curve for dust */
const u = ((this._dustFrame / 5) % 10) / 10;
let px, py;
switch (this.process) {
case 'isothermal': { const v = this.V1 + u * (V2 - this.V1); px = this._vx(v); py = this._py(this.P1 * this.V1 / v); break; }
case 'isochoric': { px = this._vx(this.V1); py = this._py(this.P1 + u * (P2 - this.P1)); break; }
case 'isobaric': { const v2 = this.V1 + u * (V2 - this.V1); px = this._vx(v2); py = this._py(this.P1); break; }
case 'adiabatic': { const v3 = this.V1 + u * (V2 - this.V1); px = this._vx(v3); py = this._py(this.P1 * Math.pow(this.V1 / v3, this.gamma)); break; }
default: px = this._vx(this.V1); py = this._py(this.P1);
}
LabFX.particles.emit({
ctx, x: px, y: py,
count: 1, color: '#06D6E0', speed: 8,
spread: Math.PI * 2, angle: 0, gravity: 0,
life: 300, shape: 'dust', size: 2,
});
}
LabFX.particles.draw(ctx);
}
}
_drawGrid(ctx) {
const { ML, MT, MR, MB } = this;
const pw = this._pw(), ph = this._ph();
/* plot background */
ctx.fillStyle = 'rgba(255,255,255,0.018)';
ctx.fillRect(ML, MT, pw, ph);
/* grid */
ctx.strokeStyle = 'rgba(255,255,255,0.055)';
ctx.lineWidth = 1; ctx.setLineDash([]);
for (let v = 5; v <= 30; v += 5) {
const x = this._vx(v);
ctx.beginPath(); ctx.moveTo(x, MT); ctx.lineTo(x, MT + ph); ctx.stroke();
}
for (let p = 1; p <= 9; p++) {
const y = this._py(p);
ctx.beginPath(); ctx.moveTo(ML, y); ctx.lineTo(ML + pw, y); ctx.stroke();
}
/* axes */
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph);
ctx.stroke();
/* tick labels */
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let p = 1; p <= 9; p++) {
const y = this._py(p);
if (y < MT + 2 || y > MT + ph - 2) continue;
ctx.fillText(p, ML - 6, y);
}
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let v = 5; v <= 30; v += 5) {
const x = this._vx(v);
ctx.fillText(v, x, MT + ph + 5);
}
/* axis titles */
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = '12px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('V, л', ML + pw / 2, MT + ph + 32);
ctx.save();
ctx.translate(13, MT + ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('P, атм', 0, 0);
ctx.restore();
}
_COLORS = {
isothermal: '#EF476F',
isochoric: '#06D6E0',
isobaric: '#7BF5A4',
adiabatic: '#FFD166',
};
/* draw one process curve through (P1,V1) */
_curve(ctx, process, alpha, lw, dashed) {
const { P1, V1, gamma, Vmin, Vmax, Pmin, Pmax } = this;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = this._COLORS[process];
ctx.lineWidth = lw;
ctx.setLineDash(dashed ? [5, 4] : []);
ctx.beginPath();
if (process === 'isochoric') {
const x = this._vx(V1);
ctx.moveTo(x, this._py(Pmax));
ctx.lineTo(x, this._py(Pmin));
} else {
let started = false;
const steps = 300;
for (let i = 0; i <= steps; i++) {
const v = Vmin + (Vmax - Vmin) * i / steps;
let p;
if (process === 'isothermal') p = P1 * V1 / v;
else if (process === 'isobaric') p = P1;
else p = P1 * Math.pow(V1 / v, gamma); // adiabatic
if (p < Pmin || p > Pmax + 0.1) { started = false; continue; }
const x = this._vx(v), y = this._py(Math.min(p, Pmax));
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
}
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
_drawBgCurves(ctx) {
for (const p of ['isothermal', 'isochoric', 'isobaric', 'adiabatic']) {
if (p !== this.process) this._curve(ctx, p, 0.14, 1.2, true);
}
/* legend dots */
const names = { isothermal: 'Изотерма', isochoric: 'Изохора', isobaric: 'Изобара', adiabatic: 'Адиабата' };
ctx.font = '10px Manrope, system-ui, sans-serif';
let lx = this.ML + this._pw() - 8, ly = this.MT + 8;
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
for (const [proc, label] of Object.entries(names)) {
const col = this._COLORS[proc];
const isCur = proc === this.process;
ctx.globalAlpha = isCur ? 0.85 : 0.3;
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(lx + 5, ly + 4, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillText(label, lx - 3, ly);
ly += 16;
}
ctx.globalAlpha = 1;
}
_drawActiveCurve(ctx) {
/* full curve dimmed */
this._curve(ctx, this.process, 0.3, 1.5, false);
/* highlighted segment state1 → state2 */
const { P1, V1, gamma } = this;
const { P2, V2 } = this._state2();
const color = this._COLORS[this.process];
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2.8;
ctx.setLineDash([]);
const steps = 200;
const [Vs, Ve] = V2 >= V1 ? [V1, V2] : [V2, V1];
if (this.process === 'isochoric') {
const x = this._vx(V1);
const y1c = this._py(P1), y2c = this._py(P2);
ctx.beginPath(); ctx.moveTo(x, y1c); ctx.lineTo(x, y2c); ctx.stroke();
this._arrowHead(ctx, x, y1c, x, y2c, color);
} else {
ctx.beginPath();
let started = false;
for (let i = 0; i <= steps; i++) {
const v = Vs + (Ve - Vs) * i / steps;
let p;
if (this.process === 'isothermal') p = P1 * V1 / v;
else if (this.process === 'isobaric') p = P1;
else p = P1 * Math.pow(V1 / v, gamma);
const x = this._vx(v), y = this._py(p);
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
ctx.stroke();
/* arrow at ~80% of segment */
const vArr = Vs + (Ve - Vs) * 0.8;
const vArr2 = Vs + (Ve - Vs) * 0.82;
let p1a, p2a;
if (this.process === 'isothermal') { p1a = P1*V1/vArr; p2a = P1*V1/vArr2; }
else if (this.process === 'isobaric') { p1a = P1; p2a = P1; }
else { p1a = P1*Math.pow(V1/vArr,gamma); p2a = P1*Math.pow(V1/vArr2,gamma); }
/* ensure arrow points from 1→2 */
const dir = V2 > V1 ? 1 : -1;
this._arrowHead(ctx,
this._vx(vArr + dir*0), this._py(p1a + dir*0),
this._vx(vArr2 + dir*0), this._py(p2a + dir*0), color);
}
ctx.restore();
}
_arrowHead(ctx, x1, y1, x2, y2, color) {
const angle = Math.atan2(y2 - y1, x2 - x1);
const s = 10;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - s * Math.cos(angle - 0.4), y2 - s * Math.sin(angle - 0.4));
ctx.lineTo(x2 - s * Math.cos(angle + 0.4), y2 - s * Math.sin(angle + 0.4));
ctx.closePath(); ctx.fill();
}
_drawPoints(ctx) {
const { P2, V2 } = this._state2();
const color = this._COLORS[this.process];
const dot = (x, y, fill, label, textX, textY) => {
ctx.fillStyle = fill;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.stroke();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.fillStyle = fill; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(label, textX, textY);
};
const x1 = this._vx(this.V1), y1 = this._py(this.P1);
const x2 = this._vx(V2), y2 = this._py(P2);
dot(x1, y1, '#9B5DE5', '1', x1 - 12, y1 - 4);
dot(x2, y2, color, '2', x2 + 12, y2 - 4);
}
_drawInfoBox(ctx) {
const info = this.info();
const color = this._COLORS[info.process];
const names = { isothermal:'Изотермический', isochoric:'Изохорный', isobaric:'Изобарный', adiabatic:'Адиабатический' };
const formulas = { isothermal:'PV = const', isochoric:'V = const', isobaric:'P = const', adiabatic:'PV^γ = const' };
const bx = this.ML + 6, by = this.MT + 6;
const boxW = 205, boxH = 98;
ctx.fillStyle = 'rgba(13,13,26,0.9)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(`${names[info.process]} ${formulas[info.process]}`, bx + 10, by + 8);
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillText(`T₁ = ${info.T1} K → T₂ = ${info.T2} K`, bx + 10, by + 28);
const wColor = info.W_raw > 0 ? '#7BF5A4' : info.W_raw < 0 ? '#EF476F' : 'rgba(255,255,255,0.4)';
const qColor = info.Q_raw > 0 ? '#FFD166' : info.Q_raw < 0 ? '#06D6E0' : 'rgba(255,255,255,0.4)';
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('W =', bx + 10, by + 48);
ctx.fillStyle = wColor; ctx.fillText(`${info.W} Дж`, bx + 38, by + 48);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('Q =', bx + 10, by + 65);
ctx.fillStyle = qColor; ctx.fillText(`${info.Q} Дж`, bx + 38, by + 65);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('ΔU =', bx + 10, by + 82);
ctx.fillStyle = 'rgba(255,255,255,0.65)'; ctx.fillText(`${info.dU} Дж`, bx + 40, by + 82);
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
const pos = e => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return {
px: (t.clientX - r.left) * (this.W / r.width),
py: (t.clientY - r.top) * (this.H / r.height),
};
};
const hit = (px, py) => {
const x1 = this._vx(this.V1), y1 = this._py(this.P1);
if (Math.hypot(px - x1, py - y1) < 18) return 'state1';
const { P2, V2 } = this._state2();
const x2 = this._vx(V2), y2 = this._py(P2);
if (Math.hypot(px - x2, py - y2) < 18) return 'state2';
return null;
};
const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v));
const clampP = p => Math.max(this.Pmin + 0.1, Math.min(this.Pmax - 0.1, p));
const onDown = e => { const { px, py } = pos(e); this._drag = hit(px, py); };
const onMove = e => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { px, py } = pos(e);
const v = this._xv(px), p = this._yp(py);
if (this._drag === 'state1') {
this.V1 = clampV(v); this.P1 = clampP(p);
} else {
/* constrain state2 to current process curve */
switch (this.process) {
case 'isothermal': case 'isobaric': case 'adiabatic': {
const V2 = clampV(v);
this._ratio = Math.max(0.01, Math.min(0.99, (V2 / this.V1 - 0.2) / 3.3));
break;
}
case 'isochoric': {
const P2 = clampP(p);
this._ratio = Math.max(0.01, Math.min(0.99, (P2 / this.P1 - 0.2) / 3.3));
break;
}
}
}
this.draw(); this._emit();
};
const onUp = () => { this._drag = null; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { px, py } = pos(e);
cv.style.cursor = hit(px, py) ? 'grab' : 'default';
});
}
}
/* ─── lab UI init ─────────────────────────────────── */
var isoSim = null;
function _openIsoprocess() {
document.getElementById('sim-topbar-title').textContent = 'Изопроцессы';
_simShow('sim-isoprocess');
_registerSimState('isoprocess', () => isoSim?.getParams(),
st => { if (isoSim) { isoSim.setParams(st); if (st.process) isoSim.setProcess(st.process); } });
if (_embedMode) _startStateEmit('isoprocess');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!isoSim) {
isoSim = new IsoprocessSim(document.getElementById('isoprocess-canvas'));
isoSim.onUpdate = _isoUpdateUI;
isoSim.setGamma(1.667);
}
isoSim.fit();
isoSim.draw();
isoSim._emit();
}));
}
function isoProc(proc, el) {
document.querySelectorAll('.iso-proc-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
if (isoSim) isoSim.setProcess(proc);
/* sound already emitted inside setProcess */
}
function isoGamma(g, el) {
document.querySelectorAll('.iso-gamma-btn').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
if (isoSim) isoSim.setGamma(g);
}
function isoParam(name, val) {
const v = parseFloat(val);
if (name === 'P1') { document.getElementById('iso-p1-val').textContent = v.toFixed(1); if (isoSim) isoSim.setParams({ P1: v }); }
if (name === 'V1') { document.getElementById('iso-v1-val').textContent = v; if (isoSim) isoSim.setParams({ V1: v }); }
}
function isoRatio(val) { if (isoSim) isoSim.setRatio(parseFloat(val)); }
function isoPreset(name) {
const P = {
iso_expand: { proc:'isothermal', P1:4, V1:8, ratio:0.75, gamma:1.4 },
iso_comp: { proc:'isothermal', P1:1.5, V1:20, ratio:0.25, gamma:1.4 },
heat_iso: { proc:'isochoric', P1:2, V1:10, ratio:0.72, gamma:1.667 },
adiab_exp: { proc:'adiabatic', P1:5, V1:6, ratio:0.7, gamma:1.667 },
};
const p = P[name]; if (!p) return;
document.querySelectorAll('.iso-proc-btn').forEach(b => b.classList.remove('active'));
const pb = document.getElementById(`iproc-${p.proc}`); if (pb) pb.classList.add('active');
document.querySelectorAll('.iso-gamma-btn').forEach(b => b.classList.remove('active'));
const gb = document.getElementById(p.gamma === 1.4 ? 'igamma-14' : 'igamma-167'); if (gb) gb.classList.add('active');
document.getElementById('sl-iso-p1').value = p.P1; document.getElementById('iso-p1-val').textContent = p.P1.toFixed(1);
document.getElementById('sl-iso-v1').value = p.V1; document.getElementById('iso-v1-val').textContent = p.V1;
document.getElementById('sl-iso-ratio').value = p.ratio;
if (isoSim) { isoSim.setGamma(p.gamma); isoSim.setProcess(p.proc); isoSim.setParams({ P1: p.P1, V1: p.V1 }); isoSim.setRatio(p.ratio); }
}
function _isoUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('isobar-t1', info.T1);
v('isobar-t2', info.T2);
v('isobar-w', info.W);
v('isobar-q', info.Q);
v('isobar-du', info.dU);
}
/* ── titration ── */