Files
Learn_System/frontend/js/labs/pendulum.js
T
Maxim Dolgolyov 7a323f8fe0 feat(labs): универсальные инструменты для физических симуляций (Раунд 2)
ФУНДАМЕНТ — frontend/js/labs/_phys_visuals.js (новый файл, 871 строк):
- LSPhysFX.FORCE_COLORS: стандарт 10 цветов (mg красный, N циан, F_тр оранжевый,
  T фиолетовый, F_упр зелёный, F_сопр серый, F_прил жёлтый, импульс, v, a)
- drawVector / drawForceArrow / drawSpring / drawRope / drawSurface
- drawCoordSystem / drawScale / drawClock / drawAngleArc / drawPivot
- drawEnergyBars (KE/PE/W_тр/E_упр стеком с авто-масштабированием)
- LSTimeControl class (pause/play/0.1×-5×/scrubber для одного DOF)
- LSMotionTrail class (gradient line с alpha fade)
- LSBuildTimeControlUI helper для DOM-UI бара

ГРАФИКИ — frontend/js/labs/_graph_panel.js (новый файл, 415 строк):
- LSGraphPanel: 3 stacked time-series плота, авто-scale, freeze/export PNG
- LSGraphPanelUI: overlay-виджет 320×248 в углу canvas, body-selector,
  кнопки Сброс/Стоп/PNG download

FBD (свободные силовые диаграммы) интегрированы в:
- projectile.js: mg + drag + wind + elastic (bounce)
- pendulum.js: mg + T (math), mg + F_упр (spring vert/horiz)
- collision.js: стрелки скорости каждого шара + flash импульса
- newton.js: все сцены законов I-III + новые Атвуд/наклон/скатывание
- forcesandbox.js: gravity/N/friction/spring/applied на каждом теле

ENERGY BARS интегрированы в 5 сим с расчётами:
- projectile: ΔE_drag = F_d·v·dt (cumulative)
- pendulum: для math/spring/double/physical с учётом γ-затухания
- collision: KE loss при каждом столкновении
- newton: все 8 сцен включая Атвуд + наклон + скатывание (с моментом инерции)
- forcesandbox: + E_упр от пружин

GRAPHS PANEL — в 5 сим:
- pendulum: θ/ω/E (режим-aware)
- collision: |v₁|, |v₂|, v_цм
- newton: x/v/a (зависит от закона)
- forcesandbox: x/|v|/|a| выбранного тела
- hydrostatics: depth/vy/submergedFrac (только Архимед)

TIME CONTROL + MOTION TRAILS в 5 сим:
- pendulum (полный scrubber для math), collision, newton, forcesandbox (speed+pause)
- projectile (layered speed+pause, свой trail сохранён)
- LSMotionTrail на bob/балах/блоках с alpha gradient

Заменено рисование пружин на LSPhysFX.drawSpring везде.

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

