Files
Learn_System/frontend/js/labs/pendulum.js
T
Maxim Dolgolyov e46548d06b feat(labs): механика V2 — 4 ключевые симы школьной физики расширены
pendulum V2 (472 → 1651 строк):
- Математический (default, сохранён)
- Двойной маятник (Lagrangian RK4, ghost-копия для демо хаоса)
- Связанные маятники (биения, чарт θ₁/θ₂)
- Пружинный (вертикальный/горизонтальный, T=2π√(m/k))
- Физический (4 формы: стержень/обруч/диск/прямоугольник, с моментом инерции)
- Маятник Фуко (Кориолис, slider широты, период прецессии)
- Резонанс (внешняя F₀·cos(ω_d·t), резонансная кривая A(ω))
- Фазовый портрет (универсальный toggle для всех режимов)

collision V2 (~1000 → 2416 строк):
- 1D (default, сохранён)
- 2D под углом (импульс по осям, slider e, до/после стат)
- Multi-ball (N=2-10, стены с отскоками, перемешать)
- Бильярдный стол (6 луз, кий с прицелом, треугольник шаров, реалистичное трение)
- Реф.фрейм ЦМ (universal toggle)

newton V2 (1693 → 2585 строк):
- 4-й закон-таб «Классические задачи»
- Машина Атвуда (a=(m₂-m₁)g/(m₁+m₂), идеальный/массивный блок)
- Тело на наклонной плоскости (FBD, статика/скольжение, slider α/μ/F_app)
- Скатывание шара/цилиндра/обруча (момент инерции, гонка, наглядно почему обруч медленнее)

projectile V2 (1900 → 2400 строк):
- Парашют: F_d = ½C_d·ρ·A·v² с терминальной скоростью v_t = √(2mg/(C_d·ρ·A))
- C_d selector: парашют/куб/сфера/полусфера/диск; раскрытие парашюта на заданной высоте
- Горка-катапульта: v_0 = √(2gL(sinα-μcosα)) автомат
- 10 планет: Земля/Луна/Марс/Юпитер/Меркурий/Венера/Сатурн/Уран/Нептун/Плутон
  с реальными g + плотностью атмосферы (для drag)
- Сравнительный режим: 3 планеты одновременно с разными цветами

Все 4 симы — additive, существующая функциональность сохранена.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:14:42 +03:00

