'use strict'; /* ══════════════════════════════════════════════════════════════ IsoprocessSim — PV-diagram for 4 ideal-gas isoprocesses n = 1, R = 0.0821 L·atm/mol·K; energies in Joules Isothermal PV = const ΔU=0, W=nRT·ln(V2/V1), Q=W Isochoric V = const W=0, ΔU=νCvΔT, Q=ΔU Isobaric P = const W=PΔV, ΔU=νCvΔT, Q=ΔU+W Adiabatic PV^γ = const Q=0, ΔU=-W, W=PΔV/(γ-1) ══════════════════════════════════════════════════════════════ */ class IsoprocessSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; /* physics */ this.n = 1; this.R = 0.0821; // L·atm / mol·K this.R_J = 8.314; // J / mol·K this.gamma = 1.4; // 7/5 diatomic default /* state */ this.P1 = 3.0; // atm this.V1 = 10.0; // L this._ratio = 0.5; // 0..1, maps end state position along process /* process */ this.process = 'isothermal'; /* axis range */ this.Vmin = 1; this.Vmax = 33; this.Pmin = 0.2; this.Pmax = 9.5; /* margins */ this.ML = 52; this.MB = 46; this.MT = 20; this.MR = 18; this._drag = null; // 'state1' | 'state2' this.onUpdate = null; this._bindEvents(); new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); } /* ── public API ─────────────────────────────── */ fit() { const dpr = window.devicePixelRatio || 1; const w = this.canvas.offsetWidth || 600; const h = this.canvas.offsetHeight || 400; this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = w; this.H = h; } setProcess(p) { this.process = p; this.draw(); this._emit(); } setGamma(g) { this.gamma = +g; this.draw(); this._emit(); } getParams() { return { P1: this.P1, V1: this.V1, process: this.process }; } setParams({ P1, V1 } = {}) { if (P1 !== undefined) this.P1 = Math.max(0.4, Math.min(8, +P1)); if (V1 !== undefined) this.V1 = Math.max(2, Math.min(28, +V1)); this.draw(); this._emit(); } setRatio(r) { this._ratio = Math.max(0.01, Math.min(0.99, +r)); this.draw(); this._emit(); } /* ── coordinate transforms ─────────────────── */ _pw() { return this.W - this.ML - this.MR; } _ph() { return this.H - this.MT - this.MB; } _vx(v) { return this.ML + (v - this.Vmin) / (this.Vmax - this.Vmin) * this._pw(); } _py(p) { return this.MT + (1 - (p - this.Pmin) / (this.Pmax - this.Pmin)) * this._ph(); } _xv(x) { return this.Vmin + (x - this.ML) / this._pw() * (this.Vmax - this.Vmin); } _yp(y) { return this.Pmin + (1 - (y - this.MT) / this._ph()) * (this.Pmax - this.Pmin); } /* ── physics ───────────────────────────────── */ _T(P, V) { return P * V / (this.n * this.R); } _state2() { const { P1, V1, _ratio, gamma } = this; /* ratio in [0..1] → multiplier in [0.2..3.5] for V2/V1 or P2/P1 */ const mult = 0.2 + _ratio * 3.3; const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v)); const clampP = p => Math.max(this.Pmin + 0.05, Math.min(this.Pmax - 0.1, p)); switch (this.process) { case 'isothermal': { const V2 = clampV(V1 * mult); return { P2: clampP(P1 * V1 / V2), V2 }; } case 'isochoric': { return { P2: clampP(P1 * mult), V2: V1 }; } case 'isobaric': { const V2 = clampV(V1 * mult); return { P2: P1, V2 }; } case 'adiabatic': { const V2 = clampV(V1 * mult); return { P2: clampP(P1 * Math.pow(V1 / V2, gamma)), V2 }; } } return { P2: P1, V2: V1 }; } info() { const { P1, V1, n, R_J, gamma } = this; const T1 = this._T(P1, V1); const { P2, V2 } = this._state2(); const T2 = this._T(P2, V2); /* internal energy: ΔU = νCvΔT, Cv = R/(γ-1) */ const Cv_J = R_J / (gamma - 1); const dU_J = n * Cv_J * (T2 - T1); /* P in Pa = P_atm * 101325, V in m³ = V_L * 0.001 */ const P1Pa = P1 * 101325, P2Pa = P2 * 101325; const V1m3 = V1 * 0.001, V2m3 = V2 * 0.001; let W_J = 0, Q_J = 0; switch (this.process) { case 'isothermal': W_J = n * R_J * T1 * Math.log(V2 / V1); Q_J = W_J; break; case 'isochoric': W_J = 0; Q_J = dU_J; break; case 'isobaric': W_J = P1Pa * (V2m3 - V1m3); Q_J = dU_J + W_J; break; case 'adiabatic': Q_J = 0; W_J = -dU_J; break; } const fmt = x => (x >= 0 ? '+' : '') + Math.round(x); return { P1: P1.toFixed(2), V1: V1.toFixed(1), T1: Math.round(T1), P2: P2.toFixed(2), V2: V2.toFixed(1), T2: Math.round(T2), W: fmt(W_J), Q: fmt(Q_J), dU: fmt(Math.round(dU_J)), W_raw: W_J, Q_raw: Q_J, dU_raw: dU_J, process: this.process, }; } _emit() { if (this.onUpdate) this.onUpdate(this.info()); } /* ── draw ──────────────────────────────────── */ draw() { const { ctx, W, H } = this; if (!W || !H) return; ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); this._drawGrid(ctx); this._drawBgCurves(ctx); this._drawActiveCurve(ctx); this._drawPoints(ctx); this._drawInfoBox(ctx); } _drawGrid(ctx) { const { ML, MT, MR, MB } = this; const pw = this._pw(), ph = this._ph(); /* plot background */ ctx.fillStyle = 'rgba(255,255,255,0.018)'; ctx.fillRect(ML, MT, pw, ph); /* grid */ ctx.strokeStyle = 'rgba(255,255,255,0.055)'; ctx.lineWidth = 1; ctx.setLineDash([]); for (let v = 5; v <= 30; v += 5) { const x = this._vx(v); ctx.beginPath(); ctx.moveTo(x, MT); ctx.lineTo(x, MT + ph); ctx.stroke(); } for (let p = 1; p <= 9; p++) { const y = this._py(p); ctx.beginPath(); ctx.moveTo(ML, y); ctx.lineTo(ML + pw, y); ctx.stroke(); } /* axes */ ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph); ctx.stroke(); /* tick labels */ ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (let p = 1; p <= 9; p++) { const y = this._py(p); if (y < MT + 2 || y > MT + ph - 2) continue; ctx.fillText(p, ML - 6, y); } ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (let v = 5; v <= 30; v += 5) { const x = this._vx(v); ctx.fillText(v, x, MT + ph + 5); } /* axis titles */ ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.font = '12px Manrope, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('V, л', ML + pw / 2, MT + ph + 32); ctx.save(); ctx.translate(13, MT + ph / 2); ctx.rotate(-Math.PI / 2); ctx.fillText('P, атм', 0, 0); ctx.restore(); } _COLORS = { isothermal: '#EF476F', isochoric: '#06D6E0', isobaric: '#7BF5A4', adiabatic: '#FFD166', }; /* draw one process curve through (P1,V1) */ _curve(ctx, process, alpha, lw, dashed) { const { P1, V1, gamma, Vmin, Vmax, Pmin, Pmax } = this; ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = this._COLORS[process]; ctx.lineWidth = lw; ctx.setLineDash(dashed ? [5, 4] : []); ctx.beginPath(); if (process === 'isochoric') { const x = this._vx(V1); ctx.moveTo(x, this._py(Pmax)); ctx.lineTo(x, this._py(Pmin)); } else { let started = false; const steps = 300; for (let i = 0; i <= steps; i++) { const v = Vmin + (Vmax - Vmin) * i / steps; let p; if (process === 'isothermal') p = P1 * V1 / v; else if (process === 'isobaric') p = P1; else p = P1 * Math.pow(V1 / v, gamma); // adiabatic if (p < Pmin || p > Pmax + 0.1) { started = false; continue; } const x = this._vx(v), y = this._py(Math.min(p, Pmax)); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } } ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } _drawBgCurves(ctx) { for (const p of ['isothermal', 'isochoric', 'isobaric', 'adiabatic']) { if (p !== this.process) this._curve(ctx, p, 0.14, 1.2, true); } /* legend dots */ const names = { isothermal: 'Изотерма', isochoric: 'Изохора', isobaric: 'Изобара', adiabatic: 'Адиабата' }; ctx.font = '10px Manrope, system-ui, sans-serif'; let lx = this.ML + this._pw() - 8, ly = this.MT + 8; ctx.textAlign = 'right'; ctx.textBaseline = 'top'; for (const [proc, label] of Object.entries(names)) { const col = this._COLORS[proc]; const isCur = proc === this.process; ctx.globalAlpha = isCur ? 0.85 : 0.3; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(lx + 5, ly + 4, 4, 0, Math.PI * 2); ctx.fill(); ctx.fillText(label, lx - 3, ly); ly += 16; } ctx.globalAlpha = 1; } _drawActiveCurve(ctx) { /* full curve dimmed */ this._curve(ctx, this.process, 0.3, 1.5, false); /* highlighted segment state1 → state2 */ const { P1, V1, gamma } = this; const { P2, V2 } = this._state2(); const color = this._COLORS[this.process]; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 2.8; ctx.setLineDash([]); const steps = 200; const [Vs, Ve] = V2 >= V1 ? [V1, V2] : [V2, V1]; if (this.process === 'isochoric') { const x = this._vx(V1); const y1c = this._py(P1), y2c = this._py(P2); ctx.beginPath(); ctx.moveTo(x, y1c); ctx.lineTo(x, y2c); ctx.stroke(); this._arrowHead(ctx, x, y1c, x, y2c, color); } else { ctx.beginPath(); let started = false; for (let i = 0; i <= steps; i++) { const v = Vs + (Ve - Vs) * i / steps; let p; if (this.process === 'isothermal') p = P1 * V1 / v; else if (this.process === 'isobaric') p = P1; else p = P1 * Math.pow(V1 / v, gamma); const x = this._vx(v), y = this._py(p); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } ctx.stroke(); /* arrow at ~80% of segment */ const vArr = Vs + (Ve - Vs) * 0.8; const vArr2 = Vs + (Ve - Vs) * 0.82; let p1a, p2a; if (this.process === 'isothermal') { p1a = P1*V1/vArr; p2a = P1*V1/vArr2; } else if (this.process === 'isobaric') { p1a = P1; p2a = P1; } else { p1a = P1*Math.pow(V1/vArr,gamma); p2a = P1*Math.pow(V1/vArr2,gamma); } /* ensure arrow points from 1→2 */ const dir = V2 > V1 ? 1 : -1; this._arrowHead(ctx, this._vx(vArr + dir*0), this._py(p1a + dir*0), this._vx(vArr2 + dir*0), this._py(p2a + dir*0), color); } ctx.restore(); } _arrowHead(ctx, x1, y1, x2, y2, color) { const angle = Math.atan2(y2 - y1, x2 - x1); const s = 10; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - s * Math.cos(angle - 0.4), y2 - s * Math.sin(angle - 0.4)); ctx.lineTo(x2 - s * Math.cos(angle + 0.4), y2 - s * Math.sin(angle + 0.4)); ctx.closePath(); ctx.fill(); } _drawPoints(ctx) { const { P2, V2 } = this._state2(); const color = this._COLORS[this.process]; const dot = (x, y, fill, label, textX, textY) => { ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.stroke(); ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.fillStyle = fill; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(label, textX, textY); }; const x1 = this._vx(this.V1), y1 = this._py(this.P1); const x2 = this._vx(V2), y2 = this._py(P2); dot(x1, y1, '#9B5DE5', '1', x1 - 12, y1 - 4); dot(x2, y2, color, '2', x2 + 12, y2 - 4); } _drawInfoBox(ctx) { const info = this.info(); const color = this._COLORS[info.process]; const names = { isothermal:'Изотермический', isochoric:'Изохорный', isobaric:'Изобарный', adiabatic:'Адиабатический' }; const formulas = { isothermal:'PV = const', isochoric:'V = const', isobaric:'P = const', adiabatic:'PV^γ = const' }; const bx = this.ML + 6, by = this.MT + 6; const boxW = 205, boxH = 98; ctx.fillStyle = 'rgba(13,13,26,0.9)'; ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(`${names[info.process]} ${formulas[info.process]}`, bx + 10, by + 8); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillText(`T₁ = ${info.T1} K → T₂ = ${info.T2} K`, bx + 10, by + 28); const wColor = info.W_raw > 0 ? '#7BF5A4' : info.W_raw < 0 ? '#EF476F' : 'rgba(255,255,255,0.4)'; const qColor = info.Q_raw > 0 ? '#FFD166' : info.Q_raw < 0 ? '#06D6E0' : 'rgba(255,255,255,0.4)'; ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('W =', bx + 10, by + 48); ctx.fillStyle = wColor; ctx.fillText(`${info.W} Дж`, bx + 38, by + 48); ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('Q =', bx + 10, by + 65); ctx.fillStyle = qColor; ctx.fillText(`${info.Q} Дж`, bx + 38, by + 65); ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('ΔU =', bx + 10, by + 82); ctx.fillStyle = 'rgba(255,255,255,0.65)'; ctx.fillText(`${info.dU} Дж`, bx + 40, by + 82); } /* ── events ─────────────────────────────────── */ _bindEvents() { const cv = this.canvas; const pos = e => { const r = cv.getBoundingClientRect(); const t = e.touches ? e.touches[0] : e; return { px: (t.clientX - r.left) * (this.W / r.width), py: (t.clientY - r.top) * (this.H / r.height), }; }; const hit = (px, py) => { const x1 = this._vx(this.V1), y1 = this._py(this.P1); if (Math.hypot(px - x1, py - y1) < 18) return 'state1'; const { P2, V2 } = this._state2(); const x2 = this._vx(V2), y2 = this._py(P2); if (Math.hypot(px - x2, py - y2) < 18) return 'state2'; return null; }; const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v)); const clampP = p => Math.max(this.Pmin + 0.1, Math.min(this.Pmax - 0.1, p)); const onDown = e => { const { px, py } = pos(e); this._drag = hit(px, py); }; const onMove = e => { if (!this._drag) return; if (e.cancelable) e.preventDefault(); const { px, py } = pos(e); const v = this._xv(px), p = this._yp(py); if (this._drag === 'state1') { this.V1 = clampV(v); this.P1 = clampP(p); } else { /* constrain state2 to current process curve */ switch (this.process) { case 'isothermal': case 'isobaric': case 'adiabatic': { const V2 = clampV(v); this._ratio = Math.max(0.01, Math.min(0.99, (V2 / this.V1 - 0.2) / 3.3)); break; } case 'isochoric': { const P2 = clampP(p); this._ratio = Math.max(0.01, Math.min(0.99, (P2 / this.P1 - 0.2) / 3.3)); break; } } } this.draw(); this._emit(); }; const onUp = () => { this._drag = null; }; cv.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true }); cv.addEventListener('touchmove', e => onMove(e), { passive: false }); cv.addEventListener('touchend', onUp); cv.addEventListener('mousemove', e => { if (this._drag) { cv.style.cursor = 'grabbing'; return; } const { px, py } = pos(e); cv.style.cursor = hit(px, py) ? 'grab' : 'default'; }); } }