'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();
}
