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>
1004 lines
33 KiB
JavaScript
1004 lines
33 KiB
JavaScript
'use strict';
|
||
/* ══════════════════════════════════════════════════════════════
|
||
HeatEngineSim — тепловые двигатели
|
||
Поддерживаемые циклы: Carnot, Otto, Diesel, Brayton
|
||
n = 1 моль, R = 8.314 Дж/(моль·К), γ = 1.4
|
||
|
||
Левая часть: PV-диаграмма (Canvas 2D) — замкнутый цикл A→B→C→D→A
|
||
Правая часть: анимация поршня + частицы газа
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
class HeatEngineSim {
|
||
constructor(pvCanvas, pistonCanvas) {
|
||
this._pv = pvCanvas;
|
||
this._pis = pistonCanvas;
|
||
this._pvCtx = pvCanvas.getContext('2d');
|
||
this._pisCtx = pistonCanvas.getContext('2d');
|
||
|
||
/* physics constants */
|
||
this.n = 1;
|
||
this.R = 8.314;
|
||
this.gamma = 1.4;
|
||
|
||
/* parameters */
|
||
this.cycleType = 'carnot'; // 'carnot' | 'otto' | 'diesel' | 'brayton'
|
||
this.Th = 800; // K
|
||
this.Tc = 300; // K
|
||
this.cr = 8; // compression ratio (Otto/Diesel)
|
||
|
||
/* animation state */
|
||
this._running = false;
|
||
this._t = 0; // 0..1 progress through cycle
|
||
this._speed = 0.004; // dt per frame
|
||
this._raf = null;
|
||
|
||
/* PV viewport */
|
||
this._ML = 58; this._MB = 46; this._MT = 28; this._MR = 22;
|
||
this._pvW = 0; this._pvH = 0;
|
||
this._Vmin = 0; this._Vmax = 1;
|
||
this._Pmin = 0; this._Pmax = 1;
|
||
|
||
/* particles for piston view */
|
||
this._particles = [];
|
||
this._initParticles(40);
|
||
|
||
/* hover state */
|
||
this._hoverSeg = -1;
|
||
this._pv.addEventListener('mousemove', e => this._onPvMove(e));
|
||
this._pv.addEventListener('mouseleave', () => { this._hoverSeg = -1; this._drawPv(); });
|
||
|
||
/* tooltip */
|
||
this._tooltip = null;
|
||
|
||
/* callbacks */
|
||
this.onStats = null; // called with stats object
|
||
|
||
new ResizeObserver(() => { this.fit(); this._drawPv(); this._drawPiston(); }).observe(pvCanvas.parentElement || pvCanvas);
|
||
}
|
||
|
||
/* ── public API ─────────────────────────────────────── */
|
||
|
||
setCycle(type) { this.cycleType = type; this._recompute(); }
|
||
setTh(v) { this.Th = Math.max(this.Tc + 10, +v); this._recompute(); }
|
||
setTc(v) { this.Tc = Math.max(200, Math.min(this.Th - 10, +v)); this._recompute(); }
|
||
setCR(v) { this.cr = Math.max(2, Math.min(20, +v)); this._recompute(); }
|
||
|
||
start() {
|
||
if (!this._running) {
|
||
this._running = true;
|
||
this._lastPhase = null;
|
||
this._fxDroneHandle = null;
|
||
if (window.LabFX) {
|
||
this._fxDroneHandle = LabFX.sound.startDrone('drone');
|
||
}
|
||
this._loop();
|
||
}
|
||
}
|
||
pause() {
|
||
this._running = false;
|
||
cancelAnimationFrame(this._raf);
|
||
if (window.LabFX && this._fxDroneHandle) {
|
||
try { this._fxDroneHandle.stop(); } catch {}
|
||
this._fxDroneHandle = null;
|
||
}
|
||
}
|
||
stop() { this.pause(); this._t = 0; this._drawPv(); this._drawPiston(); }
|
||
step() { this._t = (this._t + this._speed * 10) % 1; this._drawPv(); this._drawPiston(); }
|
||
reset() { this.stop(); this._recompute(); }
|
||
|
||
fit() {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
for (const [cv, ctx, side] of [
|
||
[this._pv, this._pvCtx, 'pv'],
|
||
[this._pis, this._pisCtx, 'pis'],
|
||
]) {
|
||
const w = cv.offsetWidth || 400;
|
||
const h = cv.offsetHeight || 300;
|
||
cv.width = w * dpr;
|
||
cv.height = h * dpr;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
if (side === 'pv') { this._pvW = w; this._pvH = h; }
|
||
}
|
||
}
|
||
|
||
/* ── cycle computation ───────────────────────────────── */
|
||
|
||
_recompute() {
|
||
this._nodes = this['_nodes_' + this.cycleType]();
|
||
this._updateViewport();
|
||
this._emitStats();
|
||
this._drawPv();
|
||
this._drawPiston();
|
||
}
|
||
|
||
/* Returns array of {V,P,T,label,phase} nodes.
|
||
Segments: nodes[i] → nodes[(i+1)%n], phase type on segment start. */
|
||
|
||
_nodes_carnot() {
|
||
const { n, R, gamma, Th, Tc } = this;
|
||
/* choose V_A so diagarm looks good; derive B,C,D from Carnot chain */
|
||
const VA = 0.01; // m³ — 10 L
|
||
const PA = n * R * Th / VA;
|
||
|
||
/* A→B: isothermal at Th; pick V_B = 3*VA */
|
||
const VB = 3 * VA;
|
||
const PB = n * R * Th / VB;
|
||
|
||
/* B→C: adiabatic expansion to Tc
|
||
Tc/Th = (VB/VC)^(γ-1) → VC = VB*(Th/Tc)^(1/(γ-1)) */
|
||
const VC = VB * Math.pow(Th / Tc, 1 / (gamma - 1));
|
||
const PC = n * R * Tc / VC;
|
||
|
||
/* C→D: isothermal at Tc, pick VD so D→A is adiabatic
|
||
PD*VD^γ = PA*VA^γ and PD = nRTc/VD → VD = VA*(Th/Tc)^(1/(γ-1)) */
|
||
const VD = VA * Math.pow(Th / Tc, 1 / (gamma - 1));
|
||
const PD = n * R * Tc / VD;
|
||
|
||
return [
|
||
{ V: VA, P: PA, T: Th, label: 'A', phase: 'isotherm_hot' },
|
||
{ V: VB, P: PB, T: Th, label: 'B', phase: 'adiabat_exp' },
|
||
{ V: VC, P: PC, T: Tc, label: 'C', phase: 'isotherm_cold' },
|
||
{ V: VD, P: PD, T: Tc, label: 'D', phase: 'adiabat_comp' },
|
||
];
|
||
}
|
||
|
||
_nodes_otto() {
|
||
const { n, R, gamma, Th, Tc, cr } = this;
|
||
/* Otto: 1→2 adiabatic comp, 2→3 isochoric heat, 3→4 adiabatic exp, 4→1 isochoric cool */
|
||
const V1 = 0.02; // m³ — BDC
|
||
const V2 = V1 / cr; // TDC
|
||
const T1 = Tc;
|
||
const P1 = n * R * T1 / V1;
|
||
/* adiabatic compression 1→2 */
|
||
const T2 = T1 * Math.pow(cr, gamma - 1);
|
||
const P2 = P1 * Math.pow(cr, gamma);
|
||
/* isochoric heat 2→3: peak temp = Th */
|
||
const T3 = Th;
|
||
const P3 = P2 * (T3 / T2);
|
||
const V3 = V2;
|
||
/* adiabatic expansion 3→4 */
|
||
const T4 = T3 / Math.pow(cr, gamma - 1);
|
||
const P4 = P3 / Math.pow(cr, gamma);
|
||
const V4 = V1;
|
||
|
||
return [
|
||
{ V: V1, P: P1, T: T1, label: 'A', phase: 'adiabat_comp' },
|
||
{ V: V2, P: P2, T: T2, label: 'B', phase: 'isochoric_hot' },
|
||
{ V: V3, P: P3, T: T3, label: 'C', phase: 'adiabat_exp' },
|
||
{ V: V4, P: P4, T: T4, label: 'D', phase: 'isochoric_cold' },
|
||
];
|
||
}
|
||
|
||
_nodes_diesel() {
|
||
const { n, R, gamma, Th, Tc, cr } = this;
|
||
/* Diesel: 1→2 adiabatic comp, 2→3 isobaric heat, 3→4 adiabatic exp, 4→1 isochoric cool */
|
||
const V1 = 0.02;
|
||
const V2 = V1 / cr;
|
||
const T1 = Tc;
|
||
const P1 = n * R * T1 / V1;
|
||
/* adiabatic comp 1→2 */
|
||
const T2 = T1 * Math.pow(cr, gamma - 1);
|
||
const P2 = P1 * Math.pow(cr, gamma);
|
||
/* isobaric heat 2→3 to Th */
|
||
const T3 = Th;
|
||
const V3 = V2 * (T3 / T2);
|
||
const P3 = P2;
|
||
/* cutoff ratio */
|
||
const rc = V3 / V2;
|
||
/* adiabatic exp 3→4 */
|
||
const V4 = V1;
|
||
const P4 = P3 * Math.pow(V3 / V4, gamma);
|
||
const T4 = P4 * V4 / (n * R);
|
||
|
||
return [
|
||
{ V: V1, P: P1, T: T1, label: 'A', phase: 'adiabat_comp' },
|
||
{ V: V2, P: P2, T: T2, label: 'B', phase: 'isobar_hot' },
|
||
{ V: V3, P: P3, T: T3, label: 'C', phase: 'adiabat_exp' },
|
||
{ V: V4, P: P4, T: T4, label: 'D', phase: 'isochoric_cold' },
|
||
];
|
||
}
|
||
|
||
_nodes_brayton() {
|
||
const { n, R, gamma, Th, Tc, cr } = this;
|
||
/* Brayton (gas turbine): 1→2 adiabatic comp, 2→3 isobaric heat, 3→4 adiabatic exp, 4→1 isobaric cool */
|
||
const V1 = 0.025;
|
||
const T1 = Tc;
|
||
const P1 = n * R * T1 / V1;
|
||
const P2 = P1 * cr; // pressure ratio
|
||
/* adiabatic comp */
|
||
const T2 = T1 * Math.pow(cr, (gamma - 1) / gamma);
|
||
const V2 = n * R * T2 / P2;
|
||
/* isobaric heat to Th */
|
||
const T3 = Th;
|
||
const V3 = n * R * T3 / P2;
|
||
const P3 = P2;
|
||
/* adiabatic exp */
|
||
const P4 = P1;
|
||
const T4 = T3 / Math.pow(cr, (gamma - 1) / gamma);
|
||
const V4 = n * R * T4 / P4;
|
||
|
||
return [
|
||
{ V: V1, P: P1, T: T1, label: 'A', phase: 'adiabat_comp' },
|
||
{ V: V2, P: P2, T: T2, label: 'B', phase: 'isobar_hot' },
|
||
{ V: V3, P: P3, T: T3, label: 'C', phase: 'adiabat_exp' },
|
||
{ V: V4, P: P4, T: T4, label: 'D', phase: 'isochoric_cold' },
|
||
];
|
||
}
|
||
|
||
/* ── viewport & stats ─────────────────────────────── */
|
||
|
||
_updateViewport() {
|
||
const ns = this._nodes;
|
||
if (!ns || !ns.length) return;
|
||
const Vs = ns.map(n => n.V), Ps = ns.map(n => n.P);
|
||
const Vmin = Math.min(...Vs), Vmax = Math.max(...Vs);
|
||
const Pmin = Math.min(...Ps), Pmax = Math.max(...Ps);
|
||
const dV = Vmax - Vmin || 0.001;
|
||
const dP = Pmax - Pmin || 1;
|
||
this._Vmin = Vmin - dV * 0.18;
|
||
this._Vmax = Vmax + dV * 0.18;
|
||
this._Pmin = Pmin - dP * 0.18;
|
||
this._Pmax = Pmax + dP * 0.18;
|
||
}
|
||
|
||
_emitStats() {
|
||
if (!this.onStats) return;
|
||
const ns = this._nodes;
|
||
if (!ns) return;
|
||
|
||
/* calculate Q_hot, Q_cold, W_net by summing segment works */
|
||
let W = 0, Qh = 0, Qc = 0;
|
||
const { n: nn, R, gamma } = this;
|
||
for (let i = 0; i < ns.length; i++) {
|
||
const a = ns[i], b = ns[(i + 1) % ns.length];
|
||
const seg = a.phase;
|
||
const dW = this._segWork(a, b, seg);
|
||
const dQ = this._segHeat(a, b, seg, dW);
|
||
W += dW;
|
||
if (dQ > 0) Qh += dQ; else Qc += dQ;
|
||
}
|
||
|
||
const etaCarnot = 1 - this.Tc / this.Th;
|
||
const eta = Qh > 0 ? W / Qh : 0;
|
||
|
||
this.onStats({
|
||
Th: Math.round(this.Th),
|
||
Tc: Math.round(this.Tc),
|
||
etaCarnot: (etaCarnot * 100).toFixed(1),
|
||
eta: (Math.max(0, eta) * 100).toFixed(1),
|
||
Qh: Math.round(Qh),
|
||
Qc: Math.round(Math.abs(Qc)),
|
||
W: Math.round(W),
|
||
});
|
||
}
|
||
|
||
_segWork(a, b, phase) {
|
||
const { n, R, gamma } = this;
|
||
if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
|
||
return n * R * a.T * Math.log(b.V / a.V);
|
||
}
|
||
if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
|
||
return (a.P * a.V - b.P * b.V) / (gamma - 1);
|
||
}
|
||
if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
|
||
return 0;
|
||
}
|
||
if (phase === 'isobar_hot' || phase === 'isobar_cold' || phase === 'isochoric_cold') {
|
||
return a.P * (b.V - a.V);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
_segHeat(a, b, phase, W) {
|
||
const { n, R, gamma } = this;
|
||
const Cv = R / (gamma - 1);
|
||
const dU = n * Cv * (b.T - a.T);
|
||
if (phase === 'adiabat_exp' || phase === 'adiabat_comp') return 0;
|
||
return dU + W;
|
||
}
|
||
|
||
/* ── animation loop ──────────────────────────────── */
|
||
|
||
_loop() {
|
||
if (!this._running) return;
|
||
this._t = (this._t + this._speed) % 1;
|
||
this._updateParticles();
|
||
this._fxUpdate();
|
||
this._drawPv();
|
||
this._drawPiston();
|
||
this._raf = requestAnimationFrame(() => this._loop());
|
||
}
|
||
|
||
_fxUpdate() {
|
||
if (!window.LabFX) return;
|
||
const st = this._stateAt(this._t);
|
||
if (!st) return;
|
||
|
||
const pisCtx = this._pisCtx;
|
||
const W = this._pis.offsetWidth || 300;
|
||
const H = this._pis.offsetHeight || 300;
|
||
|
||
/* phase change tick */
|
||
if (this._lastPhase !== null && st.phase !== this._lastPhase) {
|
||
LabFX.sound.play('tick', { pitch: 0.8, volume: 0.2 });
|
||
/* BDC / TDC bounce — detect segment boundaries (u≈0) */
|
||
LabFX.sound.play('bounce', { pitch: 0.5, volume: 0.15 });
|
||
}
|
||
this._lastPhase = st.phase;
|
||
|
||
/* compute piston position for particle emit */
|
||
const ns = this._nodes;
|
||
const Vmin = Math.min(...ns.map(n => n.V));
|
||
const Vmax = Math.max(...ns.map(n => n.V));
|
||
const Vfrac = (st.V - Vmin) / (Vmax - Vmin || 1);
|
||
const cylX = W * 0.2, cylW = W * 0.6;
|
||
const cylTop = H * 0.12, cylBot = H * 0.92;
|
||
const cylH = cylBot - cylTop;
|
||
const pistonY = cylTop + cylH * (1 - Vfrac);
|
||
const resX = W * 0.05, resW = W * 0.12;
|
||
|
||
const isHot = st.phase === 'isotherm_hot' || st.phase === 'isochoric_hot' || st.phase === 'isobar_hot';
|
||
const isCold = st.phase === 'isotherm_cold' || st.phase === 'isochoric_cold' || st.phase === 'isobar_cold';
|
||
|
||
if (isHot) {
|
||
/* red smoke upward from hot reservoir */
|
||
LabFX.particles.emit({
|
||
ctx: pisCtx, x: resX + resW / 2, y: pistonY - 5,
|
||
count: 1, color: 'rgba(255,80,40,0.3)', speed: 15,
|
||
spread: 0.6, angle: -Math.PI / 2, gravity: -50,
|
||
life: 1500, shape: 'smoke', size: 6,
|
||
});
|
||
} else if (isCold) {
|
||
/* blue dust downward from cold reservoir */
|
||
LabFX.particles.emit({
|
||
ctx: pisCtx, x: resX + resW / 2, y: pistonY + 10,
|
||
count: 1, color: 'rgba(100,150,255,0.3)', speed: 10,
|
||
spread: 0.6, angle: Math.PI / 2, gravity: 30,
|
||
life: 800, shape: 'dust', size: 4,
|
||
});
|
||
}
|
||
|
||
LabFX.particles.update(this._speed);
|
||
}
|
||
|
||
/* ── interpolated state ──────────────────────────── */
|
||
|
||
_stateAt(t) {
|
||
const ns = this._nodes;
|
||
if (!ns || ns.length < 2) return null;
|
||
const N = ns.length;
|
||
const seg = Math.floor(t * N);
|
||
const u = (t * N) - seg;
|
||
const a = ns[seg % N];
|
||
const b = ns[(seg + 1) % N];
|
||
const phase = a.phase;
|
||
|
||
let V, P, T;
|
||
if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
|
||
/* isothermal: PV = const → V = Va + u*(Vb-Va), P = const/V */
|
||
V = a.V + u * (b.V - a.V);
|
||
P = a.P * a.V / V;
|
||
T = a.T;
|
||
} else if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
|
||
/* adiabatic: PV^γ = const → parametric by V */
|
||
V = a.V * Math.pow(b.V / a.V, u);
|
||
P = a.P * Math.pow(a.V / V, this.gamma);
|
||
T = P * V / (this.n * this.R);
|
||
} else if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
|
||
/* isochoric: V = const */
|
||
V = a.V;
|
||
P = a.P + u * (b.P - a.P);
|
||
T = P * V / (this.n * this.R);
|
||
} else {
|
||
/* isobaric */
|
||
V = a.V + u * (b.V - a.V);
|
||
P = a.P;
|
||
T = P * V / (this.n * this.R);
|
||
}
|
||
return { V, P, T, phase, seg };
|
||
}
|
||
|
||
/* ── PV diagram ──────────────────────────────────── */
|
||
|
||
_vx(v) {
|
||
const pw = this._pvW - this._ML - this._MR;
|
||
return this._ML + (v - this._Vmin) / (this._Vmax - this._Vmin) * pw;
|
||
}
|
||
_py(p) {
|
||
const ph = this._pvH - this._MT - this._MB;
|
||
return this._MT + (1 - (p - this._Pmin) / (this._Pmax - this._Pmin)) * ph;
|
||
}
|
||
|
||
_drawPv() {
|
||
const ctx = this._pvCtx;
|
||
const W = this._pvW, H = this._pvH;
|
||
if (!W || !H) return;
|
||
|
||
ctx.fillStyle = '#0D0D1A';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
if (!this._nodes) return;
|
||
|
||
this._pvDrawGrid(ctx, W, H);
|
||
this._pvDrawCycle(ctx);
|
||
this._pvDrawCurrentPoint(ctx);
|
||
this._pvDrawPhaseLabel(ctx, W);
|
||
this._pvDrawHoverTooltip(ctx, W, H);
|
||
}
|
||
|
||
_pvDrawGrid(ctx, W, H) {
|
||
const { _ML: ML, _MT: MT, _MB: MB, _MR: MR } = this;
|
||
const pw = W - ML - MR, ph = H - MT - MB;
|
||
|
||
ctx.fillStyle = 'rgba(255,255,255,0.018)';
|
||
ctx.fillRect(ML, MT, pw, ph);
|
||
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||
ctx.lineWidth = 1; ctx.setLineDash([]);
|
||
const vSteps = 6, pSteps = 5;
|
||
for (let i = 0; i <= vSteps; i++) {
|
||
const v = this._Vmin + (this._Vmax - this._Vmin) * i / vSteps;
|
||
const x = this._vx(v);
|
||
ctx.beginPath(); ctx.moveTo(x, MT); ctx.lineTo(x, MT + ph); ctx.stroke();
|
||
}
|
||
for (let i = 0; i <= pSteps; i++) {
|
||
const p = this._Pmin + (this._Pmax - this._Pmin) * i / pSteps;
|
||
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();
|
||
|
||
/* axis labels */
|
||
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();
|
||
|
||
/* tick labels */
|
||
ctx.font = '9px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = 'rgba(255,255,255,0.38)';
|
||
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
||
for (let i = 0; i <= pSteps; i++) {
|
||
const p = this._Pmin + (this._Pmax - this._Pmin) * i / pSteps;
|
||
const y = this._py(p);
|
||
if (y < MT + 2 || y > MT + ph - 2) continue;
|
||
ctx.fillText(this._fmtSci(p), ML - 5, y);
|
||
}
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||
for (let i = 0; i <= vSteps; i++) {
|
||
const v = this._Vmin + (this._Vmax - this._Vmin) * i / vSteps;
|
||
const x = this._vx(v);
|
||
ctx.fillText(this._fmtSci(v), x, MT + ph + 5);
|
||
}
|
||
}
|
||
|
||
_pvDrawCycle(ctx) {
|
||
const ns = this._nodes;
|
||
const N = ns.length;
|
||
const segColors = {
|
||
isotherm_hot: '#EF476F',
|
||
adiabat_exp: '#FFD166',
|
||
isotherm_cold: '#06D6E0',
|
||
adiabat_comp: '#7BF5A4',
|
||
isochoric_hot: '#F15BB5',
|
||
isochoric_cold: '#4CC9F0',
|
||
isobar_hot: '#F15BB5',
|
||
isobar_cold: '#4CC9F0',
|
||
};
|
||
|
||
/* filled area — net work */
|
||
ctx.beginPath();
|
||
for (let i = 0; i < N; i++) {
|
||
const a = ns[i], b = ns[(i + 1) % N];
|
||
this._pvSegPath(ctx, a, b, i === 0);
|
||
}
|
||
ctx.closePath();
|
||
ctx.fillStyle = 'rgba(155,93,229,0.12)';
|
||
ctx.fill();
|
||
|
||
/* W label inside */
|
||
const cV = ns.reduce((s, nd) => s + nd.V, 0) / N;
|
||
const cP = ns.reduce((s, nd) => s + nd.P, 0) / N;
|
||
const cx = this._vx(cV), cy = this._py(cP);
|
||
ctx.fillStyle = 'rgba(155,93,229,0.75)';
|
||
ctx.font = 'bold 10px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText('Wцикл', cx, cy - 6);
|
||
|
||
/* segments */
|
||
for (let i = 0; i < N; i++) {
|
||
const a = ns[i], b = ns[(i + 1) % N];
|
||
const col = segColors[a.phase] || '#9B5DE5';
|
||
const hovered = this._hoverSeg === i;
|
||
ctx.save();
|
||
ctx.strokeStyle = col;
|
||
ctx.lineWidth = hovered ? 3.5 : 2.2;
|
||
ctx.globalAlpha = hovered ? 1 : 0.82;
|
||
ctx.setLineDash([]);
|
||
ctx.beginPath();
|
||
this._pvSegPath(ctx, a, b, true);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
/* arrow at midpoint */
|
||
this._pvArrow(ctx, a, b, col);
|
||
}
|
||
|
||
/* node circles & labels */
|
||
for (let i = 0; i < N; i++) {
|
||
const nd = ns[i];
|
||
const x = this._vx(nd.V), y = this._py(nd.P);
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fill();
|
||
ctx.strokeStyle = segColors[nd.phase] || '#9B5DE5';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
/* offset label away from center */
|
||
const dv = nd.V - (this._Vmin + this._Vmax) / 2;
|
||
const dp = nd.P - (this._Pmin + this._Pmax) / 2;
|
||
const ox = dv > 0 ? 13 : -13;
|
||
const oy = dp > 0 ? -12 : 12;
|
||
ctx.fillText(nd.label, x + ox, y + oy);
|
||
}
|
||
}
|
||
|
||
_pvSegPath(ctx, a, b, isFirst) {
|
||
const STEPS = 80;
|
||
const phase = a.phase;
|
||
for (let i = 0; i <= STEPS; i++) {
|
||
const u = i / STEPS;
|
||
let V, P;
|
||
if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
|
||
V = a.V + u * (b.V - a.V);
|
||
P = a.P * a.V / V;
|
||
} else if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
|
||
V = a.V * Math.pow(b.V / a.V, u);
|
||
P = a.P * Math.pow(a.V / V, this.gamma);
|
||
} else if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
|
||
V = a.V;
|
||
P = a.P + u * (b.P - a.P);
|
||
} else {
|
||
V = a.V + u * (b.V - a.V);
|
||
P = a.P;
|
||
}
|
||
const x = this._vx(V), y = this._py(P);
|
||
if (i === 0 && isFirst) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
}
|
||
|
||
_pvArrow(ctx, a, b, col) {
|
||
/* midpoint at u=0.55 */
|
||
const phase = a.phase;
|
||
const u0 = 0.52, u1 = 0.56;
|
||
const [x0, y0] = this._pvPoint(a, b, phase, u0);
|
||
const [x1, y1] = this._pvPoint(a, b, phase, u1);
|
||
const ang = Math.atan2(y1 - y0, x1 - x0);
|
||
const sz = 7;
|
||
ctx.save();
|
||
ctx.fillStyle = col;
|
||
ctx.globalAlpha = 0.85;
|
||
ctx.beginPath();
|
||
ctx.translate(x1, y1);
|
||
ctx.rotate(ang);
|
||
ctx.moveTo(sz, 0);
|
||
ctx.lineTo(-sz * 0.6, -sz * 0.4);
|
||
ctx.lineTo(-sz * 0.6, sz * 0.4);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
_pvPoint(a, b, phase, u) {
|
||
let V, P;
|
||
if (phase === 'isotherm_hot' || phase === 'isotherm_cold') {
|
||
V = a.V + u * (b.V - a.V); P = a.P * a.V / V;
|
||
} else if (phase === 'adiabat_exp' || phase === 'adiabat_comp') {
|
||
V = a.V * Math.pow(b.V / a.V, u); P = a.P * Math.pow(a.V / V, this.gamma);
|
||
} else if (phase === 'isochoric_hot' || phase === 'isochoric_cold') {
|
||
V = a.V; P = a.P + u * (b.P - a.P);
|
||
} else {
|
||
V = a.V + u * (b.V - a.V); P = a.P;
|
||
}
|
||
return [this._vx(V), this._py(P)];
|
||
}
|
||
|
||
_pvDrawCurrentPoint(ctx) {
|
||
if (!this._running && this._t === 0) return;
|
||
const st = this._stateAt(this._t);
|
||
if (!st) return;
|
||
const x = this._vx(st.V), y = this._py(st.P);
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 7, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#fff';
|
||
ctx.globalAlpha = 0.9;
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#9B5DE5';
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
_pvDrawPhaseLabel(ctx, W) {
|
||
const st = this._stateAt(this._t);
|
||
if (!st) return;
|
||
const names = {
|
||
isotherm_hot: 'Изотермическое расширение (Tгор)',
|
||
adiabat_exp: 'Адиабатическое расширение',
|
||
isotherm_cold: 'Изотермическое сжатие (Tхол)',
|
||
adiabat_comp: 'Адиабатическое сжатие',
|
||
isochoric_hot: 'Изохорное нагревание',
|
||
isochoric_cold: 'Изохорное охлаждение',
|
||
isobar_hot: 'Изобарное нагревание',
|
||
isobar_cold: 'Изобарное охлаждение',
|
||
};
|
||
const label = names[st.phase] || '';
|
||
if (!label) return;
|
||
ctx.save();
|
||
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
||
ctx.fillText(label, W / 2, this._MT - 18);
|
||
ctx.restore();
|
||
}
|
||
|
||
_pvDrawHoverTooltip(ctx, W, H) {
|
||
if (this._hoverSeg < 0 || !this._tooltipX) return;
|
||
const ns = this._nodes;
|
||
const i = this._hoverSeg;
|
||
const a = ns[i];
|
||
const phase = a.phase;
|
||
|
||
/* formulas per phase */
|
||
const formulas = {
|
||
isotherm_hot: 'W = nRTгор · ln(V₂/V₁)',
|
||
isotherm_cold: 'W = nRTхол · ln(V₂/V₁)',
|
||
adiabat_exp: 'W = (P₁V₁ - P₂V₂) / (γ - 1)',
|
||
adiabat_comp: 'W = (P₁V₁ - P₂V₂) / (γ - 1)',
|
||
isochoric_hot: 'W = 0, Q = νCᵥΔT',
|
||
isochoric_cold: 'W = 0, Q = νCᵥΔT',
|
||
isobar_hot: 'W = PΔV, Q = νCₚΔT',
|
||
isobar_cold: 'W = PΔV, Q = νCₚΔT',
|
||
};
|
||
const txt = formulas[phase] || '';
|
||
if (!txt) return;
|
||
|
||
const tx = Math.min(this._tooltipX, W - 180);
|
||
const ty = Math.max(this._MT + 4, this._tooltipY - 36);
|
||
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(13,13,26,0.92)';
|
||
ctx.strokeStyle = 'rgba(155,93,229,0.5)';
|
||
ctx.lineWidth = 1;
|
||
const tw = ctx.measureText(txt).width + 20;
|
||
const th = 24;
|
||
ctx.beginPath();
|
||
ctx.roundRect(tx, ty, tw, th, 6);
|
||
ctx.fill(); ctx.stroke();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.82)';
|
||
ctx.font = '10px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||
ctx.fillText(txt, tx + 10, ty + th / 2);
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ── hover detection ─────────────────────────────── */
|
||
|
||
_onPvMove(e) {
|
||
const rect = this._pv.getBoundingClientRect();
|
||
const mx = e.clientX - rect.left;
|
||
const my = e.clientY - rect.top;
|
||
this._tooltipX = mx;
|
||
this._tooltipY = my;
|
||
const ns = this._nodes;
|
||
if (!ns) return;
|
||
let found = -1;
|
||
for (let i = 0; i < ns.length; i++) {
|
||
const a = ns[i], b = ns[(i + 1) % ns.length];
|
||
if (this._nearSeg(mx, my, a, b)) { found = i; break; }
|
||
}
|
||
if (found !== this._hoverSeg) {
|
||
this._hoverSeg = found;
|
||
this._drawPv();
|
||
}
|
||
}
|
||
|
||
_nearSeg(mx, my, a, b) {
|
||
const STEPS = 40, THRESH = 10;
|
||
const phase = a.phase;
|
||
for (let i = 0; i <= STEPS; i++) {
|
||
const [x, y] = this._pvPoint(a, b, phase, i / STEPS);
|
||
if (Math.hypot(x - mx, y - my) < THRESH) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* ── piston animation ───────────────────────────── */
|
||
|
||
_initParticles(N) {
|
||
this._particles = [];
|
||
for (let i = 0; i < N; i++) {
|
||
this._particles.push({
|
||
x: Math.random(), y: Math.random(),
|
||
vx: (Math.random() - 0.5), vy: (Math.random() - 0.5),
|
||
});
|
||
}
|
||
}
|
||
|
||
_updateParticles() {
|
||
const st = this._stateAt(this._t);
|
||
const speedScale = st ? Math.sqrt(st.T / 300) * 0.012 : 0.008;
|
||
for (const p of this._particles) {
|
||
p.x += p.vx * speedScale;
|
||
p.y += p.vy * speedScale;
|
||
if (p.x < 0) { p.x = 0; p.vx = Math.abs(p.vx); }
|
||
if (p.x > 1) { p.x = 1; p.vx = -Math.abs(p.vx); }
|
||
if (p.y < 0) { p.y = 0; p.vy = Math.abs(p.vy); }
|
||
if (p.y > 1) { p.y = 1; p.vy = -Math.abs(p.vy); }
|
||
}
|
||
}
|
||
|
||
_drawPiston() {
|
||
const ctx = this._pisCtx;
|
||
const W = this._pis.offsetWidth || 300;
|
||
const H = this._pis.offsetHeight || 300;
|
||
if (!W || !H) return;
|
||
|
||
ctx.fillStyle = '#0D0D1A';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
const ns = this._nodes;
|
||
if (!ns) return;
|
||
|
||
const st = this._stateAt(this._t) || { V: ns[0].V, T: ns[0].T, phase: ns[0].phase };
|
||
|
||
/* cylinder geometry */
|
||
const cylX = W * 0.2, cylW = W * 0.6;
|
||
const cylTop = H * 0.12, cylBot = H * 0.92;
|
||
const cylH = cylBot - cylTop;
|
||
|
||
/* piston position from volume */
|
||
const Vmin = Math.min(...ns.map(n => n.V));
|
||
const Vmax = Math.max(...ns.map(n => n.V));
|
||
const Vfrac = (st.V - Vmin) / (Vmax - Vmin || 1);
|
||
const pistonY = cylTop + cylH * (1 - Vfrac); // bottom = max V, top = min V
|
||
|
||
/* heat source color indicator */
|
||
const phase = st.phase;
|
||
const isHot = phase === 'isotherm_hot' || phase === 'isochoric_hot' || phase === 'isobar_hot';
|
||
const isCold = phase === 'isotherm_cold' || phase === 'isochoric_cold' || phase === 'isobar_cold';
|
||
const isAdia = phase === 'adiabat_exp' || phase === 'adiabat_comp';
|
||
|
||
/* reservoir strip on left */
|
||
const resW = W * 0.12;
|
||
const resX = W * 0.05;
|
||
if (isHot) {
|
||
ctx.fillStyle = 'rgba(239,71,111,0.25)';
|
||
ctx.beginPath();
|
||
ctx.roundRect(resX, cylTop, resW, cylH, 5);
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#EF476F';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
ctx.fillStyle = '#EF476F';
|
||
ctx.font = 'bold 9px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Tгор', resX + resW / 2, cylTop + 14);
|
||
/* heat flow arrows */
|
||
this._drawHeatArrows(ctx, resX + resW, pistonY, cylX, '#EF476F', true);
|
||
} else if (isCold) {
|
||
ctx.fillStyle = 'rgba(6,214,224,0.18)';
|
||
ctx.beginPath();
|
||
ctx.roundRect(resX, cylTop, resW, cylH, 5);
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#06D6E0';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
ctx.fillStyle = '#06D6E0';
|
||
ctx.font = 'bold 9px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Tхол', resX + resW / 2, cylTop + 14);
|
||
this._drawHeatArrows(ctx, cylX, pistonY, resX + resW, '#06D6E0', false);
|
||
} else if (isAdia) {
|
||
/* insulation hatching */
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(255,200,0,0.35)';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([4, 4]);
|
||
for (let y = cylTop; y < cylBot; y += 8) {
|
||
ctx.beginPath(); ctx.moveTo(resX, y); ctx.lineTo(resX + resW, y); ctx.stroke();
|
||
}
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
ctx.strokeStyle = 'rgba(255,200,0,0.4)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
ctx.roundRect(resX, cylTop, resW, cylH, 5);
|
||
ctx.stroke();
|
||
ctx.fillStyle = 'rgba(255,200,0,0.7)';
|
||
ctx.font = 'bold 8px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Q=0', resX + resW / 2, cylTop + 14);
|
||
}
|
||
|
||
/* cylinder walls */
|
||
ctx.strokeStyle = 'rgba(200,200,220,0.5)';
|
||
ctx.lineWidth = 2.5;
|
||
ctx.setLineDash([]);
|
||
/* left wall */
|
||
ctx.beginPath(); ctx.moveTo(cylX, cylTop); ctx.lineTo(cylX, cylBot); ctx.stroke();
|
||
/* right wall */
|
||
ctx.beginPath(); ctx.moveTo(cylX + cylW, cylTop); ctx.lineTo(cylX + cylW, cylBot); ctx.stroke();
|
||
/* bottom */
|
||
ctx.beginPath(); ctx.moveTo(cylX, cylBot); ctx.lineTo(cylX + cylW, cylBot); ctx.stroke();
|
||
|
||
/* gas region */
|
||
const gasGrad = ctx.createLinearGradient(cylX, pistonY, cylX, cylBot);
|
||
const hotness = Math.min(1, (st.T - this.Tc) / Math.max(1, this.Th - this.Tc));
|
||
const r = Math.round(13 + hotness * 220);
|
||
const g = Math.round(13 + (1 - hotness) * 100);
|
||
const b = Math.round(26 + (1 - hotness) * 180);
|
||
gasGrad.addColorStop(0, `rgba(${r},${g},${b},0.12)`);
|
||
gasGrad.addColorStop(1, `rgba(${r},${g},${b},0.28)`);
|
||
ctx.fillStyle = gasGrad;
|
||
ctx.fillRect(cylX, pistonY, cylW, cylBot - pistonY);
|
||
|
||
/* particles */
|
||
const gasH = cylBot - pistonY;
|
||
for (const p of this._particles) {
|
||
const px = cylX + p.x * cylW;
|
||
const py = pistonY + p.y * gasH;
|
||
ctx.beginPath();
|
||
ctx.arc(px, py, 2.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||
ctx.globalAlpha = 0.7;
|
||
ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
/* piston */
|
||
const pisH = Math.max(12, H * 0.05);
|
||
ctx.fillStyle = 'rgba(100,120,180,0.85)';
|
||
ctx.strokeStyle = 'rgba(160,180,255,0.7)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
ctx.roundRect(cylX + 2, pistonY - pisH, cylW - 4, pisH, 4);
|
||
ctx.fill(); ctx.stroke();
|
||
|
||
/* piston rod */
|
||
const rodW = cylW * 0.1;
|
||
ctx.fillStyle = 'rgba(130,140,200,0.7)';
|
||
ctx.fillRect(cylX + cylW / 2 - rodW / 2, cylTop, rodW, pistonY - pisH - cylTop);
|
||
|
||
/* labels */
|
||
ctx.fillStyle = 'rgba(255,255,255,0.65)';
|
||
ctx.font = '10px Manrope, system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('T = ' + Math.round(st.T) + ' K', W * 0.75, cylTop + 16);
|
||
ctx.fillText('V = ' + this._fmtSci(st.V) + ' m³', W * 0.75, cylTop + 30);
|
||
|
||
/* adiabatic shimmer on insulation lines */
|
||
const isAdia2 = phase === 'adiabat_exp' || phase === 'adiabat_comp';
|
||
if (isAdia2 && window.LabFX) {
|
||
const pulse = LabFX.glow.pulse(performance.now() / 1000, 0.6);
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.1 + pulse * 0.25;
|
||
ctx.fillStyle = 'rgba(255,200,0,0.6)';
|
||
ctx.fillRect(resX, cylTop, resW, cylH);
|
||
ctx.restore();
|
||
}
|
||
|
||
if (window.LabFX) LabFX.particles.draw(ctx);
|
||
}
|
||
|
||
_drawHeatArrows(ctx, x0, y0, x1, col, rightward) {
|
||
const N = 3;
|
||
ctx.save();
|
||
ctx.strokeStyle = col;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.globalAlpha = 0.7;
|
||
for (let i = 0; i < N; i++) {
|
||
const yo = y0 - 15 + i * 15;
|
||
const dx = rightward ? 8 : -8;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x0, yo);
|
||
ctx.lineTo(x1, yo);
|
||
/* arrowhead */
|
||
const ax = x1, ay = yo;
|
||
ctx.moveTo(ax, ay);
|
||
ctx.lineTo(ax - dx, ay - 4);
|
||
ctx.moveTo(ax, ay);
|
||
ctx.lineTo(ax - dx, ay + 4);
|
||
ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ── util ────────────────────────────────────────── */
|
||
|
||
_fmtSci(v) {
|
||
if (Math.abs(v) >= 1e4 || (Math.abs(v) < 0.01 && v !== 0)) {
|
||
return v.toExponential(1);
|
||
}
|
||
if (Math.abs(v) >= 1000) return Math.round(v).toString();
|
||
if (Math.abs(v) >= 10) return v.toFixed(1);
|
||
return v.toFixed(3);
|
||
}
|
||
}
|
||
|
||
/* ─── lab UI init ─────────────────────────────────── */
|
||
var heSim = null;
|
||
|
||
function _openHeatEngine() {
|
||
document.getElementById('sim-topbar-title').textContent = 'Тепловые двигатели';
|
||
_simShow('sim-heatengine');
|
||
|
||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||
if (!heSim) {
|
||
heSim = new HeatEngineSim(
|
||
document.getElementById('he-pv-canvas'),
|
||
document.getElementById('he-piston-canvas')
|
||
);
|
||
heSim.onStats = _heUpdateUI;
|
||
}
|
||
heSim.fit();
|
||
heSim._recompute();
|
||
}));
|
||
}
|
||
|
||
function heSetCycle(type, el) {
|
||
document.querySelectorAll('.he-cycle-btn').forEach(b => b.classList.remove('active'));
|
||
if (el) el.classList.add('active');
|
||
/* show/hide CR slider */
|
||
const crRow = document.getElementById('he-cr-row');
|
||
if (crRow) crRow.style.display = (type === 'carnot') ? 'none' : '';
|
||
if (heSim) heSim.setCycle(type);
|
||
}
|
||
|
||
function heParam(name, val) {
|
||
const v = parseFloat(val);
|
||
if (name === 'Th') {
|
||
document.getElementById('he-th-val').textContent = Math.round(v);
|
||
if (heSim) heSim.setTh(v);
|
||
} else if (name === 'Tc') {
|
||
document.getElementById('he-tc-val').textContent = Math.round(v);
|
||
if (heSim) heSim.setTc(v);
|
||
} else if (name === 'cr') {
|
||
document.getElementById('he-cr-val').textContent = Math.round(v);
|
||
if (heSim) heSim.setCR(v);
|
||
}
|
||
}
|
||
|
||
function heStart() { if (heSim) heSim.start(); }
|
||
function hePause() { if (heSim) heSim.pause(); }
|
||
function heStep() { if (heSim) heSim.step(); }
|
||
function heReset() { if (heSim) heSim.reset(); }
|
||
|
||
function _heUpdateUI(s) {
|
||
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||
v('hebar-th', s.Th + ' K');
|
||
v('hebar-tc', s.Tc + ' K');
|
||
v('hebar-eta', s.eta + '%');
|
||
v('hebar-qh', s.Qh + ' Дж');
|
||
v('hebar-qc', s.Qc + ' Дж');
|
||
v('hebar-w', s.W + ' Дж');
|
||
}
|