1971 lines
72 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;
/* FBD toggle */
this._fbdOn = false;
/* ── Energy bars widget ── */
this._energyOn = false;
this._frictionWork = 0; // cumulative damping loss (J)
this._energyScale = 0;
/* ── GraphPanel widget ── */
this._graphsOn = false;
this._graphUI = null;
/* ── TimeControl + MotionTrails ── */
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
this._tcTrails = {
math: window.LSMotionTrail ? new window.LSMotionTrail({ color: '#9B5DE5', width: 3, maxLen: 150 }) : null,
double1: window.LSMotionTrail ? new window.LSMotionTrail({ color: '#06D6E0', width: 2.5, maxLen: 200 }) : null,
double2: window.LSMotionTrail ? new window.LSMotionTrail({ color: '#F15BB5', width: 2.5, maxLen: 200 }) : null,
};
this.showTCTrails = false;
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;
}
this._frictionWork = 0;
this._energyScale = 0;
if (this._tc) this._tc.reset();
/* clear motion trails */
for (const t of Object.values(this._tcTrails)) { if (t) t.clear(); }
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: '—' };
}
}
/* ── Graph panel helpers ───────────────────── */
_pendGraphValues() {
switch (this.mode) {
case 'math':
case 'physical':
case 'resonance': {
const th = (this.mode === 'physical') ? this.ph.theta :
(this.mode === 'resonance') ? this.rs.theta : this.theta;
const om = (this.mode === 'physical') ? this.ph.omega :
(this.mode === 'resonance') ? this.rs.omega : this.omega;
const L2 = (this.L || 200) * (this.L || 200);
const KE = 0.5 * om * om * L2;
const PE = (this.g || 9.81) * 100 * (this.L || 200) * (1 - Math.cos(th));
return [th, om, KE + PE];
}
case 'double': return [this.d.th1, this.d.th2, this.d.om1];
case 'coupled': return [this.cp.th1, this.cp.th2, this.cp.om1 - this.cp.om2];
case 'spring': return [this.sp.x, this.sp.v, 0.5 * (this.sp.k || 8) * this.sp.x * this.sp.x];
case 'foucault': return [this.fc.x, this.fc.y, Math.hypot(this.fc.vx || 0, this.fc.vy || 0)];
default: return [0, 0, 0];
}
}
_pendGraphOpts() {
const BASE = { maxPoints: 400, colors: ['#06D6E0', '#FFD166', '#EF476F'], toggleBtnId: 'btn-pend-graphs', title: 'Графики' };
switch (this.mode) {
case 'math': case 'physical': case 'resonance':
return Object.assign({}, BASE, { traces: ['th', 'om', 'E'], labels: ['θ', 'ω', 'E'], units: ['рад', 'рад/с', 'Дж'] });
case 'double':
return Object.assign({}, BASE, { traces: ['th1', 'th2', 'om1'], labels: ['θ1', 'θ2', 'ω1'], units: ['рад', 'рад', 'рад/с'] });
case 'coupled':
return Object.assign({}, BASE, { traces: ['th1', 'th2', 'dom'], labels: ['θ1', 'θ2', 'Δω'], units: ['рад', 'рад', 'рад/с'] });
case 'spring':
return Object.assign({}, BASE, { traces: ['x', 'v', 'E'], labels: ['x', 'v', 'E'], units: ['м', 'м/с', 'Дж'] });
case 'foucault':
return Object.assign({}, BASE, { traces: ['x', 'y', 'v'], labels: ['x', 'y', '|v|'], units: ['м', 'м', 'м/с'] });
default:
return Object.assign({}, BASE, { traces: ['th', 'om', 'E'], labels: ['θ', 'ω', 'E'], units: ['', '', ''] });
}
}
toggleGraphs(canvasOuter) {
if (!window.LSGraphPanelUI) return false;
this._graphsOn = !this._graphsOn;
if (this._graphsOn) {
this._graphUI = new GraphPanelUI(canvasOuter, this._pendGraphOpts());
this._graphUI.isOn = true;
this._graphUI._build();
} else {
if (this._graphUI) { this._graphUI._destroy(); this._graphUI = null; }
}
return this._graphsOn;
}
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_clearTrail() { this._trail = []; }
_clearPhase() { this._phaseTrail = []; }
_clearAll() { this._clearTrail(); this._clearPhase(); }
/* ── getState / applyState for math mode (scrub support) ── */
getState() {
if (this.mode !== 'math') return null;
return { theta: this.theta, omega: this.omega, tSim: this._tSim };
}
applyState(st) {
if (!st || this.mode !== 'math') return;
this.theta = st.theta;
this.omega = st.omega;
this._tSim = st.tSim || 0;
this.draw();
}
_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;
if (window.LabFX) LabFX.particles.update(rawDt);
/* TimeControl: scale speed, handle pause */
let dt;
if (this._tc) {
dt = this._tc.advance(rawDt * this.speed);
if (dt === 0) { this.draw(); this._tick(); return; }
/* record state for math mode scrubbing */
if (this.mode === 'math') {
this._tc.record(this.getState());
}
} else {
dt = rawDt * this.speed;
}
/* tick motion trails */
if (this.showTCTrails) {
const tr = this._tcTrails;
if (tr.math) tr.math.tick();
if (tr.double1) tr.double1.tick();
if (tr.double2) tr.double2.tick();
}
this._stepMode(dt);
this.draw();
this._emit();
if (window.LSGraphPanel && this._graphsOn && this._graphUI) {
this._graphUI.push(this._tSim, this._pendGraphValues());
}
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();
if (this.showTCTrails && this._tcTrails.math) this._tcTrails.math.push(bx, by);
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();
/* accumulate damping loss for energy bars */
if (this._energyOn && this.damping > 0) {
/* power dissipated = c * omega² (normalised) */
this._frictionWork += this.damping * this.omega * this.omega * dt * 0.5 * this.L * this.L;
}
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.showTCTrails) {
/* push both bobs to motion trails */
const { bx: b1x, by: b1y } = this._doubleBobPos();
if (this._tcTrails.double1) this._tcTrails.double1.push(b1x, b1y);
if (this._tcTrails.double2) this._tcTrails.double2.push(bx, by);
}
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);
}
/* ── Energy bars overlay ── */
if (this._energyOn && window.LSPhysFX) {
this._drawEnergyBarsPend(ctx, mainW, H);
}
if (window.LabFX) LabFX.particles.draw(ctx);
}
/* ── Energy bars: pendulum ── */
_drawEnergyBarsPend(ctx, W, H) {
var en = this._calcEnergiesPend();
if (!en) return;
var tot = en.ke + en.pe + en.elastic + en.friction;
if (tot > this._energyScale) this._energyScale = tot;
var PW = 188, MARGIN = 12;
LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0,
{ ke: en.ke, pe: en.pe, elastic: en.elastic, friction: en.friction,
total: this._energyScale }, {});
}
_calcEnergiesPend() {
var ke = 0, pe = 0, el = 0, fr = this._frictionWork;
var m = 1; // normalised mass
switch (this.mode) {
case 'math': {
var L = this.L / 100; // px -> m (1px=1cm)
ke = 0.5 * (this.omega * this.omega * L * L);
pe = this.g * L * (1 - Math.cos(this.theta));
break;
}
case 'spring': {
var sm = this.sp.m || 1;
var sk = this.sp.k || 20;
ke = 0.5 * sm * this.sp.v * this.sp.v;
el = 0.5 * sk * this.sp.x * this.sp.x;
break;
}
case 'double': {
var d = this.d;
var L1 = d.L1 / 100, L2 = d.L2 / 100;
var m1 = d.m1, m2 = d.m2;
/* KE of both bobs (approx: treat as point masses) */
var v1sq = L1 * L1 * d.om1 * d.om1;
/* bob2 velocity via compound motion */
var vx2 = L1 * d.om1 * Math.cos(d.th1) + L2 * d.om2 * Math.cos(d.th2);
var vy2 = L1 * d.om1 * Math.sin(d.th1) + L2 * d.om2 * Math.sin(d.th2);
ke = 0.5 * m1 * v1sq + 0.5 * m2 * (vx2 * vx2 + vy2 * vy2);
pe = m1 * this.g * L1 * (1 - Math.cos(d.th1)) +
m2 * this.g * (L1 * (1 - Math.cos(d.th1)) + L2 * (1 - Math.cos(d.th2)));
break;
}
case 'physical': {
var ph = this.ph;
var Lp = ph.L / 100;
/* moment of inertia about pivot depends on shape */
var I;
if (ph.shape === 'rod') I = (1/3) * Lp * Lp; // rod: I = 1/3 mL²
else if (ph.shape === 'hoop') I = 2 * (Lp/2) * (Lp/2); // hoop: I = 2*m*R² (R=L/2)
else I = (1/2) * (Lp/2) * (Lp/2); // disk: I = 1/2 mR²
ke = 0.5 * I * ph.omega * ph.omega;
var Lcom = Lp / 2;
pe = ph.g * Lcom * (1 - Math.cos(ph.theta));
break;
}
default:
return null;
}
return { ke: Math.max(0, ke), pe: Math.max(0, pe),
elastic: Math.max(0, el), friction: Math.max(0, fr) };
}
/* ── draw: math ──────────────────────────────── */
_drawMath(ctx, W, H) {
const { px, py, bx, by } = this._bobPos();
this._drawTrailPts(ctx, this._trail, '#9B5DE5');
/* MotionTrail overlay */
if (this.showTCTrails && this._tcTrails.math) this._tcTrails.math.draw(ctx);
// 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);
/* FBD overlay for math pendulum */
if (this._fbdOn && window.LSPhysFX) {
const L = this.L * (H * 0.42);
const mg = this.m * this.g;
/* gravity — downward */
LSPhysFX.drawForceArrow(ctx, bx, by, 0, 50, 'gravity',
'mg=' + mg.toFixed(1) + 'Н');
/* tension — along rod toward pivot */
const T_mag = mg * Math.cos(this.theta) + this.m * L * this.thetaDot * this.thetaDot;
const rodDx = px - bx, rodDy = py - by;
const rodLen = Math.sqrt(rodDx * rodDx + rodDy * rodDy) || 1;
const tLen = Math.min(55, Math.max(20, T_mag * 2.5));
LSPhysFX.drawForceArrow(ctx, bx, by,
(rodDx / rodLen) * tLen, (rodDy / rodLen) * tLen,
'tension', 'T=' + T_mag.toFixed(1) + 'Н');
}
}
/* ── 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');
/* MotionTrail overlay for both bobs */
if (this.showTCTrails) {
if (this._tcTrails.double1) this._tcTrails.double1.draw(ctx);
if (this._tcTrails.double2) this._tcTrails.double2.draw(ctx);
}
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);
}
/* FBD overlay for spring pendulum */
if (this._fbdOn && window.LSPhysFX) {
if (isVert) {
const ancX2 = W / 2;
const ancY2 = H * 0.15;
const eqY2 = ancY2 + sp.restLen * 300;
const bobY2 = eqY2 + sp.x * 300;
const mg2 = sp.m * this.g;
const Fsp2 = -sp.k * sp.x;
/* gravity down */
LSPhysFX.drawForceArrow(ctx, ancX2, bobY2, 0, 48, 'gravity',
'mg=' + mg2.toFixed(1) + 'Н');
/* spring force */
LSPhysFX.drawForceArrow(ctx, ancX2, bobY2, 0, -Math.sign(sp.x) * Math.min(55, Math.abs(Fsp2) * 2.5 + 10),
'elastic', 'F_упр=' + Math.abs(Fsp2).toFixed(1) + 'Н');
} else {
const ancX3 = W * 0.25;
const baseY3 = H * 0.5;
const eqX3 = ancX3 + sp.restLen * 300;
const bobX3 = eqX3 + sp.x * 300;
const Fsp3 = -sp.k * sp.x;
/* spring force horizontal */
LSPhysFX.drawForceArrow(ctx, bobX3, baseY3, -Math.sign(sp.x) * Math.min(55, Math.abs(Fsp3) * 2.5 + 10), 0,
'elastic', 'F_упр=' + Math.abs(Fsp3).toFixed(1) + 'Н');
}
}
}
_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 — unified via LSPhysFX.drawSpring when available
if (window.LSPhysFX) {
LSPhysFX.drawSpring(ctx, anchorX, anchorY, anchorX, springEndY, { coils: 10, amp: 8, color: '#FFD166' });
} else {
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 — unified via LSPhysFX.drawSpring when available
if (window.LSPhysFX) {
LSPhysFX.drawSpring(ctx, anchorX, baseY, bobX - 20, baseY, { coils: 10, amp: 8, color: '#FFD166' });
} else {
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;
_pendInjectTimeControlUI(pendSim);
}
pendSim.fit();
pendSim.setMode(pendSim.mode || 'math');
pendSim.play();
}));
}
function _pendInjectTimeControlUI(sim) {
if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
var wrap = document.getElementById('sim-pendulum');
if (!wrap || wrap.querySelector('.tc-bar')) return;
var tc = sim._tc;
/* Trail toggle button */
var trailBtn = document.createElement('button');
trailBtn.className = 'zoom-btn';
trailBtn.style.cssText = 'display:inline-flex;align-items:center;gap:3px;padding:3px 8px;border:1px solid rgba(255,255,255,0.15);border-radius:7px;background:rgba(28,18,48,0.8);color:#ccc;font-size:.68rem;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;font-family:Manrope,sans-serif';
trailBtn.innerHTML = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><path d="M3 12 Q6 3 12 12 Q18 21 21 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Следы';
trailBtn.title = 'Включить следы движения';
trailBtn.addEventListener('click', function() {
sim.showTCTrails = !sim.showTCTrails;
if (!sim.showTCTrails) {
Object.values(sim._tcTrails).forEach(function(t) { if (t) t.clear(); });
}
trailBtn.style.background = sim.showTCTrails ? 'rgba(6,214,224,0.2)' : 'rgba(28,18,48,0.8)';
trailBtn.style.color = sim.showTCTrails ? '#06D6E0' : '#ccc';
trailBtn.style.borderColor = sim.showTCTrails ? 'rgba(6,214,224,0.5)' : 'rgba(255,255,255,0.15)';
});
var tcBar = window.LSBuildTimeControlUI(sim, tc, { scrubSupported: true });
/* Append trail toggle */
var sep = document.createElement('div');
sep.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
tcBar.appendChild(sep);
tcBar.appendChild(trailBtn);
var statsBar = wrap.querySelector('.proj-stats-bar');
if (statsBar) wrap.insertBefore(tcBar, statsBar);
else wrap.appendChild(tcBar);
}
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 pendToggleGraphs() {
if (!pendSim) return;
const canvasOuter = document.querySelector('#sim-pendulum .proj-canvas-outer');
if (!canvasOuter) return;
const on = pendSim.toggleGraphs(canvasOuter);
const btn = document.getElementById('btn-pend-graphs');
if (btn) btn.classList.toggle('active', on);
}
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);
}
/* ── Energy toggle: pendulum ── */
function pendToggleEnergy() {
if (!pendSim) return;
pendSim._energyOn = !pendSim._energyOn;
const on = pendSim._energyOn;
const btn = document.getElementById('pend-energy-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
}
if (!on) { pendSim._frictionWork = 0; pendSim._energyScale = 0; }
pendSim.draw();
}