'use strict';

/* ═══════════════════════════════════════════════════════════════════
   ProjectileSim v4 — physics simulation
   Features: air drag (RK4) · wind · bounce · speed multiplier
             ghost trail comparison · velocity vector labels
             range arrow · landing angle · canvas click play/pause
             target challenge mode · x/y/vx/vy graphs · dual throw
             parachute physics · ramp launch · multi-planet gravity
   ═══════════════════════════════════════════════════════════════════ */

class ProjectileSim {
  constructor(canvas) {
    this.c   = canvas;
    this.ctx = canvas.getContext('2d');

    /* ── physics params ── */
    this.v0    = 20;
    this.angle = 45;
    this.h0    = 2;
    this.g     = 9.81;

    /* air resistance */
    this.drag = false;
    this.Cd   = 0.3;
    this.mass = 1;   // kg

    /* wind (m/s, positive = tailwind / right) */
    this.wind = 0;

    /* bounce */
    this.bounce      = false;
    this.restitution = 0.7;

    /* animation speed multiplier */
    this.speed = 1;

    /* computed trajectory (null = use analytical) */
    this._path   = null;   // [{x, y, vx, vy, t}]
    this._pathTf = 0;

    /* ── Energy bars widget ── */
    this._energyOn   = false;   // toggle state
    this._frictionWork = 0;     // cumulative J lost to drag
    this._energyScale  = 0;     // max observed total (for stable scale)

    /* animation state */
    this.t        = 0;
    this.playing  = false;
    this._raf     = null;
    this._lastTs  = null;

    /* visual effects */
    this._trail       = [];
    this._sparks      = [];
    this._impactTs    = -999;
    this._launchFlash = 0;
    this._stars       = this._genStars(90);

    /* ghost trails for comparison */
    this._ghosts   = [];
    this._ghostIdx = 0;
    this._GHOST_COLORS = [
      'rgba(255,214,102,.45)',
      'rgba(6,214,224,.45)',
      'rgba(123,245,164,.45)',
      'rgba(255,140,66,.45)',
    ];

    this.onUpdate    = null;
    this.onPlayPause = null;  // called by canvas click

    /* hover inspector */
    this._hover      = null;   // { t, s } | null
    this._viewParams = null;   // coordinate transform params (set in draw)

    /* ── Feature 1: target challenge mode ── */
    this.targetMode     = false;
    this._targets       = [];   // [{x,y,w,h,hit,flashTs}]
    this._targetAttempts = 0;
    this.onTargetUpdate = null; // callback → ({hits, total, attempts})

    /* ── Feature 2: graphs panel ── */
    this._graphsCanvas  = null; // set by attachGraphsCanvas()
    this._graphsVisible = false;

    /* ── Feature 3: dual throw ── */
    this.dualMode   = false;
    this._p2 = {               // second projectile params + live state
      v0: 25, angle: 30, h0: 0,
      path: null, pathTf: 0,
      t: 0, trail: [],
    };

    /* ── Feature 4: parachute ── */
    this.parachute       = false;   // parachute mode on/off
    this.chuteArea       = 1.0;     // A m² cross-section
    this.chuteCd         = 1.5;     // drag coefficient (preset: parachute)
    this.chuteOpenHeight = -1;      // -1 = immediate; >=0 = open at this altitude
    this._chuteOpen      = false;   // runtime: is chute deployed?
    this._chuteOpenedTs  = -999;    // perf.now when deployed
    this._chimeEmitted   = false;   // v_t chime fired once per run

    /* ── Feature 5: ramp launch ── */
    this.ramp        = false;   // ramp/slope mode on/off
    this.rampAngle   = 30;      // degrees
    this.rampLength  = 10;      // m
    this.rampMu      = 0.1;     // friction coefficient
    this._rampV0     = 0;       // computed launch speed from ramp

    /* ── Feature 6: planet gravity ── */
    // planets table: { name, g, rho } (rho = atmospheric density kg/m³)
    this.planets = [
      { id: 'earth',   name: 'Земля',    g: 9.81,  rho: 1.225  },
      { id: 'moon',    name: 'Луна',     g: 1.62,  rho: 0      },
      { id: 'mars',    name: 'Марс',     g: 3.71,  rho: 0.020  },
      { id: 'venus',   name: 'Венера',   g: 8.87,  rho: 65     },
      { id: 'jupiter', name: 'Юпитер',   g: 24.79, rho: 1.3    },
      { id: 'mercury', name: 'Меркурий', g: 3.7,   rho: 0      },
      { id: 'saturn',  name: 'Сатурн',   g: 10.44, rho: 0.19   },
      { id: 'uranus',  name: 'Уран',     g: 8.69,  rho: 0.42   },
      { id: 'neptune', name: 'Нептун',   g: 11.15, rho: 0.45   },
      { id: 'pluto',   name: 'Плутон',   g: 0.62,  rho: 0.0001 },
    ];
    this.planetId        = 'earth';   // active planet
    this.rho             = 1.225;     // air density (set by planet or override)

    /* ── Feature 6b: multi-planet compare ── */
    this.planetCompare   = false;     // show 3 planet trajectories simultaneously
    this.comparePlanets  = ['earth', 'moon', 'mars']; // which 3

    /* FBD toggle */
    this._fbdOn = false;

    /* ── TimeControl (speed-only; projectile manages its own time) ── */
    this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;

    canvas.addEventListener('click', () => {
      if (this.onPlayPause) this.onPlayPause();
    });
    canvas.addEventListener('mousemove', e => this._onMouseMove(e));
    canvas.addEventListener('mouseleave', () => this._onMouseLeave());

    new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
  }

  /* ── public API ── */

  fit() {
    const dpr = window.devicePixelRatio || 1;
    const r = this.c.parentElement.getBoundingClientRect();
    const w = r.width || 600, h = r.height || 400;
    this.c.width  = w * dpr;
    this.c.height = h * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this._cw = w; this._ch = h;
  }