1651 lines
59 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 — 8-mode pendulum simulation
Modes:
math — simple mathematical pendulum (default)
double — double pendulum (chaotic, Lagrangian mechanics)
coupled — two coupled pendulums (energy transfer)
spring — spring pendulum (vertical / horizontal)
physical — physical pendulum (rod / hoop / disk / rect)
foucault — Foucault pendulum (latitude slider)
resonance— driven oscillation + resonance curve
Phase portrait overlay available for all modes.
══════════════════════════════════════════════════════════════ */
class PendulumSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* current mode */
this.mode = 'math';
/* ── MODE: math ──────────────────────────── */
this.L = 200;
this.g = 9.81;
this.theta = Math.PI / 4;
this.omega = 0;
this.damping = 0;
/* ── MODE: double ────────────────────────── */
this.d = {
L1: 130, L2: 100,
m1: 1.5, m2: 1.0,
th1: Math.PI * 0.6, om1: 0,
th2: Math.PI * 0.4, om2: 0,
trail: [], // [{x,y}]
maxTrail: 500,
// ghost for chaos comparison
showGhost: false,
gth1: 0, gom1: 0, gth2: 0, gom2: 0,
ghostTrail: [],
};
/* ── MODE: coupled ───────────────────────── */
this.cp = {
L: 160, g: 9.81,
k: 0.3, // spring coupling
th1: Math.PI / 5, om1: 0,
th2: 0, om2: 0,
hist1: [], hist2: [],
};
/* ── MODE: spring ────────────────────────── */
this.sp = {
mode: 'vert', // 'vert' | 'horiz'
k: 20, // N/m
m: 1, // kg
x: 0.08, // displacement (m)
v: 0,
hist: [],
restLen: 0.2, // natural length (m)
// driven resonance on spring
drive: false, dOmega: 0, dF: 0,
};
/* ── MODE: physical ──────────────────────── */
this.ph = {
shape: 'rod', // 'rod'|'hoop'|'disk'|'rect'
L: 200, // px (total length / radius)
theta: Math.PI / 5,
omega: 0,
g: 9.81,
damping: 0,
};
/* ── MODE: foucault ──────────────────────── */
this.fc = {
phi: Math.PI / 4, // latitude (rad)
L: 150, // pendulum length (px)
// 2D state in rotating frame: x, y, vx, vy
x: 60, y: 0, vx: 0, vy: 0,
trail: [],
maxTrail: 800,
tSim: 0,
// scaled Omega_z = Omega_earth * sin(phi) — for demo speed up
timeScale: 200, // how many Earth-hours pass per sim-second
};
/* ── MODE: resonance ─────────────────────── */
this.rs = {
L: 180,
g: 9.81,
gamma: 0.3, // damping
F0: 0.8, // driving amplitude (rad/s²)
dOmega: 1.5, // driving frequency
theta: 0.1,
omega: 0,
tSim: 0,
// resonance curve data (precomputed on param change)
curve: [], // [{w, A}]
curveDirty: true,
};
/* ── phase portrait ─── */
this.showPhase = false;
this._phaseTrail = []; // [{x,y}] = [{theta, omega}]
this._maxPhase = 1000;
/* animation */
this.playing = false;
this._raf = null;
this._lastTs = null;
this.speed = 1;
/* trail (math mode) */
this._trail = [];
this._maxTrail = 200;
/* energy history (math mode) */
this._eHistory = [];
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;
}
setMode(m) {
this.mode = m;
this.pause();
this._clearAll();
this.draw();
this._emit();
}
getParams() {
return { mode: this.mode, 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._clearAll();
// reset mode state to defaults
switch (this.mode) {
case 'math':
this.theta = Math.PI / 4; this.omega = 0; this._tSim = 0; this._eHistory = [];
break;
case 'double':
this.d.th1 = Math.PI * 0.6; this.d.om1 = 0;
this.d.th2 = Math.PI * 0.4; this.d.om2 = 0;
this.d.trail = []; this.d.ghostTrail = [];
if (this.d.showGhost) this._initDoubleGhost();
break;
case 'coupled':
this.cp.th1 = Math.PI / 5; this.cp.om1 = 0;
this.cp.th2 = 0; this.cp.om2 = 0;
this.cp.hist1 = []; this.cp.hist2 = [];
break;
case 'spring':
this.sp.x = 0.08; this.sp.v = 0; this.sp.hist = [];
break;
case 'physical':
this.ph.theta = Math.PI / 5; this.ph.omega = 0;
break;
case 'foucault':
this.fc.x = 60; this.fc.y = 0; this.fc.vx = 0; this.fc.vy = 0;
this.fc.trail = []; this.fc.tSim = 0;
break;
case 'resonance':
this.rs.theta = 0.1; this.rs.omega = 0; this.rs.tSim = 0;
break;
}
if (window.LabFX) LabFX.sound.play('click');
this.draw();
this._emit();
}
start() { this.play(); }
stop() { this.pause(); }
info() {
switch (this.mode) {
case 'math': {
const T = 2 * Math.PI * Math.sqrt(this.L / (this.g * 100));
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' : '—',
};
}
case 'double': {
const T = 2 * Math.PI * Math.sqrt(this.d.L1 / (9.81 * 100));
return {
angle: (this.d.th1 * 180 / Math.PI).toFixed(1) + '° / ' + (this.d.th2 * 180 / Math.PI).toFixed(1) + '°',
omega: this.d.om1.toFixed(2) + ' / ' + this.d.om2.toFixed(2),
period: T.toFixed(2) + ' с (звено)',
energy: 'хаос',
};
}
case 'coupled': {
const T = 2 * Math.PI * Math.sqrt(this.cp.L / (this.cp.g * 100));
return {
angle: 'θ1=' + (this.cp.th1 * 180 / Math.PI).toFixed(1) + '°',
omega: 'θ2=' + (this.cp.th2 * 180 / Math.PI).toFixed(1) + '°',
period: T.toFixed(2) + ' с',
energy: 'k=' + this.cp.k.toFixed(2),
};
}
case 'spring': {
const T = 2 * Math.PI * Math.sqrt(this.sp.m / this.sp.k);
const KE = 0.5 * this.sp.m * this.sp.v * this.sp.v;
const PE = 0.5 * this.sp.k * this.sp.x * this.sp.x;
const total = KE + PE || 1;
return {
angle: 'x=' + (this.sp.x * 100).toFixed(1) + ' см',
omega: 'v=' + this.sp.v.toFixed(2) + ' м/с',
period: T.toFixed(2) + ' с',
energy: Math.round(KE / total * 100) + '% KE',
};
}
case 'physical': {
const { I, d } = this._physInertia();
const T = 2 * Math.PI * Math.sqrt(I / (this.ph.g * 100 * d));
return {
angle: (this.ph.theta * 180 / Math.PI).toFixed(1) + '°',
omega: this.ph.omega.toFixed(3) + ' рад/с',
period: T.toFixed(2) + ' с',
energy: this.ph.shape,
};
}
case 'foucault': {
const phiDeg = (this.fc.phi * 180 / Math.PI).toFixed(0);
const sinPhi = Math.sin(this.fc.phi);
const Trot = sinPhi > 0.001 ? (24 / sinPhi).toFixed(1) + ' ч' : '∞';
return {
angle: 'φ=' + phiDeg + '°',
omega: Trot,
period: (2 * Math.PI * Math.sqrt(this.fc.L / (9.81 * 100))).toFixed(2) + ' с',
energy: 'вращение',
};
}
case 'resonance': {
const omega0 = Math.sqrt(this.rs.g * 100 / this.rs.L);
const T = 2 * Math.PI / omega0;
return {
angle: (this.rs.theta * 180 / Math.PI).toFixed(1) + '°',
omega: 'ω=' + this.rs.dOmega.toFixed(2) + ' рад/с',
period: T.toFixed(2) + ' с (собст)',
energy: 'ω₀=' + omega0.toFixed(2),
};
}
default:
return { angle: '—', omega: '—', period: '—', energy: '—' };
}
}
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_clearTrail() { this._trail = []; }
_clearPhase() { this._phaseTrail = []; }
_clearAll() { this._clearTrail(); this._clearPhase(); }
_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;
if (window.LabFX) LabFX.particles.update(rawDt);
this._stepMode(dt);
this.draw();
this._emit();
this._tick();
});
}
_stepMode(dt) {
switch (this.mode) {
case 'math': this._stepMath(dt); break;
case 'double': this._stepDouble(dt); break;
case 'coupled': this._stepCoupled(dt); break;
case 'spring': this._stepSpring(dt); break;
case 'physical': this._stepPhysical(dt); break;
case 'foucault': this._stepFoucault(dt); break;
case 'resonance': this._stepResonance(dt);break;
}
}
/* ─── MODE: math ─────────────────────────────── */
_stepMath(dt) {
const prevOmega = this.omega;
const gL = this.g * 100 / this.L;
const c = this.damping;
const deriv = (th, om) => ({ dth: om, dom: -gL * Math.sin(th) - c * om });
const rk4 = (th, om) => {
const k1 = deriv(th, om);
const k2 = deriv(th + k1.dth * dt / 2, om + k1.dom * dt / 2);
const k3 = deriv(th + k2.dth * dt / 2, om + k2.dom * dt / 2);
const k4 = deriv(th + k3.dth * dt, om + k3.dom * dt);
return {
th: th + dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth),
om: om + dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom),
};
};
const r = rk4(this.theta, this.omega);
this.theta = r.th; this.omega = r.om;
this._tSim += dt;
if (window.LabFX && prevOmega !== 0 && Math.sign(this.omega) !== Math.sign(prevOmega)) {
LabFX.sound.play('tick', { pitch: 1.2, volume: 0.2 });
}
const { bx, by } = this._bobPos();
this._trail.push({ x: bx, y: by });
if (this._trail.length > this._maxTrail) this._trail.shift();
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();
if (this.showPhase) {
this._phaseTrail.push({ x: this.theta, y: this.omega });
if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift();
}
}
/* ─── MODE: double ───────────────────────────── */
_stepDouble(dt) {
// Use smaller sub-steps for stability
const steps = 8;
const h = dt / steps;
for (let s = 0; s < steps; s++) {
this._rk4Double(h, false);
if (this.d.showGhost) this._rk4Double(h, true);
}
// trail for lower bob
const { bx, by } = this._doubleBobPos();
this.d.trail.push({ x: bx, y: by });
if (this.d.trail.length > this.d.maxTrail) this.d.trail.shift();
if (this.d.showGhost) {
const { bx: gx, by: gy } = this._doubleBobPos(true);
this.d.ghostTrail.push({ x: gx, y: gy });
if (this.d.ghostTrail.length > this.d.maxTrail) this.d.ghostTrail.shift();
}
if (this.showPhase) {
this._phaseTrail.push({ x: this.d.th1, y: this.d.om1 });
if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift();
}
}
_rk4Double(h, ghost) {
const d = this.d;
const g = 9.81 * 100; // px/s²
const { L1, L2, m1, m2 } = d;
const derivD = (th1, om1, th2, om2) => {
const dth = th1 - th2;
const denom = L1 * (2 * m1 + m2 - m2 * Math.cos(2 * dth));
const dom1 = (
-g * (2 * m1 + m2) * Math.sin(th1)
- m2 * g * Math.sin(th1 - 2 * th2)
- 2 * Math.sin(dth) * m2 * (om2 * om2 * L2 + om1 * om1 * L1 * Math.cos(dth))
) / denom;
const dom2 = (
2 * Math.sin(dth) * (
om1 * om1 * L1 * (m1 + m2)
+ g * (m1 + m2) * Math.cos(th1)
+ om2 * om2 * L2 * m2 * Math.cos(dth)
)
) / (L2 * (2 * m1 + m2 - m2 * Math.cos(2 * dth)));
return { dom1, dth1: om1, dom2, dth2: om2 };
};
let th1, om1, th2, om2;
if (ghost) {
th1 = d.gth1; om1 = d.gom1; th2 = d.gth2; om2 = d.gom2;
} else {
th1 = d.th1; om1 = d.om1; th2 = d.th2; om2 = d.om2;
}
const k1 = derivD(th1, om1, th2, om2);
const k2 = derivD(th1 + k1.dth1 * h / 2, om1 + k1.dom1 * h / 2, th2 + k1.dth2 * h / 2, om2 + k1.dom2 * h / 2);
const k3 = derivD(th1 + k2.dth1 * h / 2, om1 + k2.dom1 * h / 2, th2 + k2.dth2 * h / 2, om2 + k2.dom2 * h / 2);
const k4 = derivD(th1 + k3.dth1 * h, om1 + k3.dom1 * h, th2 + k3.dth2 * h, om2 + k3.dom2 * h);
const nth1 = th1 + h / 6 * (k1.dth1 + 2 * k2.dth1 + 2 * k3.dth1 + k4.dth1);
const nom1 = om1 + h / 6 * (k1.dom1 + 2 * k2.dom1 + 2 * k3.dom1 + k4.dom1);
const nth2 = th2 + h / 6 * (k1.dth2 + 2 * k2.dth2 + 2 * k3.dth2 + k4.dth2);
const nom2 = om2 + h / 6 * (k1.dom2 + 2 * k2.dom2 + 2 * k3.dom2 + k4.dom2);
if (ghost) {
d.gth1 = nth1; d.gom1 = nom1; d.gth2 = nth2; d.gom2 = nom2;
} else {
d.th1 = nth1; d.om1 = nom1; d.th2 = nth2; d.om2 = nom2;
}
}
_initDoubleGhost() {
const eps = 0.001;
this.d.gth1 = this.d.th1 + eps;
this.d.gom1 = this.d.om1;
this.d.gth2 = this.d.th2;
this.d.gom2 = this.d.om2;
this.d.ghostTrail = [];
}
/* ─── MODE: coupled ──────────────────────────── */
_stepCoupled(dt) {
const { L, g, k } = this.cp;
const gL = g * 100 / L;
// equations: th1'' = -gL*sin(th1) - k*(th1-th2)
// th2'' = -gL*sin(th2) + k*(th1-th2)
const derivC = (th1, om1, th2, om2) => ({
dth1: om1, dom1: -gL * Math.sin(th1) - k * (th1 - th2),
dth2: om2, dom2: -gL * Math.sin(th2) + k * (th1 - th2),
});
let { th1, om1, th2, om2 } = this.cp;
const k1 = derivC(th1, om1, th2, om2);
const k2 = derivC(th1 + k1.dth1 * dt / 2, om1 + k1.dom1 * dt / 2, th2 + k1.dth2 * dt / 2, om2 + k1.dom2 * dt / 2);
const k3 = derivC(th1 + k2.dth1 * dt / 2, om1 + k2.dom1 * dt / 2, th2 + k2.dth2 * dt / 2, om2 + k2.dom2 * dt / 2);
const k4 = derivC(th1 + k3.dth1 * dt, om1 + k3.dom1 * dt, th2 + k3.dth2 * dt, om2 + k3.dom2 * dt);
this.cp.th1 += dt / 6 * (k1.dth1 + 2 * k2.dth1 + 2 * k3.dth1 + k4.dth1);
this.cp.om1 += dt / 6 * (k1.dom1 + 2 * k2.dom1 + 2 * k3.dom1 + k4.dom1);
this.cp.th2 += dt / 6 * (k1.dth2 + 2 * k2.dth2 + 2 * k3.dth2 + k4.dth2);
this.cp.om2 += dt / 6 * (k1.dom2 + 2 * k2.dom2 + 2 * k3.dom2 + k4.dom2);
this.cp.hist1.push(this.cp.th1);
this.cp.hist2.push(this.cp.th2);
if (this.cp.hist1.length > 400) { this.cp.hist1.shift(); this.cp.hist2.shift(); }
if (this.showPhase) {
this._phaseTrail.push({ x: this.cp.th1, y: this.cp.om1 });
if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift();
}
}
/* ─── MODE: spring ───────────────────────────── */
_stepSpring(dt) {
const { k, m } = this.sp;
let accBase;
if (this.sp.mode === 'vert') {
// vertical: x is displacement from equilibrium (eq at mg/k below natural)
accBase = -k / m * this.sp.x;
} else {
accBase = -k / m * this.sp.x;
}
let driveAcc = 0;
if (this.sp.drive) {
driveAcc = this.sp.dF * Math.cos(this.sp.dOmega * (this._tSim || 0));
}
const deriv = (x, v) => ({ dx: v, dv: accBase + driveAcc - 0.1 * v });
const k1 = deriv(this.sp.x, this.sp.v);
const k2 = deriv(this.sp.x + k1.dx * dt / 2, this.sp.v + k1.dv * dt / 2);
const k3 = deriv(this.sp.x + k2.dx * dt / 2, this.sp.v + k2.dv * dt / 2);
const k4 = deriv(this.sp.x + k3.dx * dt, this.sp.v + k3.dv * dt);
this.sp.x += dt / 6 * (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx);
this.sp.v += dt / 6 * (k1.dv + 2 * k2.dv + 2 * k3.dv + k4.dv);
this._tSim = (this._tSim || 0) + dt;
this.sp.hist.push(this.sp.x);
if (this.sp.hist.length > 400) this.sp.hist.shift();
if (this.showPhase) {
this._phaseTrail.push({ x: this.sp.x, y: this.sp.v });
if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift();
}
}
/* ─── MODE: physical ─────────────────────────── */
_physInertia() {
// Returns {I, d} in px units (I=kg*px², d=px)
// We set mass=1 and treat L as length in px
const L = this.ph.L;
switch (this.ph.shape) {
case 'rod': return { I: L * L / 3, d: L / 2 }; // rod pivoted at end
case 'hoop': return { I: 2 * L * L, d: L }; // hoop radius L, pivot at rim
case 'disk': return { I: 1.5 * L * L, d: L }; // disk radius L, pivot at rim: I=3/2*mr²
case 'rect': return { I: L * L * 4 / 3, d: L }; // rect height 2L, pivot at top
default: return { I: L * L / 3, d: L / 2 };
}
}
_stepPhysical(dt) {
const { I, d } = this._physInertia();
const gL = this.ph.g * 100 * d / I; // torque ratio
const c = this.ph.damping;
const deriv = (th, om) => ({ dth: om, dom: -gL * Math.sin(th) - c * om });
const k1 = deriv(this.ph.theta, this.ph.omega);
const k2 = deriv(this.ph.theta + k1.dth * dt / 2, this.ph.omega + k1.dom * dt / 2);
const k3 = deriv(this.ph.theta + k2.dth * dt / 2, this.ph.omega + k2.dom * dt / 2);
const k4 = deriv(this.ph.theta + k3.dth * dt, this.ph.omega + k3.dom * dt);
this.ph.theta += dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth);
this.ph.omega += dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom);
if (this.showPhase) {
this._phaseTrail.push({ x: this.ph.theta, y: this.ph.omega });
if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift();
}
}
/* ─── MODE: foucault ─────────────────────────── */
_stepFoucault(dt) {
// Top-down view. In rotating frame:
// x'' = -omega0²·x + 2*Omega_z*y'
// y'' = -omega0²·y - 2*Omega_z*x'
// omega0 = sqrt(g/L) (in px units)
// Omega_z = Omega_earth * sin(phi) — sped up by timeScale
const fc = this.fc;
const omega0sq = 9.81 * 100 / fc.L;
// Earth rotation: 2pi / (24*3600) rad/s ≈ 7.27e-5 rad/s
// We speed up by timeScale (e.g. 200 sim-hours/real-second)
const OmegaEarth = (2 * Math.PI / 86400) * fc.timeScale;
const Omz = OmegaEarth * Math.sin(fc.phi);
const deriv = (x, vx, y, vy) => ({
dx: vx, dvx: -omega0sq * x + 2 * Omz * vy,
dy: vy, dvy: -omega0sq * y - 2 * Omz * vx,
});
let { x, vx, y, vy } = fc;
const k1 = deriv(x, vx, y, vy);
const k2 = deriv(x + k1.dx * dt / 2, vx + k1.dvx * dt / 2, y + k1.dy * dt / 2, vy + k1.dvy * dt / 2);
const k3 = deriv(x + k2.dx * dt / 2, vx + k2.dvx * dt / 2, y + k2.dy * dt / 2, vy + k2.dvy * dt / 2);
const k4 = deriv(x + k3.dx * dt, vx + k3.dvx * dt, y + k3.dy * dt, vy + k3.dvy * dt);
fc.x += dt / 6 * (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx);
fc.vx += dt / 6 * (k1.dvx + 2 * k2.dvx + 2 * k3.dvx + k4.dvx);
fc.y += dt / 6 * (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy);
fc.vy += dt / 6 * (k1.dvy + 2 * k2.dvy + 2 * k3.dvy + k4.dvy);
fc.tSim += dt;
fc.trail.push({ x: fc.x, y: fc.y });
if (fc.trail.length > fc.maxTrail) fc.trail.shift();
}
/* ─── MODE: resonance ────────────────────────── */
_stepResonance(dt) {
// θ'' = -(g/L)sinθ - γ·ω + F0·cos(ω_d·t)
const rs = this.rs;
const gL = rs.g * 100 / rs.L;
const deriv = (th, om, t) => ({
dth: om,
dom: -gL * Math.sin(th) - rs.gamma * om + rs.F0 * Math.cos(rs.dOmega * t),
});
const k1 = deriv(rs.theta, rs.omega, rs.tSim);
const k2 = deriv(rs.theta + k1.dth * dt / 2, rs.omega + k1.dom * dt / 2, rs.tSim + dt / 2);
const k3 = deriv(rs.theta + k2.dth * dt / 2, rs.omega + k2.dom * dt / 2, rs.tSim + dt / 2);
const k4 = deriv(rs.theta + k3.dth * dt, rs.omega + k3.dom * dt, rs.tSim + dt);
rs.theta += dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth);
rs.omega += dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom);
rs.tSim += dt;
if (this.showPhase) {
this._phaseTrail.push({ x: rs.theta, y: rs.omega });
if (this._phaseTrail.length > this._maxPhase) this._phaseTrail.shift();
}
}
_buildResonanceCurve() {
// Steady-state amplitude: A(w) = F0 / sqrt((w0²-w²)² + (γ·w)²)
const rs = this.rs;
const w0 = Math.sqrt(rs.g * 100 / rs.L);
const curve = [];
for (let i = 0; i <= 100; i++) {
const w = 0.05 + i * w0 * 3 / 100;
const denom = Math.sqrt(Math.pow(w0 * w0 - w * w, 2) + Math.pow(rs.gamma * w, 2));
const A = denom > 0.001 ? rs.F0 / denom : rs.F0 * 999;
curve.push({ w, A: Math.min(A, 5) });
}
rs.curve = curve;
rs.curveDirty = false;
}
/* ─── bob/pivot positions ─────────────────────── */
_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),
};
}
_doubleBobPos(ghost) {
const d = this.d;
const cx = this.W / 2;
const cy = Math.min(this.H * 0.22, 80);
const th1 = ghost ? d.gth1 : d.th1;
const th2 = ghost ? d.gth2 : d.th2;
const x1 = cx + d.L1 * Math.sin(th1);
const y1 = cy + d.L1 * Math.cos(th1);
const bx = x1 + d.L2 * Math.sin(th2);
const by = y1 + d.L2 * Math.cos(th2);
return { px: cx, py: cy, mx: x1, my: y1, bx, by };
}
/* ══════════════════════════════════════════════
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 mainW = this.showPhase ? Math.floor(W * 0.6) : W;
switch (this.mode) {
case 'math': this._drawMath(ctx, mainW, H); break;
case 'double': this._drawDouble(ctx, mainW, H); break;
case 'coupled': this._drawCoupled(ctx, mainW, H); break;
case 'spring': this._drawSpring(ctx, mainW, H); break;
case 'physical': this._drawPhysical(ctx, mainW, H); break;
case 'foucault': this._drawFoucault(ctx, mainW, H); break;
case 'resonance': this._drawResonance(ctx, mainW, H); break;
}
if (this.showPhase) {
this._drawPhasePortrait(ctx, mainW, 0, W - mainW, H);
}
if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ── draw: math ──────────────────────────────── */
_drawMath(ctx, W, H) {
const { px, py, bx, by } = this._bobPos();
this._drawTrailPts(ctx, this._trail, '#9B5DE5');
// support
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(this.W / 2 - 30, py - 4, 60, 4);
// rod
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();
this._drawBobGlow(ctx, bx, by, 18, '#9B5DE5');
this._drawAngleArc(ctx, px, py, this.theta);
this._drawEnergyBar(ctx, W, H);
this._drawEnergyChart(ctx, W, H);
}
/* ── draw: double ────────────────────────────── */
_drawDouble(ctx, W, H) {
// ghost trail (comparison) — draw first so main is on top
if (this.d.showGhost && this.d.ghostTrail.length > 1) {
this._drawTrailPts(ctx, this.d.ghostTrail, '#EF476F');
}
// main trail
this._drawTrailPts(ctx, this.d.trail, '#FFD166');
const { px, py, mx, my, bx, by } = this._doubleBobPos(false);
// support
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(px - 30, py - 4, 60, 4);
// rods
ctx.strokeStyle = 'rgba(255,255,255,0.7)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(mx, my); ctx.stroke();
ctx.beginPath(); ctx.moveTo(mx, my); ctx.lineTo(bx, by); ctx.stroke();
// pivot points
ctx.fillStyle = '#666';
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
this._drawBobGlow(ctx, mx, my, 12, '#06D6E0');
this._drawBobGlow(ctx, bx, by, 16, '#FFD166');
// ghost pendulum (arms)
if (this.d.showGhost) {
const g = this._doubleBobPos(true);
ctx.strokeStyle = 'rgba(239,71,111,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(g.px, g.py); ctx.lineTo(g.mx, g.my); ctx.stroke();
ctx.beginPath(); ctx.moveTo(g.mx, g.my); ctx.lineTo(g.bx, g.by); ctx.stroke();
ctx.fillStyle = 'rgba(239,71,111,0.6)';
ctx.beginPath(); ctx.arc(g.bx, g.by, 10, 0, Math.PI * 2); ctx.fill();
}
// chaos label
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px Manrope, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('Двойной маятник — хаос', 12, 12);
if (this.d.showGhost) {
ctx.fillStyle = '#EF476F'; ctx.fillText('• смещение +0.001 рад', 12, 26);
}
}
/* ── draw: coupled ───────────────────────────── */
_drawCoupled(ctx, W, H) {
const cy = Math.min(H * 0.2, 80);
const L = this.cp.L;
const x1 = W * 0.35;
const x2 = W * 0.65;
// spring connector at mid-rod
const sY1 = cy + L / 2;
const bX1 = x1 + L * Math.sin(this.cp.th1) * 0.5 + x1 * 0; // mid-rod
const bX2 = x2 + L * Math.sin(this.cp.th2) * 0.5;
// mid-points of rods
const mid1x = x1 + (L / 2) * Math.sin(this.cp.th1);
const mid1y = cy + (L / 2) * Math.cos(this.cp.th1);
const mid2x = x2 + (L / 2) * Math.sin(this.cp.th2);
const mid2y = cy + (L / 2) * Math.cos(this.cp.th2);
// draw spring between mid-points
this._drawSpringLine(ctx, mid1x, mid1y, mid2x, mid2y, '#FFD166');
// supports
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(x1 - 20, cy - 4, 40, 4);
ctx.fillRect(x2 - 20, cy - 4, 40, 4);
// pendulum 1
const b1x = x1 + L * Math.sin(this.cp.th1);
const b1y = cy + L * Math.cos(this.cp.th1);
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x1, cy); ctx.lineTo(b1x, b1y); ctx.stroke();
ctx.fillStyle = '#666';
ctx.beginPath(); ctx.arc(x1, cy, 5, 0, Math.PI * 2); ctx.fill();
this._drawBobGlow(ctx, b1x, b1y, 16, '#9B5DE5');
// pendulum 2
const b2x = x2 + L * Math.sin(this.cp.th2);
const b2y = cy + L * Math.cos(this.cp.th2);
ctx.beginPath(); ctx.moveTo(x2, cy); ctx.lineTo(b2x, b2y); ctx.stroke();
ctx.fillStyle = '#666';
ctx.beginPath(); ctx.arc(x2, cy, 5, 0, Math.PI * 2); ctx.fill();
this._drawBobGlow(ctx, b2x, b2y, 16, '#06D6E0');
// bottom graph: θ1 and θ2 vs time
this._drawCoupledChart(ctx, W, H);
}
_drawSpringLine(ctx, x1, y1, x2, y2, color) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy);
const nx = -dy / len, ny = dx / len; // normal
const coils = 10;
const amp = 6;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x1, y1);
for (let i = 0; i <= coils * 2; i++) {
const t = i / (coils * 2);
const side = (i % 2 === 0) ? amp : -amp;
const px = x1 + t * dx + nx * side;
const py = y1 + t * dy + ny * side;
ctx.lineTo(px, py);
}
ctx.lineTo(x2, y2);
ctx.stroke();
}
_drawCoupledChart(ctx, W, H) {
const h1 = this.cp.hist1, h2 = this.cp.hist2;
if (h1.length < 2) return;
const cw = Math.min(W * 0.7, 340);
const ch = 70;
const cx = (W - cw) / 2;
const cy = H - ch - 16;
ctx.fillStyle = 'rgba(22,22,38,0.75)';
ctx.beginPath(); ctx.roundRect(cx - 8, cy - 8, cw + 16, ch + 16, 8); ctx.fill();
let maxA = 0;
for (const v of h1) maxA = Math.max(maxA, Math.abs(v));
for (const v of h2) maxA = Math.max(maxA, Math.abs(v));
if (maxA < 0.001) return;
const drawLine = (data, color) => {
ctx.strokeStyle = color; 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 / 2 - (data[i] / maxA) * (ch / 2 - 4);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
};
drawLine(h1, '#9B5DE5');
drawLine(h2, '#06D6E0');
ctx.font = '10px Manrope, sans-serif'; ctx.textBaseline = 'bottom';
ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'left'; ctx.fillText('θ₁', cx + 2, cy);
ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'left'; ctx.fillText('θ₂', cx + 18, cy);
}
/* ── draw: spring ────────────────────────────── */
_drawSpring(ctx, W, H) {
const sp = this.sp;
const isVert = sp.mode === 'vert';
if (isVert) {
this._drawSpringVert(ctx, W, H);
} else {
this._drawSpringHoriz(ctx, W, H);
}
// displacement chart
this._drawSpringChart(ctx, W, H);
// period label
const T = 2 * Math.PI * Math.sqrt(sp.m / sp.k);
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '12px Manrope, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('T = 2π√(m/k) = ' + T.toFixed(2) + ' с', 12, 12);
if (!isVert) {
ctx.fillText('(T не зависит от g)', 12, 28);
}
}
_drawSpringVert(ctx, W, H) {
const sp = this.sp;
const anchorX = W / 2;
const anchorY = H * 0.15;
const eqY = anchorY + sp.restLen * 300; // equilibrium position in px
const bobY = eqY + sp.x * 300;
const springEndY = bobY - 20;
// ceiling
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(anchorX - 30, anchorY - 4, 60, 4);
// spring
this._drawSpringCoils(ctx, anchorX, anchorY, anchorX, springEndY, 10, 8, '#FFD166');
// equilibrium dashed line
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(anchorX - 50, eqY); ctx.lineTo(anchorX + 50, eqY); ctx.stroke();
ctx.setLineDash([]);
this._drawBobGlow(ctx, anchorX, bobY, 20, '#06D6E0');
}
_drawSpringHoriz(ctx, W, H) {
const sp = this.sp;
const anchorX = W * 0.25;
const baseY = H * 0.5;
const eqX = anchorX + sp.restLen * 300;
const bobX = eqX + sp.x * 300;
// wall
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(anchorX - 8, baseY - 30, 8, 60);
// floor line
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(anchorX, baseY + 22); ctx.lineTo(W * 0.85, baseY + 22); ctx.stroke();
// spring
this._drawSpringCoils(ctx, anchorX, baseY, bobX - 20, baseY, 10, 8, '#FFD166');
// equilibrium dashed
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(eqX, baseY - 40); ctx.lineTo(eqX, baseY + 40); ctx.stroke();
ctx.setLineDash([]);
this._drawBobGlow(ctx, bobX, baseY, 20, '#06D6E0');
}
_drawSpringCoils(ctx, x1, y1, x2, y2, coils, amp, color) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 1) return;
const ux = dx / len, uy = dy / len;
const nx = -uy, ny = ux;
ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath();
ctx.moveTo(x1, y1);
const seg = coils * 2 + 2;
for (let i = 1; i < seg; i++) {
const t = i / seg;
const side = (i % 2 === 0) ? amp : -amp;
ctx.lineTo(x1 + t * dx + nx * side, y1 + t * dy + ny * side);
}
ctx.lineTo(x2, y2);
ctx.stroke();
}
_drawSpringChart(ctx, W, H) {
const data = this.sp.hist;
if (data.length < 2) return;
const cw = Math.min(W * 0.55, 280);
const ch = 70;
const cx = W - cw - 16;
const cy = H - ch - 16;
ctx.fillStyle = 'rgba(22,22,38,0.75)';
ctx.beginPath(); ctx.roundRect(cx - 8, cy - 8, cw + 16, ch + 16, 8); ctx.fill();
let maxX = 0;
for (const v of data) maxX = Math.max(maxX, Math.abs(v));
if (maxX < 0.0001) return;
ctx.strokeStyle = '#06D6E0'; 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 / 2 - (data[i] / maxX) * (ch / 2 - 4);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
// zero line
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(cx, cy + ch / 2); ctx.lineTo(cx + cw, cy + ch / 2); ctx.stroke();
ctx.setLineDash([]);
ctx.font = '10px Manrope, sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; ctx.fillText('x(t)', cx + 2, cy);
}
/* ── draw: physical ──────────────────────────── */
_drawPhysical(ctx, W, H) {
const ph = this.ph;
const px = W / 2;
const py = Math.min(H * 0.15, 70);
const th = ph.theta;
const L = ph.L;
// pivot
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(px - 30, py - 4, 60, 4);
ctx.fillStyle = '#666'; ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
ctx.save();
ctx.translate(px, py);
ctx.rotate(th);
switch (ph.shape) {
case 'rod':
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, L); ctx.stroke();
ctx.fillStyle = '#9B5DE5';
ctx.beginPath(); ctx.arc(0, L, 10, 0, Math.PI * 2); ctx.fill();
break;
case 'hoop': {
const R = L * 0.5;
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 6;
ctx.beginPath(); ctx.arc(0, L, R, 0, Math.PI * 2); ctx.stroke();
// line from pivot to hoop center
ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, L); ctx.stroke();
break;
}
case 'disk': {
const R = L * 0.4;
ctx.fillStyle = 'rgba(255,209,102,0.25)';
ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(0, L, R, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, L); ctx.stroke();
break;
}
case 'rect': {
const rw = 40, rh = L * 1.8;
ctx.fillStyle = 'rgba(6,214,224,0.15)';
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.roundRect(-rw / 2, 0, rw, rh, 4); ctx.fill(); ctx.stroke();
break;
}
}
ctx.restore();
this._drawAngleArc(ctx, px, py, th);
// period comparison box
const { I, d } = this._physInertia();
const Tphys = 2 * Math.PI * Math.sqrt(I / (ph.g * 100 * d));
const Tmath = 2 * Math.PI * Math.sqrt(L / (ph.g * 100));
ctx.fillStyle = 'rgba(22,22,38,0.8)';
ctx.beginPath(); ctx.roundRect(12, 12, 210, 52, 8); ctx.fill();
ctx.font = '11px Manrope, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = '#FFD166';
ctx.fillText('T_физ = ' + Tphys.toFixed(2) + ' с (' + ph.shape + ')', 20, 18);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText('T_мат = ' + Tmath.toFixed(2) + ' с (матем.)', 20, 34);
if (this.showPhase) return; // skip energy bar when phase portrait active
}
/* ── draw: foucault ──────────────────────────── */
_drawFoucault(ctx, W, H) {
const fc = this.fc;
const cx = W / 2;
const cy = H / 2;
const R = Math.min(W, H) * 0.38;
// sand floor circle
ctx.fillStyle = 'rgba(180,150,100,0.12)';
ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(180,150,100,0.3)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke();
// compass directions
ctx.font = '12px Manrope, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillText('N', cx, cy - R + 14);
ctx.fillText('S', cx, cy + R - 14);
ctx.fillText('W', cx - R + 14, cy);
ctx.fillText('E', cx + R - 14, cy);
// trail
const trail = fc.trail;
if (trail.length > 1) {
for (let i = 1; i < trail.length; i++) {
const a = (i / trail.length) * 0.7;
ctx.strokeStyle = 'rgba(255,209,102,' + a.toFixed(2) + ')';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(cx + trail[i - 1].x, cy + trail[i - 1].y);
ctx.lineTo(cx + trail[i].x, cy + trail[i].y);
ctx.stroke();
}
}
// current bob
this._drawBobGlow(ctx, cx + fc.x, cy + fc.y, 10, '#FFD166');
// center dot
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill();
// info overlay
const phiDeg = (fc.phi * 180 / Math.PI).toFixed(0);
const sinPhi = Math.sin(fc.phi);
const Trot = sinPhi > 0.001 ? (24 / sinPhi).toFixed(1) + ' ч' : '∞';
ctx.fillStyle = 'rgba(22,22,38,0.8)';
ctx.beginPath(); ctx.roundRect(12, 12, 210, 52, 8); ctx.fill();
ctx.font = '11px Manrope, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = '#FFD166';
ctx.fillText('широта φ = ' + phiDeg + '°', 20, 18);
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillText('T_поворота = ' + Trot, 20, 34);
}
/* ── draw: resonance ─────────────────────────── */
_drawResonance(ctx, W, H) {
const rs = this.rs;
// pendulum animation (left half)
const animW = Math.floor(W * 0.5);
const px = animW / 2;
const py = Math.min(H * 0.18, 80);
const bx = px + rs.L * Math.sin(rs.theta);
const by = py + rs.L * Math.cos(rs.theta);
// driving force arrow
const Fy = rs.F0 * Math.cos(rs.dOmega * rs.tSim) * 40;
ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx + Fy * 0.5, by); ctx.stroke();
// arrowhead
const arrowDir = Fy > 0 ? 1 : -1;
ctx.fillStyle = '#EF476F';
ctx.beginPath();
ctx.moveTo(bx + Fy * 0.5, by);
ctx.lineTo(bx + Fy * 0.5 - arrowDir * 8, by - 5);
ctx.lineTo(bx + Fy * 0.5 - arrowDir * 8, by + 5);
ctx.closePath(); ctx.fill();
// support & rod
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(px - 30, py - 4, 60, 4);
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(bx, by); ctx.stroke();
ctx.fillStyle = '#666'; ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
this._drawBobGlow(ctx, bx, by, 18, '#9B5DE5');
// resonance curve (right half)
this._drawResonanceCurve(ctx, animW, 0, W - animW, H);
}
_drawResonanceCurve(ctx, offX, offY, cw, ch) {
const rs = this.rs;
if (rs.curveDirty) this._buildResonanceCurve();
const data = rs.curve;
if (data.length < 2) return;
const pad = 40;
const iw = cw - pad * 2;
const ih = ch - pad * 2 - 16;
const ox = offX + pad;
const oy = offY + pad;
ctx.fillStyle = 'rgba(22,22,38,0.7)';
ctx.beginPath(); ctx.roundRect(offX + 8, offY + 8, cw - 16, ch - 16, 10); ctx.fill();
let maxA = 0;
for (const p of data) maxA = Math.max(maxA, p.A);
if (maxA < 0.001) return;
const maxW = data[data.length - 1].w;
// axes
ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(ox, oy + ih); ctx.lineTo(ox + iw, oy + ih); ctx.stroke();
// curve
ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = ox + (data[i].w / maxW) * iw;
const y = oy + ih - (data[i].A / maxA) * ih;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
// current omega marker
const omega0 = Math.sqrt(rs.g * 100 / rs.L);
const curX = ox + (rs.dOmega / maxW) * iw;
ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 2; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(curX, oy); ctx.lineTo(curX, oy + ih); ctx.stroke();
ctx.setLineDash([]);
// omega0 line
const w0x = ox + (omega0 / maxW) * iw;
ctx.strokeStyle = 'rgba(6,214,224,0.5)'; ctx.lineWidth = 1; ctx.setLineDash([2, 4]);
ctx.beginPath(); ctx.moveTo(w0x, oy); ctx.lineTo(w0x, oy + ih); ctx.stroke();
ctx.setLineDash([]);
// labels
ctx.font = '10px Manrope, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('ω', ox + iw / 2, oy + ih + 4);
ctx.fillStyle = '#06D6E0'; ctx.fillText('ω₀', w0x, oy + 2);
ctx.fillStyle = '#EF476F'; ctx.fillText('ω′', curX, oy + 12);
ctx.fillStyle = '#FFD166'; ctx.textAlign = 'right';
ctx.fillText('A(ω)', ox + iw, oy + 4);
}
/* ── draw helpers ─────────────────────────────── */
_drawBobGlow(ctx, bx, by, r, color) {
const draw = () => {
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(bx, by, r, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
const grad = ctx.createRadialGradient(bx, by, 0, bx, by, r * 2);
grad.addColorStop(0, color.replace(')', ',0.25)').replace('rgb', 'rgba'));
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(bx, by, r * 2, 0, Math.PI * 2); ctx.fill();
};
if (window.LabFX) {
LabFX.glow.drawGlow(ctx, draw, { color, intensity: 8 });
} else {
draw();
}
}
_drawTrailPts(ctx, pts, color) {
const n = pts.length;
if (n < 2) return;
for (let i = 1; i < n; i++) {
const a = (i / n) * 0.7;
ctx.strokeStyle = color.startsWith('#')
? color + Math.round(a * 255).toString(16).padStart(2, '0')
: `rgba(155,93,229,${a})`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(pts[i - 1].x, pts[i - 1].y);
ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
}
}
_drawAngleArc(ctx, px, py, theta) {
if (Math.abs(theta) < 0.02) return;
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 + 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 + theta / 2;
ctx.fillText(
(theta * 180 / Math.PI).toFixed(1) + '°',
px + (arcR + 16) * Math.cos(labelAngle),
py + (arcR + 16) * Math.sin(labelAngle)
);
}
/* ── draw: energy bar (math mode) ───────────────── */
_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();
const kw = (KE / total) * bw;
ctx.fillStyle = '#EF476F';
ctx.beginPath(); ctx.roundRect(x, y, Math.max(2, kw), bh, 4); ctx.fill();
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);
}
/* ── draw: energy chart (math mode) ─────────────── */
_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;
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();
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();
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([]);
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);
}
/* ── draw: phase portrait ─────────────────────── */
_drawPhasePortrait(ctx, offX, offY, panelW, panelH) {
const pts = this._phaseTrail;
const pad = 30;
const iw = panelW - pad * 2;
const ih = panelH - pad * 2 - 30;
const ox = offX + pad;
const oy = offY + pad;
ctx.fillStyle = 'rgba(13,13,26,0.92)';
ctx.fillRect(offX, offY, panelW, panelH);
// axes
ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ox, oy); ctx.lineTo(ox, oy + ih); ctx.lineTo(ox + iw, oy + ih);
ctx.stroke();
// center lines
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(ox + iw / 2, oy); ctx.lineTo(ox + iw / 2, oy + ih); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ox, oy + ih / 2); ctx.lineTo(ox + iw, oy + ih / 2); ctx.stroke();
ctx.setLineDash([]);
if (pts.length > 1) {
let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
for (const p of pts) {
xMin = Math.min(xMin, p.x); xMax = Math.max(xMax, p.x);
yMin = Math.min(yMin, p.y); yMax = Math.max(yMax, p.y);
}
const xRange = Math.max(xMax - xMin, 0.1);
const yRange = Math.max(yMax - yMin, 0.1);
const mapX = (x) => ox + ((x - xMin) / xRange) * iw;
const mapY = (y) => oy + ih - ((y - yMin) / yRange) * ih;
for (let i = 1; i < pts.length; i++) {
const a = (i / pts.length) * 0.8;
ctx.strokeStyle = `rgba(155,93,229,${a.toFixed(2)})`;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(mapX(pts[i - 1].x), mapY(pts[i - 1].y));
ctx.lineTo(mapX(pts[i].x), mapY(pts[i].y));
ctx.stroke();
}
// current point
const last = pts[pts.length - 1];
ctx.fillStyle = '#FFD166';
ctx.beginPath(); ctx.arc(mapX(last.x), mapY(last.y), 4, 0, Math.PI * 2); ctx.fill();
}
// labels
ctx.font = '10px Manrope, sans-serif'; ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.textBaseline = 'top'; ctx.fillText('Фазовый портрет', offX + panelW / 2, offY + 6);
ctx.textBaseline = 'bottom'; ctx.fillText('θ', offX + panelW / 2, offY + panelH - 4);
ctx.save(); ctx.translate(offX + 12, offY + panelH / 2); ctx.rotate(-Math.PI / 2);
ctx.fillText('ω', 0, 0); ctx.restore();
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
cv.addEventListener('mousedown', e => {
if (this.mode !== 'math') return;
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(); }
});
cv.addEventListener('touchstart', e => {
if (this.mode !== 'math' || 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.setMode(pendSim.mode || 'math');
pendSim.play();
}));
}
function pendSetMode(m) {
if (!pendSim) return;
pendSim.setMode(m);
// toggle button highlight
document.querySelectorAll('.pend-mode-btn').forEach(b => {
b.classList.toggle('active', b.dataset.mode === m);
});
// show/hide param panels
document.querySelectorAll('.pend-params').forEach(el => {
el.style.display = el.dataset.mode === m ? '' : 'none';
});
pendSim.play();
}
function pendTogglePhase() {
if (!pendSim) return;
pendSim.showPhase = !pendSim.showPhase;
pendSim._clearPhase();
const btn = document.getElementById('btn-pend-phase');
if (btn) btn.classList.toggle('active', pendSim.showPhase);
}
function pendToggleGhost() {
if (!pendSim) return;
pendSim.d.showGhost = !pendSim.d.showGhost;
if (pendSim.d.showGhost) pendSim._initDoubleGhost();
else pendSim.d.ghostTrail = [];
const btn = document.getElementById('btn-pend-ghost');
if (btn) btn.classList.toggle('active', pendSim.d.showGhost);
}
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();
}
}
/* double pendulum params */
function pendDoubleParam(name, val) {
const v = parseFloat(val);
const el = document.getElementById('pend-d-' + name + '-val');
if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(1);
if (!pendSim) return;
if (name === 'L1') { pendSim.d.L1 = v; }
else if (name === 'L2') { pendSim.d.L2 = v; }
else if (name === 'm1') { pendSim.d.m1 = v; }
else if (name === 'm2') { pendSim.d.m2 = v; }
else if (name === 'th1') { pendSim.d.th1 = v * Math.PI / 180; pendSim.d.om1 = 0; pendSim.d.trail = []; }
else if (name === 'th2') { pendSim.d.th2 = v * Math.PI / 180; pendSim.d.om2 = 0; pendSim.d.trail = []; }
if (pendSim.d.showGhost) pendSim._initDoubleGhost();
}
/* coupled pendulum params */
function pendCoupledParam(name, val) {
const v = parseFloat(val);
const el = document.getElementById('pend-cp-' + name + '-val');
if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(2);
if (!pendSim) return;
if (name === 'k') { pendSim.cp.k = v; }
else if (name === 'L') { pendSim.cp.L = v; pendSim.cp.hist1 = []; pendSim.cp.hist2 = []; }
else if (name === 'th1') { pendSim.cp.th1 = v * Math.PI / 180; pendSim.cp.om1 = 0; }
}
/* spring params */
function pendSpringParam(name, val) {
const v = parseFloat(val);
const el = document.getElementById('pend-sp-' + name + '-val');
if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(1);
if (!pendSim) return;
if (name === 'k') { pendSim.sp.k = v; }
else if (name === 'm') { pendSim.sp.m = v; }
else if (name === 'x0') { pendSim.sp.x = v / 100; pendSim.sp.v = 0; pendSim.sp.hist = []; }
}
function pendSpringMode(m) {
if (!pendSim) return;
pendSim.sp.mode = m;
pendSim.sp.x = 0.08; pendSim.sp.v = 0; pendSim.sp.hist = [];
document.querySelectorAll('.sp-mode-btn').forEach(b => b.classList.toggle('active', b.dataset.m === m));
}
/* physical pendulum params */
function pendPhysShape(s) {
if (!pendSim) return;
pendSim.ph.shape = s;
document.querySelectorAll('.ph-shape-btn').forEach(b => b.classList.toggle('active', b.dataset.s === s));
}
function pendPhysParam(name, val) {
const v = parseFloat(val);
const el = document.getElementById('pend-ph-' + name + '-val');
if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(1);
if (!pendSim) return;
if (name === 'L') { pendSim.ph.L = v; }
else if (name === 'theta') { pendSim.ph.theta = v * Math.PI / 180; pendSim.ph.omega = 0; }
else if (name === 'damping') { pendSim.ph.damping = v; }
}
/* foucault params */
function pendFoucaultParam(name, val) {
const v = parseFloat(val);
const el = document.getElementById('pend-fc-' + name + '-val');
if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(0);
if (!pendSim) return;
if (name === 'phi') {
pendSim.fc.phi = v * Math.PI / 180;
pendSim.fc.trail = []; pendSim.fc.tSim = 0;
pendSim.fc.x = 60; pendSim.fc.y = 0; pendSim.fc.vx = 0; pendSim.fc.vy = 0;
}
}
/* resonance params */
function pendResonanceParam(name, val) {
const v = parseFloat(val);
const el = document.getElementById('pend-rs-' + name + '-val');
if (el) el.textContent = v % 1 === 0 ? v : v.toFixed(2);
if (!pendSim) return;
if (name === 'dOmega') { pendSim.rs.dOmega = v; pendSim.rs.curveDirty = true; }
else if (name === 'F0') { pendSim.rs.F0 = v; pendSim.rs.curveDirty = true; }
else if (name === 'gamma') { pendSim.rs.gamma = v; pendSim.rs.curveDirty = true; }
else if (name === 'L') {
pendSim.rs.L = v; pendSim.rs.curveDirty = true;
pendSim.rs.theta = 0.1; pendSim.rs.omega = 0;
}
}
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);
}