'use strict'; /* ══════════════════════════════════════════════════════════════ PendulumSim — simple pendulum simulation θ'' = -(g/L)sin(θ) − γ·θ' RK4 integration · energy bar · trail · phase portrait ══════════════════════════════════════════════════════════════ */ class PendulumSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; /* physics */ this.L = 200; // px length this.g = 9.81; this.theta = Math.PI / 4; // angle (rad) this.omega = 0; // angular velocity this.damping = 0; // damping coefficient γ /* animation */ this.playing = false; this._raf = null; this._lastTs = null; this.speed = 1; /* trail */ this._trail = []; // [{x, y, age}] this._maxTrail = 200; /* energy chart (bottom) */ this._eHistory = []; // [{t, ke, pe}] this._tSim = 0; this.onUpdate = null; this._drag = 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; } getParams() { return { L: this.L, g: this.g, theta: +(this.theta * 180 / Math.PI).toFixed(3), damping: this.damping }; } setParams({ L, g, theta, damping } = {}) { if (L !== undefined) this.L = +L; if (g !== undefined) this.g = +g; if (theta !== undefined) { this.theta = +theta * Math.PI / 180; this.omega = 0; this._clearTrail(); } if (damping !== undefined) this.damping = +damping; this.draw(); this._emit(); } play() { if (this.playing) return; this.playing = true; this._lastTs = null; this._tick(); } pause() { this.playing = false; if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } reset() { this.pause(); this.theta = Math.PI / 4; this.omega = 0; this._tSim = 0; this._clearTrail(); this._eHistory = []; this.draw(); this._emit(); } start() { this.play(); } stop() { this.pause(); } info() { const T = 2 * Math.PI * Math.sqrt(this.L / (this.g * 100)); // L in px approx const KE = 0.5 * this.omega * this.omega * this.L * this.L; const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta)); const total = KE + PE; return { angle: (this.theta * 180 / Math.PI).toFixed(1) + '°', omega: this.omega.toFixed(3) + ' рад/с', period: T.toFixed(2) + ' с', energy: total > 0 ? Math.round(KE / total * 100) + '% KE' : '—', }; } /* ── internals ─────────────────────────────── */ _emit() { if (this.onUpdate) this.onUpdate(this.info()); } _clearTrail() { this._trail = []; } _tick() { if (!this.playing) return; this._raf = requestAnimationFrame(ts => { if (this._lastTs === null) this._lastTs = ts; const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05); this._lastTs = ts; const dt = rawDt * this.speed; this._step(dt); this._tSim += dt; // trail const { bx, by } = this._bobPos(); this._trail.push({ x: bx, y: by }); if (this._trail.length > this._maxTrail) this._trail.shift(); // energy history const KE = 0.5 * this.omega * this.omega * this.L * this.L; const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta)); this._eHistory.push({ t: this._tSim, ke: KE, pe: PE }); if (this._eHistory.length > 300) this._eHistory.shift(); this.draw(); this._emit(); this._tick(); }); } /* RK4 step for θ'' = -(g/L)sinθ - γ·ω */ _step(dt) { const gL = this.g * 100 / this.L; // scale g for px units const c = this.damping; const deriv = (th, om) => ({ dth: om, dom: -gL * Math.sin(th) - c * om, }); const k1 = deriv(this.theta, this.omega); const k2 = deriv(this.theta + k1.dth * dt / 2, this.omega + k1.dom * dt / 2); const k3 = deriv(this.theta + k2.dth * dt / 2, this.omega + k2.dom * dt / 2); const k4 = deriv(this.theta + k3.dth * dt, this.omega + k3.dom * dt); this.theta += dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth); this.omega += dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom); } _bobPos() { const cx = this.W / 2; const cy = Math.min(this.H * 0.18, 80); return { px: cx, py: cy, bx: cx + this.L * Math.sin(this.theta), by: cy + this.L * Math.cos(this.theta), }; } /* ── draw ──────────────────────────────────── */ draw() { const ctx = this.ctx, W = this.W, H = this.H; if (!W || !H) return; ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); const { px, py, bx, by } = this._bobPos(); // trail this._drawTrail(ctx); // support ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.fillRect(W / 2 - 30, py - 4, 60, 4); // string ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(bx, by); ctx.stroke(); // pivot ctx.fillStyle = '#666'; ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill(); // bob const bobR = 18; ctx.fillStyle = '#9B5DE5'; ctx.beginPath(); ctx.arc(bx, by, bobR, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke(); // glow const grad = ctx.createRadialGradient(bx, by, 0, bx, by, bobR * 2); grad.addColorStop(0, 'rgba(155,93,229,0.25)'); grad.addColorStop(1, 'rgba(155,93,229,0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(bx, by, bobR * 2, 0, Math.PI * 2); ctx.fill(); // angle arc if (Math.abs(this.theta) > 0.02) { ctx.strokeStyle = 'rgba(6,214,224,0.5)'; ctx.lineWidth = 1.5; const arcR = 40; const startAngle = Math.PI / 2; const endAngle = Math.PI / 2 + this.theta; ctx.beginPath(); ctx.arc(px, py, arcR, Math.min(startAngle, endAngle), Math.max(startAngle, endAngle)); ctx.stroke(); ctx.fillStyle = '#06D6E0'; ctx.font = '12px Manrope, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const labelAngle = startAngle + this.theta / 2; ctx.fillText( (this.theta * 180 / Math.PI).toFixed(1) + '°', px + (arcR + 16) * Math.cos(labelAngle), py + (arcR + 16) * Math.sin(labelAngle) ); } // energy bar this._drawEnergyBar(ctx, W, H); // energy chart this._drawEnergyChart(ctx, W, H); } _drawTrail(ctx) { const n = this._trail.length; if (n < 2) return; for (let i = 1; i < n; i++) { const a = i / n * 0.6; ctx.strokeStyle = `rgba(155,93,229,${a})`; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(this._trail[i - 1].x, this._trail[i - 1].y); ctx.lineTo(this._trail[i].x, this._trail[i].y); ctx.stroke(); } } _drawEnergyBar(ctx, W, H) { const KE = 0.5 * this.omega * this.omega * this.L * this.L; const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta)); const total = KE + PE || 1; const bw = 160, bh = 14; const x = W - bw - 20, y = 20; ctx.fillStyle = 'rgba(22,22,38,0.85)'; ctx.beginPath(); ctx.roundRect(x - 8, y - 6, bw + 16, bh + 32, 8); ctx.fill(); // KE bar const kw = (KE / total) * bw; ctx.fillStyle = '#EF476F'; ctx.beginPath(); ctx.roundRect(x, y, Math.max(2, kw), bh, 4); ctx.fill(); // PE bar ctx.fillStyle = '#06D6E0'; ctx.beginPath(); ctx.roundRect(x + kw, y, Math.max(2, bw - kw), bh, 4); ctx.fill(); ctx.font = '10px Manrope, sans-serif'; ctx.textBaseline = 'top'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left'; ctx.fillText('KE ' + Math.round(KE / total * 100) + '%', x, y + bh + 4); ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right'; ctx.fillText('PE ' + Math.round(PE / total * 100) + '%', x + bw, y + bh + 4); } _drawEnergyChart(ctx, W, H) { const data = this._eHistory; if (data.length < 2) return; const cw = Math.min(300, W * 0.4); const ch = 80; const cx = W - cw - 20; const cy = H - ch - 20; ctx.fillStyle = 'rgba(22,22,38,0.7)'; ctx.beginPath(); ctx.roundRect(cx - 8, cy - 8, cw + 16, ch + 16, 8); ctx.fill(); let maxE = 0; for (const d of data) maxE = Math.max(maxE, d.ke + d.pe); if (maxE < 0.01) return; // PE filled area ctx.fillStyle = 'rgba(6,214,224,0.2)'; ctx.beginPath(); ctx.moveTo(cx, cy + ch); for (let i = 0; i < data.length; i++) { const x = cx + (i / (data.length - 1)) * cw; const y = cy + ch - (data[i].pe / maxE) * ch; ctx.lineTo(x, y); } ctx.lineTo(cx + cw, cy + ch); ctx.closePath(); ctx.fill(); // KE line ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 0; i < data.length; i++) { const x = cx + (i / (data.length - 1)) * cw; const y = cy + ch - (data[i].ke / maxE) * ch; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); // total line ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); for (let i = 0; i < data.length; i++) { const x = cx + (i / (data.length - 1)) * cw; const y = cy + ch - ((data[i].ke + data[i].pe) / maxE) * ch; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); ctx.setLineDash([]); // labels ctx.font = '10px Manrope, sans-serif'; ctx.textBaseline = 'bottom'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left'; ctx.fillText('KE', cx + 2, cy); ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.fillText('PE', cx + 30, cy); ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.textAlign = 'right'; ctx.fillText('Total', cx + cw, cy); } /* ── events ─────────────────────────────────── */ _bindEvents() { const cv = this.canvas; cv.addEventListener('mousedown', e => { const { bx, by } = this._bobPos(); const r = cv.getBoundingClientRect(); const mx = (e.clientX - r.left) * (this.W / r.width); const my = (e.clientY - r.top) * (this.H / r.height); if (Math.hypot(mx - bx, my - by) < 30) { this._drag = true; this.pause(); } }); window.addEventListener('mousemove', e => { if (!this._drag) return; const r = cv.getBoundingClientRect(); const mx = (e.clientX - r.left) * (this.W / r.width); const my = (e.clientY - r.top) * (this.H / r.height); const { px, py } = this._bobPos(); this.theta = Math.atan2(mx - px, my - py); this.omega = 0; this._clearTrail(); this.draw(); this._emit(); }); window.addEventListener('mouseup', () => { if (this._drag) { this._drag = false; this.play(); } }); // touch cv.addEventListener('touchstart', e => { if (e.touches.length !== 1) return; const { bx, by } = this._bobPos(); const r = cv.getBoundingClientRect(); const mx = (e.touches[0].clientX - r.left) * (this.W / r.width); const my = (e.touches[0].clientY - r.top) * (this.H / r.height); if (Math.hypot(mx - bx, my - by) < 40) { this._drag = true; this.pause(); } }, { passive: true }); cv.addEventListener('touchmove', e => { if (!this._drag) return; e.preventDefault(); const r = cv.getBoundingClientRect(); const mx = (e.touches[0].clientX - r.left) * (this.W / r.width); const my = (e.touches[0].clientY - r.top) * (this.H / r.height); const { px, py } = this._bobPos(); this.theta = Math.atan2(mx - px, my - py); this.omega = 0; this._clearTrail(); this.draw(); this._emit(); }, { passive: false }); cv.addEventListener('touchend', () => { if (this._drag) { this._drag = false; this.play(); } }); } }