  getParams() {
    return { v0: this.v0, angle: this.angle, h0: this.h0, g: this.g,
             drag: this.drag, Cd: this.Cd, mass: this.mass, wind: this.wind,
             bounce: this.bounce, restitution: this.restitution,
             parachute: this.parachute, chuteArea: this.chuteArea, chuteCd: this.chuteCd,
             chuteOpenHeight: this.chuteOpenHeight,
             ramp: this.ramp, rampAngle: this.rampAngle, rampLength: this.rampLength, rampMu: this.rampMu,
             planetId: this.planetId };
  }
  setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution,
              parachute, chuteArea, chuteCd, chuteOpenHeight,
              ramp, rampAngle, rampLength, rampMu,
              planetId } = {}) {
    if (v0              !== undefined) this.v0              = +v0;
    if (angle           !== undefined) this.angle           = +angle;
    if (h0              !== undefined) this.h0              = +h0;
    if (g               !== undefined) this.g               = +g;
    if (drag            !== undefined) this.drag            = !!drag;
    if (Cd              !== undefined) this.Cd              = +Cd;
    if (mass            !== undefined) this.mass            = Math.max(0.1, +mass);
    if (wind            !== undefined) this.wind            = +wind;
    if (bounce          !== undefined) this.bounce          = !!bounce;
    if (restitution     !== undefined) this.restitution     = Math.max(0, Math.min(1, +restitution));
    if (parachute       !== undefined) this.parachute       = !!parachute;
    if (chuteArea       !== undefined) this.chuteArea       = Math.max(0.1, +chuteArea);
    if (chuteCd         !== undefined) this.chuteCd         = +chuteCd;
    if (chuteOpenHeight !== undefined) this.chuteOpenHeight = +chuteOpenHeight;
    if (ramp            !== undefined) this.ramp            = !!ramp;
    if (rampAngle       !== undefined) this.rampAngle       = Math.max(1, Math.min(89, +rampAngle));
    if (rampLength      !== undefined) this.rampLength      = Math.max(1, +rampLength);
    if (rampMu          !== undefined) this.rampMu          = Math.max(0, Math.min(1, +rampMu));
    if (planetId        !== undefined) {
      this.planetId = planetId;
      const pl = this.planets.find(p => p.id === planetId);
      if (pl) {
        this.g   = pl.g;
        this.rho = pl.rho;
      }
    }
    this._computePath();
    if (this.dualMode) this._computeP2Path();
    this._resetFX();
    this.draw();
    this._emit();
  }

  setSpeed(s) { this.speed = +s; }

  play() {
    if (this.playing) return;
    if (this._pathTf > 0 && this.t >= this._pathTf) this._resetFX();
    this._launchFlash   = 1;
    this._chuteOpen     = this.parachute && this.chuteOpenHeight < 0;
    this._chuteOpenedTs = this._chuteOpen ? performance.now() : -999;
    this._chimeEmitted  = false;
    this.playing  = true;
    this._lastTs  = null;
    /* reset p2 at launch so both start simultaneously */
    if (this.dualMode) { this._p2.t = 0; this._p2.trail = []; }
    /* LabFX: launch effects */
    if (window.LabFX) {
      const _vp = this._viewParams;
      const _H  = _vp ? _vp.H : (this._ch || this.c.height);
      const _PL = _vp ? _vp.PL : 54, _PB = _vp ? _vp.PB : 44;
      const launchX = _vp ? _PL : 54;
      const launchY = _vp ? _H - _PB - (this.h0 || 0) * (_H - _PB - (_vp.PT || 26)) / _vp.yMax : _H - 44;
      LabFX.sound.play('whoosh');
      LabFX.particles.emit({
        ctx: this.ctx, x: launchX, y: launchY,
        count: 18, color: '#FFD166', speed: 120,
        spread: Math.PI / 3, angle: -Math.PI / 3,
        life: 500, glow: true, shape: 'spark',
      });
    }
    this._tick();
  }

  pause() {
    this.playing = false;
    if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
  }

  reset() {
    this.pause();
    this._resetFX();
    this.draw();
    this._emit();
  }

  /* ghost trails */
  saveGhost() {
    if (this._pathTf <= 0) return;
    const points = [];
    if (this._path) {
      for (const p of this._path) points.push({ x: p.x, y: p.y });
    } else {
      const tf = this._pathTf;
      for (let i = 0; i <= 200; i++) {
        const s = this._stateAnalytical((i / 200) * tf);
        points.push({ x: s.x, y: s.y });
      }
    }
    const st = this.stats();
    const windStr = this.wind !== 0 ? ` ветер ${this.wind > 0 ? '+' : ''}${this.wind}` : '';
    const label = `${this.angle}° ${this.v0}м/с${windStr}${this.drag ? ' +drag' : ''}${this.bounce ? ' ↩' : ''}`;
    const color = this._GHOST_COLORS[this._ghostIdx % this._GHOST_COLORS.length];
    this._ghostIdx++;
    this._ghosts.push({ points, color, label, range: st.range, hMax: st.hMax });
    if (this._ghosts.length > 4) this._ghosts.shift();
    this.draw();
  }

  clearGhosts() {
    this._ghosts = [];
    this._ghostIdx = 0;
    this.draw();
  }

  /* ── Feature 1: target mode ── */

  toggleTargetMode() {
    this.targetMode = !this.targetMode;
    if (this.targetMode && this._targets.length === 0) this.genTargets();
    this._emitTargets();
    this.draw();
    return this.targetMode;
  }

  genTargets() {
    const st = this.stats();
    const range   = Math.max(st.range, 10);
    const hMax    = Math.max(st.hMax,  5);
    const count   = 3;
    this._targets = [];
    for (let i = 0; i < count; i++) {
      const tw = 1.0 + Math.random() * 1.5;   // window width  1–2.5 m
      const th = 1.0 + Math.random() * 1.5;   // window height 1–2.5 m
      /* spread windows across [10%, 90%] of range so they're reachable */
      const x = range * (0.1 + 0.8 * (i + Math.random() * 0.5) / count);
      const y = 1.0 + Math.random() * Math.max(1, hMax * 0.7);
      this._targets.push({ x, y, w: tw, h: th, hit: false, flashTs: -999 });
    }
    this._targetAttempts = 0;
    this._emitTargets();
    this.draw();
  }

  _checkTargetHits(prevT, nextT) {
    if (!this.targetMode || this._targets.length === 0) return;
    /* sample a few sub-steps between prevT and nextT for precision */
    const steps = 8;
    for (let s = 0; s <= steps; s++) {
      const t  = prevT + (nextT - prevT) * (s / steps);
      const st = this._curState(t);
      if (st.y < 0) break;
      for (const tgt of this._targets) {
        if (tgt.hit) continue;
        if (st.x >= tgt.x && st.x <= tgt.x + tgt.w &&
            st.y >= tgt.y && st.y <= tgt.y + tgt.h) {
          tgt.hit    = true;
          tgt.flashTs = performance.now();
          this._emitTargets();
          /* LabFX: target hit effects */
          if (window.LabFX) {
            const hx = this.tx ? this.tx(st.x) : st.x;
            const hy = this.ty ? this.ty(st.y) : st.y;
            LabFX.sound.play('chime');
            LabFX.particles.emit({
              ctx: this.ctx, x: hx, y: hy,
              count: 40, color: ['#FFD700', '#FFA500', '#FF6B35'],
              speed: 140, spread: Math.PI * 2, life: 900,
              glow: true, shape: 'spark',
            });
            LabFX.haptic([15, 30, 15]);
          }
        }
      }
    }
  }

  _emitTargets() {
    if (!this.onTargetUpdate) return;
    const hits = this._targets.filter(t => t.hit).length;
    this.onTargetUpdate({ hits, total: this._targets.length, attempts: this._targetAttempts });
  }

  _drawTargets(ctx, tpx, tpy) {
    if (!this.targetMode) return;
    const now = performance.now();
    for (const tgt of this._targets) {
      const cx  = tpx(tgt.x);
      const cy  = tpy(tgt.y + tgt.h);  // top edge in canvas coords
      const cw  = tpx(tgt.x + tgt.w) - tpx(tgt.x);
      const ch  = tpy(tgt.y) - tpy(tgt.y + tgt.h);  // positive height

      const flashAge = (now - tgt.flashTs) / 1000;
      const flashing = flashAge < 1.2;

      if (tgt.hit) {
        /* gold fill on hit */
        const alpha = flashing ? 0.25 + 0.2 * Math.sin(flashAge * 18) : 0.18;
        ctx.fillStyle = `rgba(255,214,102,${alpha})`;
        ctx.fillRect(cx, cy, cw, ch);
        ctx.strokeStyle = flashing
          ? `rgba(255,214,102,${0.7 + 0.3 * Math.sin(flashAge * 18)})`
          : 'rgba(255,214,102,.6)';
        ctx.lineWidth = 2.5;
        ctx.strokeRect(cx, cy, cw, ch);

        /* checkmark */
        const mx = cx + cw / 2, my = cy + ch / 2;
        ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2.5;
        ctx.beginPath();
        ctx.moveTo(mx - cw * 0.22, my);
        ctx.lineTo(mx - cw * 0.05, my + ch * 0.22);
        ctx.lineTo(mx + cw * 0.28, my - ch * 0.28);
        ctx.stroke();
      } else {
        /* inactive window: translucent blue rect with dashed border */
        ctx.fillStyle = 'rgba(6,214,224,.06)';
        ctx.fillRect(cx, cy, cw, ch);
        ctx.strokeStyle = 'rgba(6,214,224,.5)';
        ctx.lineWidth = 1.5;
        ctx.setLineDash([5, 3]);
        ctx.strokeRect(cx, cy, cw, ch);
        ctx.setLineDash([]);

        /* small cross in top-right corner to look like a window frame */
        ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(cx + cw / 2, cy); ctx.lineTo(cx + cw / 2, cy + ch);
        ctx.moveTo(cx, cy + ch / 2); ctx.lineTo(cx + cw, cy + ch / 2);
        ctx.stroke();
      }
    }
  }

  /* ── Feature 2: graphs canvas attachment ── */

  attachGraphsCanvas(canvas) {
    this._graphsCanvas = canvas;
  }

  drawGraphs() {
    const gc = this._graphsCanvas;
    if (!gc || !this._graphsVisible) return;

    const dpr = window.devicePixelRatio || 1;
    const W   = gc.clientWidth  || gc.width  / dpr;
    const H   = gc.clientHeight || gc.height / dpr;
    if (!W || !H) return;

    /* keep physical pixel size in sync */
    if (gc.width !== Math.round(W * dpr) || gc.height !== Math.round(H * dpr)) {
      gc.width  = Math.round(W * dpr);
      gc.height = Math.round(H * dpr);
    }
    const gctx = gc.getContext('2d');
    gctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    gctx.clearRect(0, 0, W, H);

    const tf = this._curTFlight();
    if (tf <= 0) {
      gctx.fillStyle = 'rgba(255,255,255,.2)';
      gctx.font = '11px Manrope, sans-serif';
      gctx.textAlign = 'center'; gctx.textBaseline = 'middle';
      gctx.fillText('Запустите симуляцию', W / 2, H / 2);
      return;
    }

    /* collect full trajectory data for plotting */
    const N = 200;
    const pts = [];
    for (let i = 0; i <= N; i++) {
      const t = (i / N) * tf;
      const s = this._curState(t);
      pts.push({ t, x: s.x, y: Math.max(0, s.y), vx: s.vx, vy: s.vy });
    }

    const plots = [
      { key: 'x',  label: 'x(t)', unit: 'м',    color: '#06D6E0' },
      { key: 'y',  label: 'y(t)', unit: 'м',    color: '#7BF5A4' },
      { key: 'vx', label: 'vx(t)', unit: 'м/с', color: '#9B5DE5' },
      { key: 'vy', label: 'vy(t)', unit: 'м/с', color: '#F15BB5' },
    ];

    const cols = 2, rows = 2;
    const PL = 36, PR = 10, PT = 20, PB = 22;
    const cw  = W / cols, ch = H / rows;
    const pw  = cw - PL - PR, ph = ch - PT - PB;

    /* current time marker fraction */
    const curFrac = tf > 0 ? Math.min(1, this.t / tf) : 0;

    for (let pi = 0; pi < plots.length; pi++) {
      const col = pi % cols, row = Math.floor(pi / cols);
      const ox  = col * cw, oy = row * ch;
      const plot = plots[pi];

      const vals = pts.map(p => p[plot.key]);
      const vMin = Math.min(...vals), vMax = Math.max(...vals);
      const vRange = Math.max(vMax - vMin, 0.1);

      const tx = t => ox + PL + (t / tf) * pw;
      const ty = v => oy + PT + ph - ((v - vMin) / vRange) * ph;

      /* background */
      gctx.fillStyle = 'rgba(5,5,20,.85)';
      gctx.fillRect(ox + PL, oy + PT, pw, ph);

      /* grid lines */
      gctx.strokeStyle = 'rgba(255,255,255,.05)'; gctx.lineWidth = 1;
      for (let gi = 1; gi < 4; gi++) {
        const gv = vMin + (gi / 4) * vRange;
        const gy = ty(gv);
        gctx.beginPath(); gctx.moveTo(ox + PL, gy); gctx.lineTo(ox + PL + pw, gy); gctx.stroke();
      }

      /* axes */
      gctx.strokeStyle = 'rgba(255,255,255,.25)'; gctx.lineWidth = 1.2;
      gctx.beginPath();
      gctx.moveTo(ox + PL, oy + PT); gctx.lineTo(ox + PL, oy + PT + ph);
      gctx.lineTo(ox + PL + pw, oy + PT + ph);
      gctx.stroke();

      /* axis labels */
      gctx.font = '9px Manrope, sans-serif';
      gctx.fillStyle = 'rgba(255,255,255,.35)';
      gctx.textAlign = 'right'; gctx.textBaseline = 'middle';
      gctx.fillText(_projFmt(vMax) + ' ' + plot.unit, ox + PL - 3, oy + PT + 4);
      gctx.fillText(_projFmt(vMin) + ' ' + plot.unit, ox + PL - 3, oy + PT + ph - 2);
      gctx.textAlign = 'center'; gctx.textBaseline = 'top';
      gctx.fillText(_projFmt(tf) + ' с', ox + PL + pw, oy + PT + ph + 4);

      /* data line */
      gctx.strokeStyle = plot.color; gctx.lineWidth = 2;
      gctx.beginPath();
      for (let i = 0; i < pts.length; i++) {
        const px = tx(pts[i].t), py = ty(pts[i][plot.key]);
        i === 0 ? gctx.moveTo(px, py) : gctx.lineTo(px, py);
      }
      gctx.stroke();

      /* second projectile overlay */
      if (this.dualMode && this._p2.pathTf > 0) {
        const tf2 = this._p2.pathTf;
        const pts2 = [];
        for (let i = 0; i <= N; i++) {
          const t2 = (i / N) * tf2;
          const s2 = this._p2.path ? this._p2PathStateAt(t2) : this._p2StateAnalytical(t2);
          pts2.push({ t: t2, x: s2.x, y: Math.max(0, s2.y), vx: s2.vx, vy: s2.vy });
        }
        gctx.strokeStyle = 'rgba(0,230,255,.55)'; gctx.lineWidth = 1.5;
        gctx.setLineDash([4, 3]);
        gctx.beginPath();
        for (let i = 0; i < pts2.length; i++) {
          const px = tx(pts2[i].t), py = ty(pts2[i][plot.key]);
          i === 0 ? gctx.moveTo(px, py) : gctx.lineTo(px, py);
        }
        gctx.stroke(); gctx.setLineDash([]);
      }

      /* current time indicator */
      if (curFrac > 0) {
        const curX = tx(this.t);
        gctx.strokeStyle = 'rgba(255,214,102,.7)'; gctx.lineWidth = 1.5;
        gctx.setLineDash([3, 3]);
        gctx.beginPath(); gctx.moveTo(curX, oy + PT); gctx.lineTo(curX, oy + PT + ph); gctx.stroke();
        gctx.setLineDash([]);

        /* dot on the line */
        const curV = this._curState(this.t)[plot.key];
        const curVclamped = Math.min(vMax, Math.max(vMin, curV));
        gctx.fillStyle = '#FFD166';
        gctx.beginPath(); gctx.arc(curX, ty(curVclamped), 3.5, 0, Math.PI * 2); gctx.fill();
      }

      /* label */
      gctx.font = 'bold 11px Manrope, sans-serif';
      gctx.fillStyle = plot.color;
      gctx.textAlign = 'left'; gctx.textBaseline = 'top';
      gctx.fillText(plot.label, ox + PL + 5, oy + PT + 4);
    }
  }

  /* ── Feature 3: second projectile helpers ── */

  _p2StateAnalytical(t) {
    const p2  = this._p2;
    const rad = p2.angle * Math.PI / 180;
    const vx  = p2.v0 * Math.cos(rad);
    const vy0 = p2.v0 * Math.sin(rad);
    return {
      x:  vx * t,
      y:  p2.h0 + vy0 * t - 0.5 * this.g * t * t,
      vx,
      vy: vy0 - this.g * t,
    };
  }

  _p2PathStateAt(t) {
    const path = this._p2.path;
    if (!path || path.length < 2) return { x: 0, y: this._p2.h0, vx: 0, vy: 0 };
    if (t <= 0) return path[0];
    if (t >= this._p2.pathTf) return path[path.length - 1];
    let lo = 0, hi = path.length - 1;
    while (lo < hi - 1) {
      const mid = (lo + hi) >> 1;
      if (path[mid].t <= t) lo = mid; else hi = mid;
    }
    const a = path[lo], b = path[hi];
    const frac = (t - a.t) / (b.t - a.t);
    return {
      x:  a.x  + (b.x  - a.x)  * frac,
      y:  a.y  + (b.y  - a.y)  * frac,
      vx: a.vx + (b.vx - a.vx) * frac,
      vy: a.vy + (b.vy - a.vy) * frac,
    };
  }

  _p2CurState(t) {
    return this._p2.path ? this._p2PathStateAt(t) : this._p2StateAnalytical(t);
  }

  /* recompute second projectile path using same RK4 if drag/wind/bounce active */
  _computeP2Path() {
    const p2 = this._p2;
    if (!this._needsNumerical()) {
      const rad  = p2.angle * Math.PI / 180;
      const vy0  = p2.v0 * Math.sin(rad);
      const disc = vy0 * vy0 + 2 * this.g * p2.h0;
      p2.path   = null;
      p2.pathTf = disc < 0 ? 0 : Math.max(0, (vy0 + Math.sqrt(disc)) / this.g);
      return;
    }

    const rho = this.rho, A = 0.00785;
    const k   = this.drag ? 0.5 * this.Cd * rho * A / Math.max(0.1, this.mass) : 0;
    const g   = this.g, W = this.wind, e = this.restitution;
    const maxBounces = this.bounce ? 7 : 0;
    const rad = p2.angle * Math.PI / 180;
    let x = 0, y = p2.h0;
    let vx = p2.v0 * Math.cos(rad), vy = p2.v0 * Math.sin(rad);
    const dt   = 0.005;
    const path = [{ x, y, vx, vy, t: 0 }];
    let bounces = 0;

    const deriv = (sx, sy, svx, svy) => {
      const rvx   = svx - W;
      const speed = Math.sqrt(rvx * rvx + svy * svy);
      const dragF = speed > 0 ? k * speed : 0;
      const wAcc  = (!this.drag && W !== 0) ? W * 0.05 : 0;
      return { dx: svx, dy: svy, dvx: -dragF * rvx + wAcc, dvy: -g - dragF * svy };
    };

    for (let step = 0; step < 200000; step++) {
      const k1 = deriv(x, y, vx, vy);
      const k2 = deriv(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2);
      const k3 = deriv(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2);
      const k4 = deriv(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt);
      x  += (k1.dx  + 2 * k2.dx  + 2 * k3.dx  + k4.dx)  * dt / 6;
      y  += (k1.dy  + 2 * k2.dy  + 2 * k3.dy  + k4.dy)  * dt / 6;
      vx += (k1.dvx + 2 * k2.dvx + 2 * k3.dvx + k4.dvx) * dt / 6;
      vy += (k1.dvy + 2 * k2.dvy + 2 * k3.dvy + k4.dvy) * dt / 6;
      const t2 = (step + 1) * dt;
      if (y <= 0) {
        const prev = path[path.length - 1];
        if (prev && prev.y > 0) {
          const frac = prev.y / (prev.y - y);
          const lx   = prev.x + (x - prev.x) * frac;
          const lvx  = prev.vx + (vx - prev.vx) * frac;
          const lvy  = prev.vy + (vy - prev.vy) * frac;
          const lt   = prev.t  + dt * frac;
          path.push({ x: lx, y: 0, vx: lvx, vy: lvy, t: lt });
          if (this.bounce && bounces < maxBounces && Math.abs(lvy) > 0.4) {
            vy = -e * lvy; vx = lvx * 0.96; y = 0.001; x = lx;
            bounces++;
            continue;
          }
        }
        break;
      }
      path.push({ x, y, vx, vy, t: t2 });
    }
    p2.path   = path;
    p2.pathTf = path[path.length - 1].t;
  }

  /* ── physics ── */

  /* pure analytical solution (no drag/wind/bounce) */
  _stateAnalytical(t) {
    const launch = this._effectiveLaunch();
    const rad = launch.angle * Math.PI / 180;
    const vx  = launch.v0 * Math.cos(rad);
    const vy0 = launch.v0 * Math.sin(rad);
    return {
      x:  vx * t,
      y:  launch.h0 + vy0 * t - 0.5 * this.g * t * t,
      vx,
      vy: vy0 - this.g * t,
    };
  }

  /* analytical flight time (for reference / no-effect comparison) */
  _tFlightAnalytical() {
    const launch = this._effectiveLaunch();
    const rad  = launch.angle * Math.PI / 180;
    const vy0  = launch.v0 * Math.sin(rad);
    const disc = vy0 * vy0 + 2 * this.g * launch.h0;
    if (disc < 0) return 0;
    return Math.max(0, (vy0 + Math.sqrt(disc)) / this.g);
  }

  _needsNumerical() {
    return this.drag || this.parachute || this.wind !== 0 || this.bounce || this.ramp;
  }

  /* compute launch speed from ramp: v = sqrt(2·g·L·sinα·(1-μ·cosα/sinα))
     v = sqrt(2·g·L·(sinα - μ·cosα))  assuming μ < tanα else no motion */
  _rampComputeV0() {
    const a   = this.rampAngle * Math.PI / 180;
    const sin = Math.sin(a), cos = Math.cos(a);
    const net = sin - this.rampMu * cos;
    if (net <= 0) return 0;
    return Math.sqrt(2 * this.g * this.rampLength * net);
  }

  /* effective launch angle = ramp angle when ramp is active */
  _effectiveLaunch() {
    if (this.ramp) {
      const v = this._rampComputeV0();
      return { v0: v, angle: this.rampAngle, h0: this.h0 };
    }
    return { v0: this.v0, angle: this.angle, h0: this.h0 };
  }

  /* terminal velocity for current parachute config */
  _terminalVelocity() {
    return Math.sqrt(2 * this.mass * this.g / (this.chuteCd * this.rho * this.chuteArea));
  }

  /* RK4 integration — handles drag, parachute, wind, bounce, ramp */
  _computePath() {
    if (!this._needsNumerical()) {
      this._path   = null;
      this._pathTf = this._tFlightAnalytical();
      return;
    }

    const rho   = this.rho;          // air density (planet-aware)
    const A_ball = 0.00785;          // small ball cross-section m²
    const g      = this.g;
    const W      = this.wind;
    const e      = this.restitution;
    const maxBounces = this.bounce ? 7 : 0;
    const mass   = Math.max(0.1, this.mass);

    /* simple-drag k factor (ball drag, legacy mode) */
    const kBall = this.drag && !this.parachute
      ? 0.5 * this.Cd * rho * A_ball / mass
      : 0;

    /* parachute: open immediately if chuteOpenHeight < 0, else on altitude trigger */
    const chuteAutoOpen  = this.parachute && this.chuteOpenHeight < 0;
    const chuteThreshold = this.parachute ? Math.max(0, this.chuteOpenHeight) : Infinity;

    const launch = this._effectiveLaunch();
    const rad    = launch.angle * Math.PI / 180;
    let x = 0, y = launch.h0;
    let vx = launch.v0 * Math.cos(rad);
    let vy = launch.v0 * Math.sin(rad);
    let chuteOpen = chuteAutoOpen;
    const dt = 0.005;
    const path = [{ x, y, vx, vy, t: 0, chuteOpen }];
    let bounceCount = 0;

    const deriv = (sx, sy, svx, svy, chute) => {
      const rvx   = svx - W;
      const rvy   = svy;
      const speed = Math.sqrt(rvx * rvx + rvy * rvy);
      let dragF   = 0;
      if (chute) {
        /* parachute: F_d = 0.5 * Cd * rho * A * v² / m → acceleration */
        dragF = speed > 0
          ? 0.5 * this.chuteCd * rho * this.chuteArea * speed / mass
          : 0;
      } else if (kBall > 0) {
        dragF = speed > 0 ? kBall * speed : 0;
      }
      const windAcc = (!this.drag && !chute && W !== 0) ? W * 0.05 : 0;
      return {
        dx:  svx,
        dy:  svy,
        dvx: -dragF * rvx + windAcc,
        dvy: -g - dragF * rvy,
      };
    };

    for (let step = 0; step < 200000; step++) {
      /* check if chute should open by altitude trigger */
      if (this.parachute && !chuteOpen && y <= chuteThreshold && y > 0) {
        chuteOpen = true;
      }

      const k1 = deriv(x, y, vx, vy, chuteOpen);
      const k2 = deriv(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2, chuteOpen);
      const k3 = deriv(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2, chuteOpen);
      const k4 = deriv(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt, chuteOpen);

      x  += (k1.dx  + 2 * k2.dx  + 2 * k3.dx  + k4.dx)  * dt / 6;
      y  += (k1.dy  + 2 * k2.dy  + 2 * k3.dy  + k4.dy)  * dt / 6;
      vx += (k1.dvx + 2 * k2.dvx + 2 * k3.dvx + k4.dvx) * dt / 6;
      vy += (k1.dvy + 2 * k2.dvy + 2 * k3.dvy + k4.dvy) * dt / 6;
      const t = (step + 1) * dt;

      if (y <= 0) {
        const prev = path[path.length - 1];
        if (prev && prev.y > 0) {
          const frac = prev.y / (prev.y - y);
          const lx  = prev.x  + (x  - prev.x)  * frac;
          const lvx = prev.vx + (vx - prev.vx) * frac;
          const lvy = prev.vy + (vy - prev.vy) * frac;
          const lt  = prev.t  + dt * frac;
          path.push({ x: lx, y: 0, vx: lvx, vy: lvy, t: lt, chuteOpen });

          if (this.bounce && bounceCount < maxBounces && Math.abs(lvy) > 0.4) {
            vy = -e * lvy;
            vx = lvx * (1 - 0.04);
            y  = 0.001;
            x  = lx;
            bounceCount++;
            continue;
          }
        }
        break;
      }

      path.push({ x, y, vx, vy, t, chuteOpen });
    }

    this._path   = path;
    this._pathTf = path[path.length - 1].t;
  }

  /* compute a trajectory for a given planet (for compare mode) */
  _computePlanetPath(planetId) {
    const pl = this.planets.find(p => p.id === planetId) || this.planets[0];
    const rho    = pl.rho;
    const g      = pl.g;
    const W      = this.wind;
    const mass   = Math.max(0.1, this.mass);
    const A_ball = 0.00785;
    const kBall  = this.drag ? 0.5 * this.Cd * rho * A_ball / mass : 0;

    const rad = this.angle * Math.PI / 180;
    let x = 0, y = this.h0;
    let vx = this.v0 * Math.cos(rad);
    let vy = this.v0 * Math.sin(rad);
    const dt = 0.005;
    const path = [{ x, y, vx, vy, t: 0 }];

    const deriv2 = (sx, sy, svx, svy) => {
      const rvx   = svx - W;
      const rvy   = svy;
      const speed = Math.sqrt(rvx * rvx + rvy * rvy);
      const dragF = speed > 0 ? kBall * speed : 0;
      const windAcc = (!this.drag && W !== 0) ? W * 0.05 : 0;
      return {
        dx: svx, dy: svy,
        dvx: -dragF * rvx + windAcc,
        dvy: -g - dragF * rvy,
      };
    };

    for (let step = 0; step < 100000; step++) {
      const k1 = deriv2(x, y, vx, vy);
      const k2 = deriv2(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2);
      const k3 = deriv2(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2);
      const k4 = deriv2(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt);
      x  += (k1.dx  + 2 * k2.dx  + 2 * k3.dx  + k4.dx)  * dt / 6;
      y  += (k1.dy  + 2 * k2.dy  + 2 * k3.dy  + k4.dy)  * dt / 6;
      vx += (k1.dvx + 2 * k2.dvx + 2 * k3.dvx + k4.dvx) * dt / 6;
      vy += (k1.dvy + 2 * k2.dvy + 2 * k3.dvy + k4.dvy) * dt / 6;
      const t = (step + 1) * dt;
      if (y <= 0) {
        const prev = path[path.length - 1];
        if (prev && prev.y > 0) {
          const frac = prev.y / (prev.y - y);
          path.push({
            x: prev.x + (x - prev.x) * frac,
            y: 0,
            vx: prev.vx + (vx - prev.vx) * frac,
            vy: prev.vy + (vy - prev.vy) * frac,
            t: prev.t + dt * frac,
          });
        }
        break;
      }
      path.push({ x, y, vx, vy, t });
    }
    return path;
  }

  _pathStateAt(t) {
    const path = this._path;
    if (!path || path.length < 2) return { x: 0, y: this.h0, vx: 0, vy: 0 };
    if (t <= 0) return path[0];
    if (t >= this._pathTf) return path[path.length - 1];

    let lo = 0, hi = path.length - 1;
    while (lo < hi - 1) {
      const mid = (lo + hi) >> 1;
      if (path[mid].t <= t) lo = mid; else hi = mid;
    }
    const a = path[lo], b = path[hi];
    const frac = (t - a.t) / (b.t - a.t);
    return {
      x:  a.x  + (b.x  - a.x)  * frac,
      y:  a.y  + (b.y  - a.y)  * frac,
      vx: a.vx + (b.vx - a.vx) * frac,
      vy: a.vy + (b.vy - a.vy) * frac,
    };
  }

  _curState(t) {
    return this._path ? this._pathStateAt(t) : this._stateAnalytical(t);
  }

  _curTFlight() { return this._pathTf; }

  stats() {
    const tf  = this._pathTf;
    const end = this._curState(tf);

    let hMax = this.h0;
    if (this._path) {
      for (const p of this._path) if (p.y > hMax) hMax = p.y;
    } else {
      const rad = this.angle * Math.PI / 180;
      const vy0 = this.v0 * Math.sin(rad);
      const tMax = Math.max(0, vy0 / this.g);
      hMax = Math.max(this.h0, this.h0 + vy0 * tMax - 0.5 * this.g * tMax * tMax);
    }

    const range     = Math.max(0, end.x);
    const vLand     = Math.sqrt(end.vx ** 2 + end.vy ** 2);
    const landAngle = vLand > 0.01
      ? Math.abs(Math.atan2(Math.abs(end.vy), Math.abs(end.vx)) * 180 / Math.PI)
      : 0;

    // range compared to pure analytical (no drag/wind/bounce)
    let rangeLoss = 0;
    if (this._needsNumerical()) {
      const tfND = this._tFlightAnalytical();
      const endND = this._stateAnalytical(tfND);
      const rangeND = Math.max(0, endND.x);
      if (rangeND > 0.1) rangeLoss = Math.round((range / rangeND - 1) * 100);
    }

    return {
      tf, hMax, range, vLand, rangeLoss, landAngle,
      t:        this.t,
      progress: tf > 0 ? Math.min(1, this.t / tf) : 0,
      hasMod:   this._needsNumerical(),
    };
  }

  /* ── animation loop ── */

  _tick() {
    if (!this.playing) return;
    this._raf = requestAnimationFrame(ts => {
      if (!this.playing) return;
      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: if paused via TC, just redraw */
      if (this._tc && this._tc.paused) {
        this.draw(); this._emit();
        if (this.playing) this._tick();
        return;
      }

      this._launchFlash = Math.max(0, this._launchFlash - rawDt * 2.5);

      const prevT = this.t;
      const cur = this._curState(this.t);
      this._trail.push({ mx: cur.x, my: cur.y });
      if (this._trail.length > 80) this._trail.shift();

      /* energy: accumulate drag work ΔW = F_drag · v · dt (approx) */
      if (this._energyOn && (this.drag || this.parachute)) {
        const spd  = Math.sqrt(cur.vx * cur.vx + cur.vy * cur.vy);
        const rho  = this.rho;
        const mass = Math.max(0.1, this.mass);
        const A    = this._chuteOpen ? this.chuteArea : 0.00785;
        const Cd   = this._chuteOpen ? this.chuteCd   : this.Cd;
        const Fd   = 0.5 * Cd * rho * A * spd * spd;
        this._frictionWork += Fd * spd * rawDt * this.speed;
      }

      /* advance time; respect TC scale on top of existing speed multiplier */
      const tcScale = (this._tc && !this._tc.paused) ? this._tc.scale : 1;
      this.t += rawDt * this.speed * tcScale;
      const tf = this._curTFlight();
      if (this.t >= tf) {
        this.t    = tf;
        this.playing = false;
        this._triggerImpact();
        if (this.targetMode) this._targetAttempts++;
      }

      /* parachute: check altitude-triggered deployment */
      if (this.parachute && !this._chuteOpen && this.chuteOpenHeight >= 0) {
        const cs = this._curState(this.t);
        if (cs.y <= this.chuteOpenHeight && cs.y > 0) {
          this._chuteOpen     = true;
          this._chuteOpenedTs = performance.now();
          if (window.LabFX) {
            LabFX.sound.play('whoosh');
            const _vp = this._viewParams;
            if (_vp) {
              const scX = (_vp.W - _vp.PL - _vp.PR) / _vp.xMax;
              const scY = (_vp.H - _vp.PB - _vp.PT) / _vp.yMax;
              LabFX.particles.emit({
                ctx: this.ctx,
                x: _vp.PL + cs.x * scX, y: _vp.H - _vp.PB - cs.y * scY,
                count: 30, color: ['#06D6E0', '#FFD166'], speed: 90,
                spread: Math.PI, angle: -Math.PI / 2, life: 800, glow: true, shape: 'spark',
              });
            }
          }
        }
      }

      /* parachute: chime when ~90% terminal velocity reached */
      if (this.parachute && this._chuteOpen && !this._chimeEmitted) {
        const vt = this._terminalVelocity();
        const spd = Math.sqrt(cur.vx ** 2 + cur.vy ** 2);
        if (spd <= vt * 1.1) {
          this._chimeEmitted = true;
          if (window.LabFX) LabFX.sound.play('chime');
        }
      }

      /* target hit detection on this step interval */
      this._checkTargetHits(prevT, Math.min(this.t, tf));

      /* advance second projectile */
      if (this.dualMode) {
        const p2 = this._p2;
        const p2cur = this._p2CurState(p2.t);
        p2.trail.push({ mx: p2cur.x, my: p2cur.y });
        if (p2.trail.length > 80) p2.trail.shift();
        p2.t = Math.min(p2.t + rawDt * this.speed, p2.pathTf);
      }

      this.draw();
      this._emit();
      if (this._graphsVisible) this.drawGraphs();
      if (this.playing) this._tick();
    });
  }

  _triggerImpact() {
    const end = this._curState(this._curTFlight());
    this._impactTs = performance.now();
    this._sparks = Array.from({ length: 18 }, (_, i) => {
      const ang = (i / 18) * Math.PI * 2 + Math.random() * 0.3;
      const spd = 40 + Math.random() * 80;
      return { ang, spd, mx: end.x };
    });
    /* LabFX: landing effects */
    if (window.LabFX) {
      const _vp = this._viewParams;
      const _W = _vp ? _vp.W : (this._cw || this.c.width);
      const _H = _vp ? _vp.H : (this._ch || this.c.height);
      const _PL = _vp ? _vp.PL : 54, _PB = _vp ? _vp.PB : 44;
      const _scX = _vp ? (_W - _PL - (_vp.PR || 20)) / _vp.xMax : 1;
      const _scY = _vp ? (_H - _PB - (_vp.PT || 26)) / _vp.yMax : 1;
      const landX = _vp ? _PL + end.x * _scX : 54;
      const landY = _vp ? _H - _PB : _H - 44;
      LabFX.sound.play('bounce', { pitch: 0.6 });
      LabFX.particles.emit({
        ctx: this.ctx, x: landX, y: landY,
        count: 30, color: '#8B7355', speed: 80,
        spread: Math.PI, angle: -Math.PI / 2,
        gravity: 200, life: 1200, shape: 'splash',
      });
      LabFX.shake(this.c, { intensity: 4, durMs: 200 });
      LabFX.haptic(15);
    }
    this._tickFX();
  }

  _tickFX() {
    const elapsed = (performance.now() - this._impactTs) / 1000;
    if (elapsed < 1.8) {
      this.draw(); this._emit();
      requestAnimationFrame(() => this._tickFX());
    } else {
      this._sparks = [];
      this.draw(); this._emit();
    }
  }

  _resetFX() {
    this.t              = 0;
    this._trail         = [];
    this._sparks        = [];
    this._impactTs      = -999;
    this._launchFlash   = 0;
    this._chuteOpen     = this.parachute && this.chuteOpenHeight < 0;
    this._chuteOpenedTs = -999;
    this._chimeEmitted  = false;
    this._frictionWork  = 0;
    this._energyScale   = 0;
    this._computePath();
    if (this.dualMode) {
      this._p2.t     = 0;
      this._p2.trail = [];
      this._computeP2Path();
    }
    /* clear target hits so player can retry */
    for (const tgt of this._targets) tgt.hit = false;
  }

  _emit() { if (this.onUpdate) this.onUpdate(this.stats()); }

  /* ── stars ── */

  _genStars(n) {
    return Array.from({ length: n }, () => ({
      rx: Math.random(), ry: Math.random(),
      r:  Math.random() * 1.1 + 0.2,
      a:  Math.random() * 0.55 + 0.15,
    }));
  }

  /* ── main render ── */

  draw() {
    const W = this._cw || this.c.width, H = this._ch || this.c.height;
    if (!W || !H) return;
    const ctx = this.ctx;
    const tf  = this._curTFlight();
    const st  = this.stats();

    const PL = 54, PR = 20, PT = 26, PB = 44;
    const pw = W - PL - PR, ph = H - PT - PB;

    let maxRange = Math.max(st.range, 1);
    let maxH     = Math.max(st.hMax, 1);
    for (const gh of this._ghosts) {
      if (gh.range > maxRange) maxRange = gh.range;
      if (gh.hMax  > maxH)     maxH     = gh.hMax;
    }

    const xMax = maxRange * 1.15;
    const yMax = maxH * 1.35;
    const scX  = pw / xMax, scY = ph / yMax;
    const tpx  = mx => PL + mx * scX;
    const tpy  = my => H - PB - my * scY;
    const gy   = tpy(0);

    /* store for hover inspector */
    this._viewParams = { xMax, yMax, PL, PR, PT, PB, W, H };

    /* ── 1. Sky ── */
    const sky = ctx.createLinearGradient(0, 0, 0, gy);
    sky.addColorStop(0,   '#05050f');
    sky.addColorStop(0.6, '#0d0d2a');
    sky.addColorStop(1,   '#141430');
    ctx.fillStyle = sky;
    ctx.fillRect(0, 0, W, gy);

    /* ── 2. Stars ── */
    for (const s of this._stars) {
      const sx = PL + s.rx * pw, sy = PT + s.ry * (gy - PT - 10);
      ctx.fillStyle = `rgba(255,255,255,${s.a})`;
      ctx.beginPath(); ctx.arc(sx, sy, s.r, 0, Math.PI * 2); ctx.fill();
    }

    /* ── 2.5. Wind streaks ── */
    if (this.wind !== 0) {
      this._drawWind(ctx, PL, PT, pw, gy - PT);
      /* LabFX: wind dust particles */
      if (window.LabFX && this.playing) {
        const dir = this.wind > 0 ? 1 : -1;
        const dustCount = Math.floor(3 + Math.random() * 3);
        for (let _d = 0; _d < dustCount; _d++) {
          const dustX = dir > 0 ? PL : PL + pw;
          const dustY = PT + Math.random() * (gy - PT);
          LabFX.particles.emit({
            ctx, x: dustX, y: dustY,
            count: 1, color: 'rgba(255,255,255,0.3)',
            speed: 0, spread: 0, angle: 0,
            life: 1500, shape: 'dust', gravity: 0,
            _vx: this.wind * 5, _vy: -10,
          });
        }
      }
    }

    /* ── 3. Ground ── */
    const gnd = ctx.createLinearGradient(0, gy, 0, H - PB);
    gnd.addColorStop(0, 'rgba(22,101,52,.35)');
    gnd.addColorStop(1, 'rgba(15,23,42,.9)');
    ctx.fillStyle = gnd;
    ctx.fillRect(PL, gy, pw, H - PB - gy);

    const gl = ctx.createLinearGradient(PL, 0, PL + pw, 0);
    gl.addColorStop(0,    'rgba(34,197,94,.2)');
    gl.addColorStop(0.15, 'rgba(74,222,128,.7)');
    gl.addColorStop(1,    'rgba(34,197,94,.3)');
    ctx.strokeStyle = gl; ctx.lineWidth = 2.5;
    ctx.beginPath(); ctx.moveTo(PL, gy); ctx.lineTo(PL + pw, gy); ctx.stroke();

    /* ── 4. Margin fills ── */
    ctx.fillStyle = '#0A0A14';
    ctx.fillRect(0, H - PB, W, PB);
    ctx.fillRect(0, 0, PL, H);
    ctx.fillRect(W - PR, 0, PR, H);

    /* ── 5. Grid ── */
    const stX = _projNiceStep(xMax, 6), stY = _projNiceStep(yMax, 5);
    ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
    for (let x = stX; x < xMax; x += stX) {
      ctx.beginPath(); ctx.moveTo(tpx(x), PT); ctx.lineTo(tpx(x), H - PB); ctx.stroke();
    }
    for (let y = stY; y < yMax; y += stY) {
      ctx.beginPath(); ctx.moveTo(PL, tpy(y)); ctx.lineTo(W - PR, tpy(y)); ctx.stroke();
    }

    /* ── 6. Axes + labels ── */
    ctx.strokeStyle = 'rgba(255,255,255,.2)'; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.moveTo(PL, PT); ctx.lineTo(PL, H - PB); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(PL, gy); ctx.lineTo(W - PR, gy); ctx.stroke();

    ctx.font = '10px Manrope, sans-serif';
    ctx.fillStyle = 'rgba(255,255,255,.28)';
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    for (let x = stX; x < xMax * 0.97; x += stX)
      ctx.fillText(_projFmt(x) + ' м', tpx(x), gy + 7);
    ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
    for (let y = stY; y < yMax * 0.97; y += stY)
      ctx.fillText(_projFmt(y) + ' м', PL - 6, tpy(y));

    /* ── 6.4. Planet compare trajectories ── */
    if (this.planetCompare) {
      const PCOLORS = ['#06D6E0', '#7BF5A4', '#F15BB5'];
      for (let ci = 0; ci < this.comparePlanets.length; ci++) {
        const pid  = this.comparePlanets[ci];
        const pl   = this.planets.find(p => p.id === pid);
        if (!pl) continue;
        const ppath = this._computePlanetPath(pid);
        const col   = PCOLORS[ci % PCOLORS.length];
        ctx.strokeStyle = col; ctx.lineWidth = 1.8; ctx.setLineDash([5, 3]);
        ctx.beginPath();
        for (let i = 0; i < ppath.length; i++) {
          const pp = ppath[i];
          i === 0 ? ctx.moveTo(tpx(pp.x), tpy(Math.max(0, pp.y)))
                  : ctx.lineTo(tpx(pp.x), tpy(Math.max(0, pp.y)));
        }
        ctx.stroke(); ctx.setLineDash([]);
        /* label at landing */
        const plast = ppath[ppath.length - 1];
        const plx = tpx(plast.x), ply = tpy(0);
        ctx.fillStyle = col;
        ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
        ctx.fillText(pl.name, plx, ply + 8);
        ctx.fillText(_projFmt(plast.x) + ' м', plx, ply + 20);
      }
    }

    /* ── 6.5. Ghost trails ── */
    for (const gh of this._ghosts) {
      ctx.strokeStyle = gh.color; ctx.lineWidth = 2;
      ctx.setLineDash([6, 4]);
      ctx.beginPath();
      for (let i = 0; i < gh.points.length; i++) {
        const p = gh.points[i];
        i === 0 ? ctx.moveTo(tpx(p.x), tpy(p.y)) : ctx.lineTo(tpx(p.x), tpy(p.y));
      }
      ctx.stroke(); ctx.setLineDash([]);

      const last = gh.points[gh.points.length - 1];
      const lx = tpx(last.x), ly = tpy(0);
      ctx.strokeStyle = gh.color; ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.moveTo(lx - 5, ly - 5); ctx.lineTo(lx + 5, ly + 5);
      ctx.moveTo(lx + 5, ly - 5); ctx.lineTo(lx - 5, ly + 5);
      ctx.stroke();
      ctx.fillStyle = gh.color;
      ctx.font = '9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
      ctx.fillText(gh.label, lx, ly + 10);
    }

    /* ── 6.7. Target windows ── */
    this._drawTargets(ctx, tpx, tpy);

    /* ── 6.8. HUD: target counter (top-right inside canvas) ── */
    if (this.targetMode && this._targets.length > 0) {
      const hits = this._targets.filter(t => t.hit).length;
      const hudText = `Цели: ${hits}/${this._targets.length}   Попыток: ${this._targetAttempts}`;
      ctx.font = 'bold 11px Manrope, sans-serif';
      const tw = ctx.measureText(hudText).width;
      const hx = W - PR - 8 - tw - 20, hy = PT + 30;
      ctx.fillStyle = 'rgba(5,5,20,.75)';
      ctx.beginPath(); ctx.roundRect(hx - 8, hy - 6, tw + 28, 26, 8); ctx.fill();
      ctx.strokeStyle = 'rgba(255,214,102,.4)'; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.roundRect(hx - 8, hy - 6, tw + 28, 26, 8); ctx.stroke();
      ctx.fillStyle = '#FFD166';
      ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
      ctx.fillText(hudText, hx + 4, hy + 7);
    }

    /* ── 7. Launch platform ── */
    if (this.h0 > 0.2 && !this.ramp) {
      const px0 = tpx(0), py0 = tpy(0), pyH = tpy(this.h0);
      ctx.strokeStyle = 'rgba(255,200,60,.35)'; ctx.lineWidth = 1;
      ctx.setLineDash([4, 4]);
      ctx.beginPath(); ctx.moveTo(px0, py0); ctx.lineTo(px0, pyH); ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = 'rgba(255,200,60,.25)';
      ctx.fillRect(px0 - 12, pyH, 28, 4);
      ctx.fillStyle = 'rgba(255,200,60,.5)';
      ctx.font = '9px Manrope'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
      ctx.fillText(_projFmt(this.h0) + ' м', px0 - 14, pyH);
    }

    /* ── 7.5. Ramp visualization ── */
    if (this.ramp) {
      const rA   = this.rampAngle * Math.PI / 180;
      const rL   = this.rampLength;
      /* ramp starts at (0, h0) going left-down at angle rA */
      const rxStart = -rL * Math.cos(rA);
      const ryStart = this.h0;
      const rxEnd   = 0;
      const ryEnd   = this.h0 + rL * Math.sin(rA);   /* ramp bottom */

      /* clamp start x to left edge */
      const sx = Math.max(PL, tpx(rxStart));
      const sy = tpy(ryStart);
      const ex = tpx(rxEnd);
      const ey = tpy(ryEnd);

      /* ramp surface */
      ctx.strokeStyle = 'rgba(255,180,50,.7)'; ctx.lineWidth = 3;
      ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();

      /* angle arc */
      ctx.strokeStyle = 'rgba(255,200,60,.5)'; ctx.lineWidth = 1.2;
      ctx.beginPath(); ctx.arc(ex, ey, 22, -Math.PI / 2, -rA - Math.PI / 2, true); ctx.stroke();
      ctx.fillStyle = 'rgba(255,200,60,.8)';
      ctx.font = '9px Manrope'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
      ctx.fillText(this.rampAngle + '°', ex + 25, ey - 2);

      /* ramp speed label */
      const rv = this._rampComputeV0();
      ctx.fillStyle = 'rgba(255,214,102,.7)';
      ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
      ctx.fillText('v = ' + rv.toFixed(1) + ' м/с', (sx + ex) / 2, (sy + ey) / 2 - 4);
    }

    /* ── 8. Reference / full trajectories ── */
    if (tf > 0) {
      // analytical reference (always shown as faint dashed)
      const noDragTf = this._tFlightAnalytical();
      ctx.strokeStyle = 'rgba(155,93,229,.22)';
      ctx.lineWidth = 1.5; ctx.setLineDash([7, 5]);
      ctx.beginPath();
      for (let i = 0; i <= 300; i++) {
        const s = this._stateAnalytical((i / 300) * noDragTf);
        i === 0 ? ctx.moveTo(tpx(s.x), tpy(s.y)) : ctx.lineTo(tpx(s.x), tpy(s.y));
      }
      ctx.stroke(); ctx.setLineDash([]);

      // numerical path preview (if active)
      if (this._path && this._path.length > 2) {
        ctx.strokeStyle = this.drag ? 'rgba(239,71,111,.3)' : 'rgba(255,200,60,.35)';
        ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
        ctx.beginPath();
        const step = Math.max(1, Math.floor(this._path.length / 300));
        for (let i = 0; i < this._path.length; i += step) {
          const p = this._path[i];
          i === 0 ? ctx.moveTo(tpx(p.x), tpy(p.y)) : ctx.lineTo(tpx(p.x), tpy(p.y));
        }
        const last = this._path[this._path.length - 1];
        ctx.lineTo(tpx(last.x), tpy(last.y));
        ctx.stroke(); ctx.setLineDash([]);
      }
    }

    /* ── 9. Flown path ── */
    if (this.t > 0 && tf > 0) {
      const s0 = this._curState(0), s1 = this._curState(Math.min(this.t, tf));
      const grad = ctx.createLinearGradient(tpx(s0.x), tpy(s0.y), tpx(s1.x), tpy(s1.y));
      grad.addColorStop(0,   'rgba(155,93,229,.4)');
      grad.addColorStop(0.5, '#9B5DE5');
      grad.addColorStop(1,   '#F15BB5');
      ctx.strokeStyle = grad; ctx.lineWidth = 3;
      ctx.beginPath();

      if (this._path) {
        let first = true;
        for (const p of this._path) {
          if (p.t > this.t) break;
          first ? (ctx.moveTo(tpx(p.x), tpy(p.y)), first = false)
                : ctx.lineTo(tpx(p.x), tpy(p.y));
        }
        const cur = this._pathStateAt(this.t);
        ctx.lineTo(tpx(cur.x), tpy(Math.max(0, cur.y)));
      } else {
        const steps = Math.max(2, Math.ceil(st.progress * 300));
        for (let i = 0; i <= steps; i++) {
          const s = this._stateAnalytical((i / 300) * tf);
          i === 0 ? ctx.moveTo(tpx(s.x), tpy(s.y)) : ctx.lineTo(tpx(s.x), tpy(s.y));
        }
      }
      ctx.stroke();
    }

    /* ── 10. Trail dots ── */
    for (let i = 0; i < this._trail.length; i++) {
      const frac = i / this._trail.length;
      const tr = this._trail[i];
      ctx.fillStyle = `rgba(241,91,181,${frac * 0.55})`;
      ctx.beginPath(); ctx.arc(tpx(tr.mx), tpy(tr.my), frac * 5, 0, Math.PI * 2); ctx.fill();
    }

    /* ── 10.5. Dual throw — second projectile ── */
    if (this.dualMode && this._p2.pathTf > 0) {
      const p2  = this._p2;
      const tf2 = p2.pathTf;

      /* full reference trajectory */
      ctx.strokeStyle = 'rgba(0,230,255,.25)'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]);
      ctx.beginPath();
      const step2 = Math.max(1, p2.path ? Math.floor(p2.path.length / 250) : 1);
      if (p2.path) {
        for (let i = 0; i < p2.path.length; i += step2) {
          const pp = p2.path[i];
          i === 0 ? ctx.moveTo(tpx(pp.x), tpy(pp.y)) : ctx.lineTo(tpx(pp.x), tpy(pp.y));
        }
      } else {
        for (let i = 0; i <= 250; i++) {
          const s2 = this._p2StateAnalytical((i / 250) * tf2);
          i === 0 ? ctx.moveTo(tpx(s2.x), tpy(s2.y)) : ctx.lineTo(tpx(s2.x), tpy(s2.y));
        }
      }
      ctx.stroke(); ctx.setLineDash([]);

      /* flown path */
      if (p2.t > 0) {
        const s2_0 = this._p2CurState(0), s2_1 = this._p2CurState(Math.min(p2.t, tf2));
        const g2 = ctx.createLinearGradient(tpx(s2_0.x), tpy(s2_0.y), tpx(s2_1.x), tpy(s2_1.y));
        g2.addColorStop(0,   'rgba(0,230,255,.3)');
        g2.addColorStop(1,   '#00E6FF');
        ctx.strokeStyle = g2; ctx.lineWidth = 3;
        ctx.beginPath();
        if (p2.path) {
          let first = true;
          for (const pp of p2.path) {
            if (pp.t > p2.t) break;
            first ? (ctx.moveTo(tpx(pp.x), tpy(pp.y)), first = false) : ctx.lineTo(tpx(pp.x), tpy(pp.y));
          }
          const ps2 = this._p2PathStateAt(p2.t);
          ctx.lineTo(tpx(ps2.x), tpy(Math.max(0, ps2.y)));
        } else {
          const steps2 = Math.max(2, Math.ceil((p2.t / tf2) * 250));
          for (let i = 0; i <= steps2; i++) {
            const s2 = this._p2StateAnalytical((i / 250) * tf2);
            i === 0 ? ctx.moveTo(tpx(s2.x), tpy(s2.y)) : ctx.lineTo(tpx(s2.x), tpy(s2.y));
          }
        }
        ctx.stroke();
      }

      /* p2 trail dots */
      for (let i = 0; i < p2.trail.length; i++) {
        const frac = i / p2.trail.length;
        const tr2 = p2.trail[i];
        ctx.fillStyle = `rgba(0,230,255,${frac * 0.45})`;
        ctx.beginPath(); ctx.arc(tpx(tr2.mx), tpy(tr2.my), frac * 4.5, 0, Math.PI * 2); ctx.fill();
      }

      /* p2 ball */
      const c2  = this._p2CurState(Math.min(p2.t, tf2));
      const b2x = tpx(c2.x), b2y = tpy(Math.max(0, c2.y));

      const glo2 = ctx.createRadialGradient(b2x, b2y, 2, b2x, b2y, 28);
      glo2.addColorStop(0,   'rgba(0,230,255,.45)');
      glo2.addColorStop(1,   'transparent');
      ctx.fillStyle = glo2;
      ctx.beginPath(); ctx.arc(b2x, b2y, 28, 0, Math.PI * 2); ctx.fill();

      const bg2 = ctx.createRadialGradient(b2x - 3, b2y - 3, 1, b2x, b2y, 10);
      bg2.addColorStop(0,    '#ffffff');
      bg2.addColorStop(0.25, '#00E6FF');
      bg2.addColorStop(1,    '#0891b2');
      ctx.fillStyle = bg2;
      ctx.beginPath(); ctx.arc(b2x, b2y, 10, 0, Math.PI * 2); ctx.fill();
      ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1.5; ctx.stroke();

      /* p2 landing marker */
      const end2 = this._p2CurState(tf2);
      const lx2  = tpx(end2.x), ly2 = tpy(0);
      ctx.strokeStyle = 'rgba(0,230,255,.6)'; ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(lx2 - 6, ly2 - 6); ctx.lineTo(lx2 + 6, ly2 + 6);
      ctx.moveTo(lx2 + 6, ly2 - 6); ctx.lineTo(lx2 - 6, ly2 + 6);
      ctx.stroke();
      ctx.fillStyle = 'rgba(0,230,255,.8)';
      ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
      ctx.fillText(_projFmt(end2.x) + ' м', lx2, ly2 + 8);
    }

    /* ── 11. Max height marker ── */
    if (st.hMax > this.h0 + 0.2 && tf > 0) {
      let mpx, mpy;
      if (this._path) {
        let best = this._path[0];
        for (const p of this._path) if (p.y > best.y) best = p;
        mpx = tpx(best.x); mpy = tpy(best.y);
      } else {
        const rad = this.angle * Math.PI / 180;
        const vy0 = this.v0 * Math.sin(rad);
        const tPk = vy0 / this.g;
        const pk  = this._stateAnalytical(Math.max(0, tPk));
        mpx = tpx(pk.x); mpy = tpy(pk.y);
      }
      ctx.strokeStyle = 'rgba(255,200,60,.3)'; ctx.lineWidth = 1;
      ctx.setLineDash([4, 4]);
      ctx.beginPath(); ctx.moveTo(PL, mpy); ctx.lineTo(mpx, mpy); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(mpx, mpy); ctx.lineTo(mpx, gy); ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = 'rgba(255,200,60,.7)';
      ctx.beginPath(); ctx.arc(mpx, mpy, 4, 0, Math.PI * 2); ctx.fill();
      ctx.fillStyle = 'rgba(255,200,60,.55)';
      ctx.font = '10px Manrope'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
      ctx.fillText('↑ ' + _projFmt(st.hMax) + ' м', PL - 6, mpy);
    }

    /* ── 12. Landing marker + range arrow ── */
    if (tf > 0) {
      const lx = tpx(st.range), ly = tpy(0);
      const elapsed = (performance.now() - this._impactTs) / 1000;
      const pulse   = (elapsed >= 0 && elapsed < 10) ? 0.7 + 0.3 * Math.sin(elapsed * 8) : 0.6;

      // X mark
      ctx.strokeStyle = `rgba(6,214,224,${pulse})`; ctx.lineWidth = 2;
      const ms = 7;
      ctx.beginPath();
      ctx.moveTo(lx - ms, ly - ms); ctx.lineTo(lx + ms, ly + ms);
      ctx.moveTo(lx + ms, ly - ms); ctx.lineTo(lx - ms, ly + ms);
      ctx.stroke();
      ctx.fillStyle = `rgba(6,214,224,${pulse * 0.8})`;
      ctx.font = 'bold 10px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
      ctx.fillText(_projFmt(st.range) + ' м', lx, ly + 9);

      // <svg class="ic" viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><polyline points="8 17 3 12 8 7"/><polyline points="16 7 21 12 16 17"/></svg> range arrow
      if (st.range > 0.5 && lx > PL + 30) {
        const ay = gy + 20;
        ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1;
        ctx.beginPath(); ctx.moveTo(PL + 3, ay); ctx.lineTo(lx - 3, ay); ctx.stroke();
        ctx.fillStyle = 'rgba(6,214,224,.3)';
        ctx.beginPath(); ctx.moveTo(PL + 3, ay); ctx.lineTo(PL + 9, ay - 3); ctx.lineTo(PL + 9, ay + 3); ctx.closePath(); ctx.fill();
        ctx.beginPath(); ctx.moveTo(lx - 3, ay); ctx.lineTo(lx - 9, ay - 3); ctx.lineTo(lx - 9, ay + 3); ctx.closePath(); ctx.fill();
      }

      if (st.hasMod && st.rangeLoss !== 0) {
        const sign = st.rangeLoss > 0 ? '+' : '';
        ctx.fillStyle = st.rangeLoss < 0 ? 'rgba(239,71,111,.7)' : 'rgba(123,245,164,.7)';
        ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
        ctx.fillText(sign + st.rangeLoss + '% от идеала', lx, ly + 22);
      }
    }

    /* ── 13. Impact effects ── */
    const impactElapsed = (performance.now() - this._impactTs) / 1000;
    if (impactElapsed >= 0 && impactElapsed < 1.5 && tf > 0) {
      const end = this._curState(tf);
      const ix  = tpx(end.x), iy = tpy(0);
      const p   = impactElapsed / 1.5;

      for (let r = 0; r < 3; r++) {
        const rp = Math.max(0, impactElapsed - r * 0.12);
        if (rp <= 0) continue;
        const rr = rp * 55 * (1 + r * 0.3);
        const ra = Math.max(0, (0.5 - rp * 0.5) * (1 - r * 0.2));
        ctx.strokeStyle = `rgba(6,214,224,${ra})`; ctx.lineWidth = 2 - r * 0.4;
        ctx.beginPath(); ctx.ellipse(ix, iy, rr, rr * 0.28, 0, 0, Math.PI * 2); ctx.stroke();
      }
      if (impactElapsed < 0.6) {
        const ca = (0.6 - impactElapsed) / 0.6;
        const cg = ctx.createRadialGradient(ix, iy, 0, ix, iy, 30 + impactElapsed * 40);
        cg.addColorStop(0,   `rgba(255,230,100,${ca * 0.7})`);
        cg.addColorStop(0.4, `rgba(241,91,181,${ca * 0.4})`);
        cg.addColorStop(1,   'transparent');
        ctx.fillStyle = cg;
        ctx.beginPath(); ctx.arc(ix, iy, 60, 0, Math.PI * 2); ctx.fill();
      }
      for (const sp of this._sparks) {
        if (impactElapsed > 1.0) continue;
        const sa  = Math.max(0, 1 - impactElapsed * 1.4);
        const spd = sp.spd * impactElapsed;
        const ex  = ix + Math.cos(sp.ang) * spd;
        const ey  = iy + Math.sin(sp.ang) * spd * 0.4 - impactElapsed * impactElapsed * 120;
        ctx.strokeStyle = `rgba(255,220,80,${sa})`; ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.moveTo(ix + Math.cos(sp.ang) * spd * 0.6, iy + Math.sin(sp.ang) * spd * 0.6 * 0.4);
        ctx.lineTo(ex, ey);
        ctx.stroke();
      }
      const swR = impactElapsed * 120;
      const swa = Math.max(0, 0.35 - p * 0.35);
      ctx.strokeStyle = `rgba(255,255,255,${swa})`; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(ix - swR, iy); ctx.lineTo(ix + swR, iy); ctx.stroke();
    }

    /* ── 14. Ball ── */
    const cur   = this._curState(Math.min(this.t, tf));
    const bx    = tpx(cur.x), by = tpy(Math.max(0, cur.y));
    const speed = Math.sqrt(cur.vx ** 2 + cur.vy ** 2);

    // shadow
    const shadowX = tpx(cur.x);
    const shadowA = Math.max(0, 0.25 - (by - gy) / (ph * 2));
    if (shadowA > 0) {
      const sh = ctx.createRadialGradient(shadowX, gy + 2, 0, shadowX, gy + 2, 18);
      sh.addColorStop(0, `rgba(0,0,0,${shadowA})`);
      sh.addColorStop(1, 'transparent');
      ctx.fillStyle = sh;
      ctx.beginPath(); ctx.ellipse(shadowX, gy + 3, 18, 5, 0, 0, Math.PI * 2); ctx.fill();
    }

    // glow
    const glo = ctx.createRadialGradient(bx, by, 2, bx, by, 30);
    glo.addColorStop(0,   'rgba(241,91,181,.5)');
    glo.addColorStop(0.4, 'rgba(155,93,229,.25)');
    glo.addColorStop(1,   'transparent');
    ctx.fillStyle = glo;
    ctx.beginPath(); ctx.arc(bx, by, 30, 0, Math.PI * 2); ctx.fill();

    // ball body
    const ballGrad = ctx.createRadialGradient(bx - 3, by - 3, 1, bx, by, 10);
    ballGrad.addColorStop(0,    '#ffffff');
    ballGrad.addColorStop(0.25, '#F15BB5');
    ballGrad.addColorStop(1,    '#7c3aed');
    ctx.fillStyle = ballGrad;
    ctx.beginPath(); ctx.arc(bx, by, 10, 0, Math.PI * 2); ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1.5; ctx.stroke();

    /* ── 14.5. Parachute ── */
    if (this.parachute && this._chuteOpen && this.t < tf && cur.y > 0) {
      this._drawParachute(ctx, bx, by);
    }

    /* ── 14.6. Parachute HUD ── */
    if (this.parachute) {
      const vt   = this._terminalVelocity();
      const pct  = Math.min(100, Math.round((1 - (speed - vt) / Math.max(vt, 0.01)) * 100));
      const pctC = Math.min(100, Math.round(speed / (vt * 2 + 0.01) * 100));
      const hudRows = [
        'v = ' + speed.toFixed(1) + ' м/с',
        'v_t = ' + vt.toFixed(1) + ' м/с',
        (this._chuteOpen ? 'Откр' : 'Закр') + '   ' + Math.max(0, Math.min(100, Math.round(vt / Math.max(speed, 0.01) * 100))) + '%',
      ];
      const hudX = W - PR - 8;
      const hudY = PT + 34;
      ctx.font = '9px Manrope, sans-serif';
      const maxW = Math.max(...hudRows.map(r => ctx.measureText(r).width));
      ctx.fillStyle = 'rgba(5,5,20,.8)';
      ctx.beginPath(); ctx.roundRect(hudX - maxW - 20, hudY - 4, maxW + 24, hudRows.length * 16 + 8, 7); ctx.fill();
      ctx.strokeStyle = 'rgba(6,214,224,.4)'; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.roundRect(hudX - maxW - 20, hudY - 4, maxW + 24, hudRows.length * 16 + 8, 7); ctx.stroke();
      for (let ri = 0; ri < hudRows.length; ri++) {
        ctx.fillStyle = ri === 2 ? (this._chuteOpen ? '#7BF5A4' : '#FFD166') : '#06D6E0';
        ctx.textAlign = 'right'; ctx.textBaseline = 'top';
        ctx.fillText(hudRows[ri], hudX - 6, hudY + ri * 16);
      }
    }

    /* ── 15. Velocity arrows + labels ── */
    if (speed > 0.3 && this.t < tf) {
      const VX_LEN = Math.min(55, 50 * Math.abs(cur.vx) / Math.max(1, this.v0));
      const VY_LEN = Math.min(55, 50 * Math.abs(cur.vy) / Math.max(1, this.v0));

      if (Math.abs(cur.vx) > 0.2) {
        _projArrow(ctx, bx, by, bx + VX_LEN, by, '#06D6E0', 2);
        ctx.fillStyle = '#06D6E0'; ctx.font = 'bold 9px Manrope';
        ctx.textAlign = 'center'; ctx.textBaseline = 'top';
        ctx.fillText(_projFmt(Math.abs(cur.vx)) + ' м/с', bx + VX_LEN / 2, by + 7);
      }

      if (Math.abs(cur.vy) > 0.2) {
        const vyDir  = cur.vy > 0 ? -1 : 1;
        const vyCol  = cur.vy > 0 ? '#9B5DE5' : '#F15BB5';
        _projArrow(ctx, bx, by, bx, by + vyDir * VY_LEN, vyCol, 2);
        ctx.fillStyle = vyCol; ctx.font = 'bold 9px Manrope';
        ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
        ctx.fillText(_projFmt(Math.abs(cur.vy)) + ' м/с', bx + 6, by + vyDir * VY_LEN / 2);
      }

      // total velocity arrow
      const vLen = 48 * (speed / Math.max(1, this.v0));
      _projArrow(ctx, bx, by,
        bx + (cur.vx / speed) * vLen,
        by - (cur.vy / speed) * vLen,
        '#ffffff', 2.5);
    }

    /* ── 16. Launch flash ── */
    if (this._launchFlash > 0) {
      const f   = this._launchFlash;
      const rad = this.angle * Math.PI / 180;
      for (let i = 0; i < 10; i++) {
        const a   = rad + (i / 10) * Math.PI * 2;
        const len = f * (20 + i % 3 * 15);
        ctx.strokeStyle = `rgba(255,230,100,${f * 0.8})`; ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.moveTo(bx + Math.cos(a) * 12, by - Math.sin(a) * 12);
        ctx.lineTo(bx + Math.cos(a) * len, by - Math.sin(a) * len);
        ctx.stroke();
      }
      const halo = ctx.createRadialGradient(bx, by, 0, bx, by, f * 40);
      halo.addColorStop(0,   `rgba(255,230,100,${f * 0.5})`);
      halo.addColorStop(0.5, `rgba(241,91,181,${f * 0.2})`);
      halo.addColorStop(1,   'transparent');
      ctx.fillStyle = halo;
      ctx.beginPath(); ctx.arc(bx, by, f * 40, 0, Math.PI * 2); ctx.fill();
    }

    /* ── 17. Launch angle arc (idle) ── */
    if (this.t < 0.04 && this.angle > 2 && !this.playing) {
      const rad = this.angle * Math.PI / 180;
      ctx.strokeStyle = 'rgba(255,200,60,.45)'; ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.arc(bx, by, 34, -rad, 0); ctx.stroke();
      const ivLen = Math.min(70, 30 + this.v0 * 0.8);
      ctx.strokeStyle = 'rgba(255,255,255,.35)'; ctx.lineWidth = 1.5;
      ctx.setLineDash([5, 4]);
      ctx.beginPath();
      ctx.moveTo(bx, by);
      ctx.lineTo(bx + Math.cos(rad) * ivLen, by - Math.sin(rad) * ivLen);
      ctx.stroke(); ctx.setLineDash([]);
      ctx.fillStyle = 'rgba(255,200,60,.75)';
      ctx.font = 'bold 11px Manrope'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
      ctx.fillText(this.angle + '°', bx + 38, by - 2);
    }

    /* ── 18. Info badges (top-right) ── */
    let bRight = W - PR - 8;
    if (this.drag && !this.parachute) {
      this._drawBadge(ctx, bRight, PT + 6, 'Cd=' + this.Cd.toFixed(2) + ' m=' + this.mass + 'кг', 'rgba(239,71,111,.15)', 'rgba(239,71,111,.75)');
      bRight -= 130;
    }
    if (this.parachute) {
      this._drawBadge(ctx, bRight, PT + 6, 'A=' + this.chuteArea.toFixed(1) + 'м²  Cd=' + this.chuteCd, 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)');
      bRight -= 150;
    }
    if (this.wind !== 0) {
      const dir = this.wind > 0 ? '→' : '←';
      this._drawBadge(ctx, bRight, PT + 6, dir + ' ветер ' + Math.abs(this.wind) + 'м/с', 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)');
      bRight -= 130;
    }
    if (this.bounce) {
      this._drawBadge(ctx, bRight, PT + 6, '↩ e=' + this.restitution.toFixed(2), 'rgba(123,245,164,.1)', 'rgba(123,245,164,.75)');
      bRight -= 100;
    }
    if (this.ramp) {
      this._drawBadge(ctx, bRight, PT + 6, 'Горка ' + this.rampAngle + '° L=' + this.rampLength + 'м', 'rgba(255,180,50,.12)', 'rgba(255,180,50,.85)');
      bRight -= 140;
    }
    if (this.planetId !== 'earth') {
      const pl = this.planets.find(p => p.id === this.planetId);
      if (pl) {
        this._drawBadge(ctx, bRight, PT + 6, pl.name + ' g=' + pl.g, 'rgba(123,245,164,.1)', 'rgba(123,245,164,.8)');
      }
    }

    /* speed badge bottom-right */
    if (this.speed !== 1) {
      this._drawBadge(ctx, W - PR - 8, H - PB - 28, '×' + this.speed, 'rgba(255,214,102,.12)', 'rgba(255,214,102,.8)');
    }

    /* ── 19. Hover inspector ── */
    if (!this.playing && this._hover) {
      this._drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT);
    }

    /* ── 20. FBD overlay ── */
    if (this._fbdOn && window.LSPhysFX && this.t > 0) {
      const tf2  = this._curTFlight();
      const cur2 = this._curState(Math.min(this.t, tf2));
      const bx2  = tpx(cur2.x), by2 = tpy(Math.max(0, cur2.y));
      const spd2 = Math.sqrt(cur2.vx * cur2.vx + cur2.vy * cur2.vy);
      const FLEN = 48;

      /* gravity — straight down */
      const mg = this.mass * this.g;
      LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, FLEN, 'gravity',
        'mg=' + mg.toFixed(0) + 'Н');

      /* drag — opposite velocity */
      if (this.drag && spd2 > 0.2) {
        const rho2  = this.rho || 1.225;
        const Fd    = 0.5 * rho2 * this.Cd * 0.1 * spd2 * spd2;
        const dragLen = Math.min(FLEN, Fd * 6);
        if (dragLen > 3) {
          const dnx = -cur2.vx / spd2, dny = -cur2.vy / spd2;
          LSPhysFX.drawForceArrow(ctx, bx2, by2,
            dnx * dragLen, dny * dragLen,
            'drag', 'F_c=' + Fd.toFixed(1) + 'Н');
        }
      }

      /* wind — horizontal */
      if (this.wind !== 0) {
        const windLen = Math.min(FLEN, Math.abs(this.wind) * 4);
        LSPhysFX.drawForceArrow(ctx, bx2, by2,
          Math.sign(this.wind) * windLen, 0,
          'applied', 'F_w=' + Math.abs(this.wind).toFixed(0) + 'м/с');
      }

      /* elastic spring force — only when bounce just occurred */
      if (this.bounce) {
        const bounceElapsed = (performance.now() - this._impactTs) / 1000;
        if (bounceElapsed >= 0 && bounceElapsed < 0.25 && cur2.y <= 0.5) {
          LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, -FLEN, 'elastic', 'F_упр');
        }
      }
    }

    /* ── Energy bars overlay ── */
    if (this._energyOn && window.LSPhysFX) {
      this._drawEnergyBarsProj(ctx, W, H);
    }

    /* LabFX: particles overlay */
    if (window.LabFX) LabFX.particles.draw(this.ctx);
  }

  /* ── Energy bars: projectile ── */

  _drawEnergyBarsProj(ctx, W, H) {
    const tf  = this._curTFlight();
    const cur = this._curState(Math.min(this.t, tf));
    const h   = Math.max(0, cur.y);     // height above launch (m)
    const spd = Math.sqrt(cur.vx * cur.vx + cur.vy * cur.vy);
    const m   = Math.max(0.1, this.mass);
    const g   = this.g;
    const ke  = 0.5 * m * spd * spd;
    const pe  = m * g * h;
    const fr  = this._frictionWork;
    const tot = ke + pe + fr;
    /* stable scale */
    if (tot > this._energyScale) this._energyScale = tot;
    const scaleTotal = this._energyScale;

    const PW = 188, MARGIN = 12;
    LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0,
      { ke, pe, friction: fr, total: scaleTotal }, {});
  }

  /* ── hover inspector ── */

  _onMouseMove(e) {
    if (this.playing) { this._hover = null; return; }
    const tf = this._curTFlight();
    if (tf <= 0 || !this._viewParams) { this._hover = null; return; }

    const r  = this.c.getBoundingClientRect();
    const cw = this._cw || this.c.width, ch = this._ch || this.c.height;
    const mx = (e.clientX - r.left) * (cw / r.width);
    const my = (e.clientY - r.top)  * (ch / r.height);

    const { xMax, yMax, PL, PR, PT, PB, W, H } = this._viewParams;
    const pw = W - PL - PR, ph = H - PT - PB;
    const scX = pw / xMax, scY = ph / yMax;
    const tpx = wx => PL + wx * scX;
    const tpy = wy => H - PB - wy * scY;

    let bestT = null, bestDist = Infinity;
    const N = 400;

    if (this._path) {
      const step = Math.max(1, Math.floor(this._path.length / N));
      for (let i = 0; i < this._path.length; i += step) {
        const p = this._path[i];
        const d = Math.hypot(tpx(p.x) - mx, tpy(Math.max(0, p.y)) - my);
        if (d < bestDist) { bestDist = d; bestT = p.t; }
      }
      /* also check last point */
      const last = this._path[this._path.length - 1];
      const d = Math.hypot(tpx(last.x) - mx, tpy(Math.max(0, last.y)) - my);
      if (d < bestDist) { bestDist = d; bestT = last.t; }
    } else {
      for (let i = 0; i <= N; i++) {
        const t = (i / N) * tf;
        const s = this._stateAnalytical(t);
        const d = Math.hypot(tpx(s.x) - mx, tpy(Math.max(0, s.y)) - my);
        if (d < bestDist) { bestDist = d; bestT = t; }
      }
    }

    if (bestDist < 32 && bestT !== null) {
      const s = this._curState(bestT);
      this._hover = { t: bestT, s };
    } else {
      this._hover = null;
    }
    this.draw();
  }

  _onMouseLeave() {
    this._hover = null;
    this.draw();
  }

  _drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT) {
    const { t, s } = this._hover;
    const bx    = tpx(s.x);
    const by    = tpy(Math.max(0, s.y));
    const speed = Math.sqrt(s.vx ** 2 + s.vy ** 2);
    const velAng = Math.atan2(s.vy, s.vx) * 180 / Math.PI;

    /* ── crosshair lines ── */
    ctx.save();
    ctx.strokeStyle = 'rgba(255,214,102,.3)';
    ctx.lineWidth   = 1;
    ctx.setLineDash([4, 3]);
    ctx.beginPath(); ctx.moveTo(bx, by);  ctx.lineTo(bx, gy);  ctx.stroke();
    ctx.beginPath(); ctx.moveTo(PL, by); ctx.lineTo(bx, by); ctx.stroke();
    ctx.setLineDash([]);
    ctx.restore();

    /* ── axis labels ── */
    ctx.font      = 'bold 9px Manrope';
    ctx.fillStyle = 'rgba(255,214,102,.7)';
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    ctx.fillText(_projFmt(Math.max(0, s.x)) + ' м', bx, gy + 6);
    ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
    ctx.fillText(_projFmt(Math.max(0, s.y)) + ' м', PL - 4, by);

    /* ── dot on trajectory ── */
    const glow = ctx.createRadialGradient(bx, by, 0, bx, by, 14);
    glow.addColorStop(0,   'rgba(255,214,102,.5)');
    glow.addColorStop(1,   'transparent');
    ctx.fillStyle = glow;
    ctx.beginPath(); ctx.arc(bx, by, 14, 0, Math.PI * 2); ctx.fill();

    ctx.fillStyle   = '#FFD166';
    ctx.strokeStyle = 'rgba(255,255,255,.9)';
    ctx.lineWidth   = 1.5;
    ctx.beginPath(); ctx.arc(bx, by, 5.5, 0, Math.PI * 2); ctx.fill(); ctx.stroke();

    /* ── tooltip ── */
    const rows = [
      { label: 't',    val: t.toFixed(3) + ' с',              color: '#FFD166' },
      { label: 'x',    val: _projFmt(Math.max(0, s.x)) + ' м', color: '#06D6E0' },
      { label: 'y',    val: _projFmt(Math.max(0, s.y)) + ' м', color: '#7BF5A4' },
      { label: '|v|',  val: _projFmt(speed) + ' м/с',          color: '#ffffff' },
      { label: 'vx',   val: _projFmt(s.vx) + ' м/с',           color: '#06D6E0' },
      { label: 'vy',   val: _projFmt(s.vy) + ' м/с',           color: '#9B5DE5' },
      { label: 'угол', val: velAng.toFixed(1) + '°',           color: '#F15BB5' },
    ];

    const padX = 10, padY = 8, lineH = 17;
    const tw   = 138, th = padY * 2 + rows.length * lineH;

    /* position — avoid canvas edges */
    let tx = bx + 16, ty = by - th / 2;
    if (tx + tw > W - 22)       tx = bx - tw - 16;
    if (ty < PT + 4)             ty = PT + 4;
    if (ty + th > H - PB - 4)   ty = H - PB - th - 4;

    /* shadow */
    ctx.save();
    ctx.shadowColor = 'rgba(0,0,0,.6)';
    ctx.shadowBlur  = 12;
    ctx.fillStyle   = 'rgba(8,8,18,.92)';
    ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.fill();
    ctx.restore();

    /* border */
    ctx.strokeStyle = 'rgba(255,214,102,.35)';
    ctx.lineWidth   = 1;
    ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.stroke();

    /* top accent line */
    ctx.strokeStyle = 'rgba(255,214,102,.6)';
    ctx.lineWidth   = 2;
    ctx.beginPath();
    ctx.moveTo(tx + 9, ty + 1);
    ctx.lineTo(tx + tw - 9, ty + 1);
    ctx.stroke();

    /* rows */
    ctx.font = '10px Manrope, sans-serif';
    for (let i = 0; i < rows.length; i++) {
      const row = rows[i];
      const ry  = ty + padY + i * lineH + lineH / 2;

      /* separator */
      if (i > 0) {
        ctx.strokeStyle = 'rgba(255,255,255,.04)';
        ctx.lineWidth   = 1;
        ctx.beginPath(); ctx.moveTo(tx + 8, ry - lineH / 2); ctx.lineTo(tx + tw - 8, ry - lineH / 2); ctx.stroke();
      }

      ctx.fillStyle    = 'rgba(255,255,255,.35)';
      ctx.textAlign    = 'left';
      ctx.textBaseline = 'middle';
      ctx.fillText(row.label, tx + padX, ry);

      ctx.fillStyle = row.color;
      ctx.textAlign = 'right';
      ctx.fillText(row.val, tx + tw - padX, ry);
    }

    /* connector dot */
    ctx.fillStyle   = '#FFD166';
    ctx.strokeStyle = 'rgba(8,8,18,.9)';
    ctx.lineWidth   = 1.5;
    const cx = tx < bx ? tx + tw : tx;
    const cy = ty + th / 2;
    ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
  }

  /* ── draw helpers ── */

  _drawBadge(ctx, rightX, y, text, bg, fg) {
    const bh = 20;
    ctx.font = 'bold 9px Manrope';
    const tw  = ctx.measureText(text).width;
    const bw  = tw + 16;
    const bx  = rightX - bw;
    ctx.fillStyle = bg;
    ctx.beginPath(); ctx.roundRect(bx, y, bw, bh, 6); ctx.fill();
    ctx.fillStyle = fg;
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText(text, bx + bw / 2, y + bh / 2);
  }

  _drawWind(ctx, x, y, w, h) {
    const now      = performance.now() / 1000;
    const dir      = this.wind > 0 ? 1 : -1;
    const strength = Math.min(1, Math.abs(this.wind) / 20);
    const count    = Math.floor(4 + strength * 7);
    const len      = (18 + strength * 45) * dir;

    ctx.save();
    ctx.strokeStyle = '#06D6E0';
    for (let i = 0; i < count; i++) {
      const phase    = ((i / count) + now * strength * 0.25) % 1;
      const streak_x = dir > 0 ? x + phase * w : x + (1 - phase) * w;
      const streak_y = y + (0.1 + (i / count) * 0.8) * h;
      const alpha    = 0.08 + strength * 0.15;
      ctx.globalAlpha = alpha;
      ctx.lineWidth   = 0.8 + strength * 0.6;
      ctx.beginPath(); ctx.moveTo(streak_x, streak_y); ctx.lineTo(streak_x + len, streak_y); ctx.stroke();
    }
    ctx.restore();
  }

  /* Draw parachute dome above the ball */
  _drawParachute(ctx, bx, by) {
    const now = performance.now();
    const age = (now - this._chuteOpenedTs) / 1000;
    /* deploy animation: scale from 0 to 1 over 0.3 s */
    const scale = Math.min(1, age / 0.3);
    const R  = 26 * scale;  /* dome radius */
    const cy = by - R - 12; /* centre of dome */

    ctx.save();
    /* dome fill */
    const fill = ctx.createRadialGradient(bx, cy, 0, bx, cy, R);
    fill.addColorStop(0,   'rgba(6,214,224,0.55)');
    fill.addColorStop(0.7, 'rgba(6,214,224,0.25)');
    fill.addColorStop(1,   'rgba(6,214,224,0.05)');
    ctx.fillStyle = fill;
    ctx.beginPath();
    ctx.arc(bx, cy, R, Math.PI, 0);
    ctx.lineTo(bx + R, cy);
    ctx.closePath();
    ctx.fill();

    /* dome border */
    ctx.strokeStyle = 'rgba(6,214,224,0.75)';
    ctx.lineWidth   = 1.5;
    ctx.beginPath();
    ctx.arc(bx, cy, R, Math.PI, 0);
    ctx.stroke();

    /* suspension lines (4) */
    ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = 0.8;
    for (let li = 0; li < 4; li++) {
      const a = Math.PI + (li + 0.5) / 4 * Math.PI;
      ctx.beginPath();
      ctx.moveTo(bx + Math.cos(a) * R, cy + Math.sin(a) * R);
      ctx.lineTo(bx, by - 10);
      ctx.stroke();
    }
    ctx.restore();
  }
}

