Files
Learn_System/frontend/js/labs/pendulum.js
T
Maxim Dolgolyov ae31e4c4e8 refactor: distribute lab-init.js into 34 engine files
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only)

Each sim's _open*() + UI helpers moved to its engine file:
graph.js, projectile.js, collision.js, magnetic.js, triangle.js,
geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js,
reactions.js (chemistry), newton.js (dynamics), chemsandbox.js,
celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js,
normaldist.js, graphtransform.js, pendulum.js, equilibrium.js,
thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js,
probability.js, bohratom.js, electrolysis.js, waves.js,
crystal.js, orbitals.js, stereo.js, hydrostatics.js

All 34 engine files syntax-checked OK.
2026-05-08 14:54:54 +03:00

453 lines
15 KiB
JavaScript
Raw 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';
/* ══════════════════════════════════════════════════════════════
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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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(); }
});
}
}
/* ─── lab UI init ─────────────────────────────────── */
var pendSim = null;
function _openPendulum() {
document.getElementById('sim-topbar-title').textContent = 'Маятник';
_simShow('sim-pendulum');
_registerSimState('pendulum', () => pendSim?.getParams(), st => pendSim?.setParams(st));
if (_embedMode) _startStateEmit('pendulum');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!pendSim) {
pendSim = new PendulumSim(document.getElementById('pendulum-canvas'));
pendSim.onUpdate = _pendUpdateUI;
}
pendSim.fit();
pendSim.play();
}));
}
function pendParam(name, val) {
const v = parseFloat(val);
const ids = { theta: 'pend-theta-val', L: 'pend-L-val', g: 'pend-g-val', damping: 'pend-damp-val' };
const el = document.getElementById(ids[name]);
if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(name === 'g' ? 2 : 1);
if (pendSim) pendSim.setParams({ [name]: v });
}
function pendPreset(theta, L, g, damp) {
document.getElementById('sl-pend-theta').value = theta; document.getElementById('pend-theta-val').textContent = theta;
document.getElementById('sl-pend-L').value = L; document.getElementById('pend-L-val').textContent = L;
document.getElementById('sl-pend-g').value = g; document.getElementById('pend-g-val').textContent = g;
document.getElementById('sl-pend-damp').value = damp; document.getElementById('pend-damp-val').textContent = damp;
if (pendSim) {
pendSim.setParams({ theta, L, g, damping: damp });
pendSim.play();
}
}
function _pendUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('pendbar-v1', info.angle);
v('pendbar-v2', info.omega);
v('pendbar-v3', info.period);
v('pendbar-v4', info.energy);
}
/* ── equilibrium ── */