'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 + ' Дж'); }