/* ── module helpers ── */

function _projNiceStep(range, n) {
  const raw = range / n;
  const p   = Math.pow(10, Math.floor(Math.log10(raw)));
  for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
  return p;
}

function _projFmt(n) {
  if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
  if (n >= 100)  return Math.round(n).toString();
  if (n >= 10)   return n.toFixed(1);
  return n.toFixed(2);
}

function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
  const ang = Math.atan2(y2 - y1, x2 - x1);
  ctx.save();
  ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw;
  ctx.shadowColor = color; ctx.shadowBlur = 6;
  ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(x2, y2);
  ctx.lineTo(x2 - 9 * Math.cos(ang - 0.4), y2 - 9 * Math.sin(ang - 0.4));
  ctx.lineTo(x2 - 9 * Math.cos(ang + 0.4), y2 - 9 * Math.sin(ang + 0.4));
  ctx.closePath(); ctx.fill();
  ctx.restore();
}

/* ─── lab UI init ─────────────────────────────────── */
  function _openProjectile() {
    document.getElementById('sim-topbar-title').textContent = 'Бросок тела';
    _simShow('sim-proj');
    _simShow('ctrl-proj');
    _registerSimState('projectile', () => pSim?.getParams(), st => pSim?.setParams(st));
    if (_embedMode) _startStateEmit('projectile');

    requestAnimationFrame(() => requestAnimationFrame(() => {
      if (!pSim) {
        pSim = new ProjectileSim(document.getElementById('proj-canvas'));
        pSim.onUpdate      = _projUpdateUI;
        pSim.onPlayPause   = projPlayPause;
        pSim.onTargetUpdate = _projUpdateTargetHUD;
        const gc = document.getElementById('proj-graphs-canvas');
        if (gc) pSim.attachGraphsCanvas(gc);
        _projInjectTCBar(pSim);
      }
      pSim.fit();
      projParam();       // sync sliders → sim
      pSim.draw();
      _projUpdateUI(pSim.stats());
    }));
  }

  function _projInjectTCBar(sim) {
    if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
    var wrap = document.getElementById('sim-proj');
    if (!wrap || wrap.querySelector('.tc-bar')) return;

    /* Only speed control — projectile uses analytical/pre-computed paths */
    /* TC.paused is checked in _tick. TC.scale multiplies rawDt * speed */
    var tc = sim._tc;

    var tcBar = window.LSBuildTimeControlUI(sim, tc, { scrubSupported: false });

    /* Note: "Следы" already exist in the projectile panel — don't duplicate */

    var statsBar = wrap.querySelector('.proj-stats-bar');
    if (statsBar) wrap.insertBefore(tcBar, statsBar);
    else wrap.appendChild(tcBar);
  }

  function projPlayPause() {
    if (!pSim) return;
    if (pSim.playing) {
      pSim.pause();
    } else {
      pSim.play();
    }
    _projSyncPlayBtn();
  }

  function _projSyncPlayBtn() {
    /* small topbar button */
    const tb = document.getElementById('proj-play-btn');
    /* big launch button */
    const lb = document.getElementById('proj-launch-main');
    const lbl = document.getElementById('proj-launch-label');
    const lic = document.getElementById('proj-launch-icon');
    if (!pSim) return;

    const tf      = pSim._curTFlight();
    const done    = !pSim.playing && pSim.t >= tf && pSim.t > 0;
    const playing = pSim.playing;

    /* topbar */
    if (tb) {
      tb.innerHTML = playing
        ? '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>'
        : '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
      tb.title = playing ? 'Пауза' : 'Запустить';
      tb.classList.toggle('active', playing);
    }

    /* big button */
    if (lb && lbl && lic) {
      lb.classList.toggle('paused', playing);
      lb.classList.toggle('done',   done && !playing);
      if (playing) {
        lic.innerHTML = '<rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/>';
        lbl.textContent = 'Пауза';
      } else if (done) {
        lic.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
        lbl.textContent = 'Повторить';
      } else {
        lic.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
        lbl.textContent = 'Запустить';
      }
    }
  }

  function projParam() {
    const v0    = +document.getElementById('sl-v0').value;
    const angle = +document.getElementById('sl-angle').value;
    const h0    = +document.getElementById('sl-h0').value;
    const g     = +document.getElementById('sl-g').value;

    document.getElementById('p-v0').textContent    = v0 + ' м/с';
    document.getElementById('p-angle').textContent = angle + '°';
    document.getElementById('p-h0').textContent    = h0 + ' м';
    document.getElementById('p-g').textContent     = g.toFixed(2) + ' м/с²';

    if (pSim) { pSim.setParams({ v0, angle, h0, g }); _projSyncPlayBtn(); }
  }

  function projPreset(v0, angle, h0, g) {
    document.getElementById('sl-v0').value    = v0;
    document.getElementById('sl-angle').value = angle;
    document.getElementById('sl-h0').value    = h0;
    document.getElementById('sl-g').value     = g;
    projParam();
  }

  function projToggleDrag(rowEl) {
    if (!pSim) return;
    pSim.drag = !pSim.drag;
    const on = pSim.drag;
    rowEl.classList.toggle('active', on);
    const tog = document.getElementById('drag-toggle');
    tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
    tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
    document.getElementById('drag-params').style.display = on ? '' : 'none';
    document.getElementById('ps-loss-wrap').style.display = on ? '' : 'none';
    if (on) {
      const cd   = +document.getElementById('sl-cd').value / 100;
      const mass = +document.getElementById('sl-mass').value;
      pSim.setParams({ drag: true, Cd: cd, mass });
    } else {
      pSim.setParams({ drag: false });
    }
  }

  function projCdChange() {
    const cd = +document.getElementById('sl-cd').value / 100;
    document.getElementById('p-cd').textContent = cd.toFixed(2);
    if (pSim) pSim.setParams({ Cd: cd });
  }

  function projMassChange() {
    const mass = +document.getElementById('sl-mass').value;
    document.getElementById('p-mass').textContent = mass + ' кг';
    if (pSim) pSim.setParams({ mass });
  }

  function projWindChange() {
    const wind = +document.getElementById('sl-wind').value;
    const label = wind === 0 ? '0 м/с' : (wind > 0 ? '→ +' : '← ') + Math.abs(wind) + ' м/с';
    document.getElementById('p-wind').textContent = label;
    document.getElementById('ps-loss-wrap').style.display = wind !== 0 ? '' : (pSim && pSim.drag ? '' : 'none');
    if (pSim) { pSim.setParams({ wind }); _projSyncPlayBtn(); }
  }

  function projToggleBounce(rowEl) {
    if (!pSim) return;
    pSim.bounce = !pSim.bounce;
    const on = pSim.bounce;
    rowEl.classList.toggle('active', on);
    const tog = document.getElementById('bounce-toggle');
    tog.style.background = on ? 'rgba(123,245,164,0.8)' : 'rgba(255,255,255,0.12)';
    tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
    document.getElementById('bounce-params').style.display = on ? '' : 'none';
    const e = +document.getElementById('sl-restitution').value / 100;
    pSim.setParams({ bounce: on, restitution: e });
  }

  function projRestitutionChange() {
    const e = +document.getElementById('sl-restitution').value / 100;
    document.getElementById('p-restitution').textContent = e.toFixed(2);
    if (pSim) pSim.setParams({ restitution: e });
  }

  function projSetSpeed(s, el) {
    if (pSim) pSim.setSpeed(s);
    document.querySelectorAll('.proj-speed').forEach(b => b.classList.remove('active'));
    if (el) el.classList.add('active');
  }

  function projSaveGhost() {
    if (pSim) pSim.saveGhost();
  }

  function projClearGhosts() {
    if (pSim) pSim.clearGhosts();
  }

  /* ── Feature 1: target mode UI ── */

  function projToggleTargetMode() {
    if (!pSim) return;
    const on = pSim.toggleTargetMode();
    const btn = document.getElementById('proj-target-btn');
    if (btn) {
      btn.classList.toggle('active', on);
      btn.querySelector('span').textContent = on ? 'Режим целей: Вкл' : 'Режим целей: Выкл';
    }
    const panel = document.getElementById('proj-target-panel');
    if (panel) panel.style.display = on ? '' : 'none';
    _projUpdateTargetHUD({ hits: 0, total: pSim._targets.length, attempts: 0 });
  }

  function projGenTargets() {
    if (!pSim) return;
    pSim.genTargets();
    _projUpdateTargetHUD({ hits: 0, total: pSim._targets.length, attempts: 0 });
  }

  function _projUpdateTargetHUD(info) {
    const el = document.getElementById('proj-target-hud');
    if (!el) return;
    el.textContent = `Цели: ${info.hits}/${info.total}   Попыток: ${info.attempts}`;
  }

  /* ── Feature 2: graphs panel UI ── */

  function projToggleGraphs() {
    if (!pSim) return;
    pSim._graphsVisible = !pSim._graphsVisible;
    const panel = document.getElementById('proj-graphs-panel');
    const btn   = document.getElementById('proj-graphs-btn');
    if (panel) panel.style.display = pSim._graphsVisible ? '' : 'none';
    if (btn)   btn.classList.toggle('active', pSim._graphsVisible);
    if (pSim._graphsVisible) {
      if (!pSim._graphsCanvas) {
        const gc = document.getElementById('proj-graphs-canvas');
        if (gc) pSim.attachGraphsCanvas(gc);
      }
      pSim.drawGraphs();
    }
  }

  /* ── Feature 3: dual throw UI ── */

  function projToggleDual() {
    if (!pSim) return;
    pSim.dualMode = !pSim.dualMode;
    const on  = pSim.dualMode;
    const btn = document.getElementById('proj-dual-btn');
    if (btn) {
      btn.classList.toggle('active', on);
      btn.querySelector('span').textContent = on ? 'Двойной: Вкл' : 'Двойной: Выкл';
    }
    const panel = document.getElementById('proj-dual-panel');
    if (panel) panel.style.display = on ? '' : 'none';
    /* show/hide dual stats cells */
    const w1 = document.getElementById('ps-p2-wrap');
    const w2 = document.getElementById('ps-p2-tf-wrap');
    if (w1) w1.style.display = on ? '' : 'none';
    if (w2) w2.style.display = on ? '' : 'none';
    if (on) {
      pSim._computeP2Path();
      projP2Param();
    }
    pSim.draw();
  }

  function projP2Param() {
    if (!pSim) return;
    const v0    = +document.getElementById('sl-p2-v0').value;
    const angle = +document.getElementById('sl-p2-angle').value;
    const h0    = +document.getElementById('sl-p2-h0').value;
    document.getElementById('p2-v0').textContent    = v0 + ' м/с';
    document.getElementById('p2-angle').textContent = angle + '°';
    document.getElementById('p2-h0').textContent    = h0 + ' м';
    pSim._p2.v0    = v0;
    pSim._p2.angle = angle;
    pSim._p2.h0    = h0;
    pSim._computeP2Path();
    pSim.draw();
  }

  function _projUpdateUI(s) {
    const fmt = (n, unit) => n < 10000 ? n.toFixed(2) + ' ' + unit : (n/1000).toFixed(2) + ' к' + unit;
    document.getElementById('ps-range').textContent  = fmt(s.range, 'м');
    document.getElementById('ps-hmax').textContent   = fmt(s.hMax,  'м');
    document.getElementById('ps-tf').textContent     = s.tf.toFixed(2) + ' с';
    document.getElementById('ps-vland').textContent  = fmt(s.vLand, 'м/с');
    document.getElementById('ps-t').textContent      = s.t.toFixed(2) + ' с';
    const laEl = document.getElementById('ps-land-angle');
    if (laEl) laEl.textContent = s.landAngle > 0.5 ? s.landAngle.toFixed(1) + '°' : '—';
    if (s.hasMod) {
      const lossEl = document.getElementById('ps-loss');
      if (lossEl) {
        const sign = s.rangeLoss > 0 ? '+' : '';
        lossEl.textContent  = s.rangeLoss !== 0 ? sign + s.rangeLoss + '%' : '0%';
        lossEl.style.color  = s.rangeLoss < 0 ? '#EF476F' : '#7BF5A4';
      }
    }
    /* update dual stats row */
    if (pSim && pSim.dualMode && pSim._p2.pathTf > 0) {
      const p2end = pSim._p2CurState(pSim._p2.pathTf);
      const d2El  = document.getElementById('ps-p2-range');
      if (d2El) d2El.textContent = fmt(Math.max(0, p2end.x), 'м');
      const d2tf = document.getElementById('ps-p2-tf');
      if (d2tf) d2tf.textContent = pSim._p2.pathTf.toFixed(2) + ' с';
    }
    _projSyncPlayBtn();
    /* redraw graphs if open and not in flight (flight loop handles it) */
    if (pSim && pSim._graphsVisible && !pSim.playing) pSim.drawGraphs();
  }

  /* ── Feature 4: Parachute UI ── */

  function projToggleParachute(rowEl) {
    if (!pSim) return;
    pSim.parachute = !pSim.parachute;
    const on = pSim.parachute;
    if (rowEl) rowEl.classList.toggle('active', on);
    const tog = document.getElementById('chute-toggle');
    if (tog) {
      tog.style.background = on ? 'var(--cyan,#06D6E0)' : 'rgba(255,255,255,0.12)';
      tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
    }
    document.getElementById('chute-params').style.display = on ? '' : 'none';
    /* parachute and simple drag are mutually exclusive */
    if (on) pSim.setParams({ parachute: true, drag: false });
    else    pSim.setParams({ parachute: false });
    /* also reflect drag row */
    const dragRow = document.getElementById('drag-row');
    if (dragRow) dragRow.classList.toggle('active', false);
    const dragTog = document.getElementById('drag-toggle');
    if (dragTog) {
      dragTog.style.background = 'rgba(255,255,255,0.12)';
      dragTog.querySelector('span').style.marginLeft = '2px';
    }
    document.getElementById('drag-params').style.display = 'none';
  }

  function projChuteAreaChange() {
    const A = +document.getElementById('sl-chute-area').value / 10;
    document.getElementById('p-chute-area').textContent = A.toFixed(1) + ' м²';
    if (pSim) pSim.setParams({ chuteArea: A });
  }

  function projChuteCdChange() {
    const sel = document.getElementById('sel-chute-cd');
    if (!sel || !pSim) return;
    const cd = +sel.value;
    pSim.setParams({ chuteCd: cd });
  }

  function projChuteHeightChange() {
    const val = +document.getElementById('sl-chute-height').value;
    const h   = val <= 0 ? -1 : val;
    const lbl = document.getElementById('p-chute-height');
    if (lbl) lbl.textContent = h < 0 ? 'Сразу' : h.toFixed(0) + ' м';
    if (pSim) pSim.setParams({ chuteOpenHeight: h });
  }

  /* ── Feature 5: Ramp UI ── */

  function projToggleRamp(rowEl) {
    if (!pSim) return;
    pSim.ramp = !pSim.ramp;
    const on = pSim.ramp;
    if (rowEl) rowEl.classList.toggle('active', on);
    const tog = document.getElementById('ramp-toggle');
    if (tog) {
      tog.style.background = on ? 'rgba(255,180,50,.9)' : 'rgba(255,255,255,0.12)';
      tog.querySelector('span').style.marginLeft = on ? '14px' : '2px';
    }
    document.getElementById('ramp-params').style.display = on ? '' : 'none';
    pSim.setParams({ ramp: on });
  }

  function projRampChange() {
    const angle  = +document.getElementById('sl-ramp-angle').value;
    const length = +document.getElementById('sl-ramp-length').value;
    const mu     = +document.getElementById('sl-ramp-mu').value / 100;
    document.getElementById('p-ramp-angle').textContent  = angle + '°';
    document.getElementById('p-ramp-length').textContent = length + ' м';
    document.getElementById('p-ramp-mu').textContent     = mu.toFixed(2);
    if (pSim) pSim.setParams({ rampAngle: angle, rampLength: length, rampMu: mu });
  }

  /* ── Feature 6: Planet UI ── */

  function projPlanetChange() {
    const sel = document.getElementById('sel-planet');
    if (!sel || !pSim) return;
    const planetId = sel.value;
    pSim.planetId = planetId;
    const pl = pSim.planets.find(p => p.id === planetId);
    if (pl) {
      pSim.g   = pl.g;
      pSim.rho = pl.rho;
      /* sync g slider */
      const gSl = document.getElementById('sl-g');
      if (gSl) {
        gSl.value = Math.min(+gSl.max, pl.g);
        document.getElementById('p-g').textContent = pl.g.toFixed(2) + ' м/с²';
      }
    }
    pSim._computePath();
    if (pSim.dualMode) pSim._computeP2Path();
    pSim._resetFX();
    pSim.draw();
    pSim._emit();
  }

  function projTogglePlanetCompare() {
    if (!pSim) return;
    pSim.planetCompare = !pSim.planetCompare;
    const on  = pSim.planetCompare;
    const btn = document.getElementById('proj-planet-compare-btn');
    if (btn) {
      btn.classList.toggle('active', on);
      btn.querySelector('span').textContent = on ? 'Сравн.планет: Вкл' : 'Сравн.планет: Выкл';
    }
    const panel = document.getElementById('proj-planet-compare-panel');
    if (panel) panel.style.display = on ? '' : 'none';
    pSim.draw();
  }

  function projPlanetCompareChange(idx, val) {
    if (!pSim) return;
    pSim.comparePlanets[idx] = val;
    pSim.draw();
  }

  /* ── Energy toggle: projectile ── */
  function projToggleEnergy() {
    if (!pSim) return;
    pSim._energyOn = !pSim._energyOn;
    const on  = pSim._energyOn;
    const btn = document.getElementById('proj-energy-btn');
    if (btn) {
      btn.classList.toggle('active', on);
      btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
    }
    if (!on) { pSim._frictionWork = 0; pSim._energyScale = 0; }
    pSim.draw();
  }

  /* ── collision ── */

