'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 = ' Следы'; 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(); }