feat(labs): wave 3 — 5 new sims + optics merger

Оптическая скамья (opticsbench) — merger thinlens + mirror + refraction
- 4 режима: «Свободная сборка» / «Линза» / «Зеркало» / «Преломление»
- Все 3 движка слиты в OpticsBenchSim (1583 строк)
- Backward compat: #thinlens / #mirrors / #refraction → #opticsbench
- Удалены: thinlens.js, mirror.js, refraction.js

Радиоактивный распад (radioactive) — новая сима
- Monte-Carlo распад: λ·dt вероятность на тик, частицы меняют цвет, эмитируются α/β/γ
- Real-time N(t) график с теоретической кривой N₀·exp(-λt)
- 7 изотопов: ¹⁴C, ¹³¹I, ¹³⁷Cs, ²²⁶Ra, ⁴⁰K, ²³⁸U-chain, ²³⁵U-chain
- Цепочки распадов (U-238: 14 шагов сокращены до 5 ключевых)
- Dating mode для C-14: t = ln(N₀/N)/λ
- HUD: периодов прошло, % распалось, активность в Бк

Тепловые двигатели (heatengine) — новая сима
- 4 цикла: Карно / Отто / Дизель / Брайтон
- PV-диаграмма с замкнутым циклом, заполненной площадью работы
- Аналитически точные изотермы (PV=nRT) и адиабаты (PV^γ=const)
- Анимированный поршень с резервуарами (красный T_h / синий T_c)
- Частицы газа, скорость ∝ √T
- Hover-tooltips с формулами для каждого сегмента

Логические схемы (logic) — новая сима для информатики
- Drag-drop конструктор: 12 типов компонентов (INPUT/CLOCK/OUTPUT/AND/OR/NOT/XOR/NAND/NOR/XNOR/BUF/wire)
- Топологическая сортировка для propagation, цветовая подсветка HIGH/LOW
- Авто-генерация булевого выражения (∧ ∨ ¬ ⊕)
- Авто-таблица истинности (до 2^6 = 64 строк)
- 6 пресетов: полусумматор, полный сумматор, RS-триггер, D-триггер, декодер 2-в-4, мультиплексор 2-в-1

Стехиометрия (stoichiometry) — новая сима
- 10 реакций: Zn+HCl, H₂+O₂, CH₄+O₂, N₂+H₂ (Габер), Al+CuSO₄, Mg+O₂, CaCO₃→, HCl+NaOH, KMnO₄→, C₂H₅OH+O₂
- Sliders с переключением m/n/V (для газов V=n·22.4 при н.у.)
- Анимация частиц при реакции, подсветка лимитирующего реагента
- Пошаговый расчёт m→n→n_product→m_product с KaTeX
- HUD: лимит, избытки, теоретический выход

Каталог: 33 → 35 сим (5 новых − 3 удалённых merger)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-23 13:25:16 +03:00
parent 8f30a8cef6
commit 8b3159b529
13 changed files with 5347 additions and 2232 deletions
+920
View File
@@ -0,0 +1,920 @@
'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._loop(); } }
pause() { this._running = false; cancelAnimationFrame(this._raf); }
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._drawPv();
this._drawPiston();
this._raf = requestAnimationFrame(() => this._loop());
}
/* ── 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);
}
_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 + ' Дж');
}