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

1004 lines
33 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';
/* ══════════════════════════════════════════════════════════════
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 + ' Дж');
}