e46548d06b
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>
1651 lines
59 KiB
JavaScript
1651 lines
59 KiB
JavaScript
'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);
|
||
}
|