'use strict';

/* ═══════════════════════════════════════════════
   CollisionSim — 2D elastic/inelastic ball collision
   Conservation of momentum & energy demo
   ═══════════════════════════════════════════════ */

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

    /* physics params */
    this.m1    = 4;
    this.m2    = 4;
    this.v1    = 8;
    this.v2    = 8;
    this.angle = 0;
    this.e     = 1;
    this.speed = 1;   // sim speed multiplier (0.1 – 4)

    /* runtime */
    this.playing   = false;
    this._raf      = null;
    this._lastTs   = null;
    this._cooldown = 0;
    this._colCount = 0;

    /* balls */
    this._b = [];

    /* visual effects */
    this._sparks    = [];
    this._rings     = [];
    this._dust      = [];          // tiny debris cloud
    this._impactPt  = null;
    this._squish    = [null, null]; // per-ball squish {ts, nx, ny}
    this._launchTs  = null;         // when play() was called

    /* stats */
    this._snapBefore = null;
    this._snapAfter  = null;

    /* perfectly-inelastic merge state */
    this._merged      = false;
    this._mergeR      = 0;
    this._mergeNormal = null;   // {nx, ny} at moment of merge

    this.onUpdate    = null;
    this.onPlayPause = null;  // canvas click callback

    /* centre-of-mass trail */
    this._cmTrail = [];

    /* pre-collision ghost velocity arrows */
    this._ghostArrows = [];

    /* hover inspector */
    this._hoverBall = null;

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

    /* ── Energy bars widget ── */
    this._energyOn    = false;
    this._frictionWork = 0;   // cumulative KE lost in inelastic collisions
    this._energyScale  = 0;

    /* -- GraphPanel widget -- */
    this._graphsOn = false;
    this._graphUI  = null;

    /* ── TimeControl + MotionTrails ── */
    this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
    this._tcTrailColors = ['#06D6E0', '#F15BB5'];
    this.showTCTrails = false;

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

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

  /* ═══ public API ═══ */

  fit() {
    const r = this.c.parentElement.getBoundingClientRect();
    this.c.width  = r.width  || 700;
    this.c.height = r.height || 420;
  }

  getParams() { return { m1: this.m1, m2: this.m2, v1: this.v1, v2: this.v2, angle: this.angle, e: this.e }; }
  setParams(p) {
    if (p.m1    !== undefined) this.m1    = +p.m1;
    if (p.m2    !== undefined) this.m2    = +p.m2;
    if (p.v1    !== undefined) this.v1    = +p.v1;
    if (p.v2    !== undefined) this.v2    = +p.v2;
    if (p.angle !== undefined) this.angle = +p.angle;
    if (p.e     !== undefined) this.e     = +p.e;
    this.reset();
  }

  setSpeed(s) {
    this.speed = Math.max(0.1, Math.min(4, +s));
  }

  play() {
    if (this.playing) return;
    this.playing   = true;
    this._lastTs   = null;
    this._launchTs = performance.now();
    this._spawnLaunchFx();
    this._tick();
  }

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

  reset() {
    this.pause();
    this._sparks      = [];
    this._rings       = [];
    this._dust        = [];
    this._squish      = [null, null];
    this._impactPt    = null;
    this._launchTs    = null;
    this._merged      = false;
    this._mergeR      = 0;
    this._mergeNormal = null;
    this._cmTrail     = [];
    this._ghostArrows = [];
    this._initBalls();
    this.draw();
    this._emit();
  }

  stats() {
    if (this._b.length < 2) return { v1:0, v2:0, ke:0, p:0, colCount:0, before:null, after:null };
    const [b1, b2] = this._b;
    const v1 = Math.hypot(b1.vx, b1.vy), v2 = Math.hypot(b2.vx, b2.vy);
    const ke = 0.5 * b1.m * v1 * v1 + 0.5 * b2.m * v2 * v2;
    const px = b1.m * b1.vx + b2.m * b2.vx;
    const py = b1.m * b1.vy + b2.m * b2.vy;
    const p  = Math.hypot(px, py);
    return { v1, v2, ke, p, colCount: this._colCount,
             before: this._snapBefore, after: this._snapAfter };
  }

  /* ═══ init ═══ */

  _r(m) { return Math.max(16, Math.min(42, 12 + m * 2.2)); }

  _initBalls() {
    const W = this.c.width || 700, H = this.c.height || 420;
    const r1 = this._r(this.m1), r2 = this._r(this.m2);
    const gap = Math.max(r1 + r2 + 70, W * 0.30);
    const cx  = W / 2, cy = H / 2;
    const rad = (this.angle * Math.PI) / 180;
    const dy2 = Math.tan(rad) * (gap / 2);

    this._b = [
      { id:1, m:this.m1, r:r1, x:cx - gap/2, y:cy,
        vx:this.v1, vy:0, angle: 0, angVel: 0,
        color:'#9B5DE5', rgb:'155,93,229', trail:[],
        _trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color:'#9B5DE5', width:2.5, maxLen:120 }) : null },
      { id:2, m:this.m2, r:r2, x:cx + gap/2, y:cy + dy2,
        vx:-this.v2 * Math.cos(rad), vy:-this.v2 * Math.sin(rad), angle: 0, angVel: 0,
        color:'#06D6E0', rgb:'6,214,224', trail:[],
        _trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color:'#06D6E0', width:2.5, maxLen:120 }) : null },
    ];

    this._cooldown    = 0;
    this._colCount    = 0;
    this._snapBefore  = null;
    this._snapAfter   = null;
    this._frictionWork = 0;
    this._energyScale  = 0;
  }

  /* ═══ launch visual burst ═══ */

  _spawnLaunchFx() {
    const now = performance.now();
    for (const b of this._b) {
      /* expanding ring per ball */
      this._rings.push({ x:b.x, y:b.y, ts:now, kind:'launch',
                         life:700, maxR:b.r * 4, col:b.rgb });
      /* radial spark burst */
      for (let k = 0; k < 14; k++) {
        this._sparks.push({
          kind:'launch',
          ang: (k / 14) * Math.PI * 2,
          spd: 25 + Math.random() * 35,
          x: b.x, y: b.y, ts: now,
          col: b.rgb, life: 550,
        });
      }
    }
  }

  /* ═══ tick / step ═══ */

  _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);

      let dt;
      if (this._tc) {
        dt = this._tc.advance(rawDt * this.speed);
        if (dt === 0) { this.draw(); this._emit(); if (this.playing) this._tick(); return; }
      } else {
        dt = rawDt * this.speed;
      }
      this._tSim = (this._tSim || 0) + dt;

      /* tick ball motion trails */
      if (this.showTCTrails) {
        for (const b of this._b) { if (b._trail2) b._trail2.tick(); }
      }

      this._step(dt);
      this.draw();
      this._emit();
      if (window.LSGraphPanel && this._graphsOn && this._graphUI) {
        const [b1, b2] = this._b;
        this._graphUI.push(this._tSim || 0, [b1 ? Math.hypot(b1.vx, b1.vy) : 0, b2 ? Math.hypot(b2.vx, b2.vy) : 0, 0]);
      }
      if (this.playing) this._tick();
    });
  }

  _step(dt) {
    const W = this.c.width, H = this.c.height;
    const [b1, b2] = this._b;
    if (this._cooldown > 0) this._cooldown--;

    /* trails */
    for (const b of this._b) {
      b.trail.push({ x: b.x, y: b.y, spd: Math.hypot(b.vx, b.vy) });
      if (b.trail.length > 90) b.trail.shift();
      if (this.showTCTrails && b._trail2) b._trail2.push(b.x, b.y);
    }

    /* centre-of-mass trail */
    const M   = b1.m + b2.m;
    const cmx = (b1.m * b1.x + b2.m * b2.x) / M;
    const cmy = (b1.m * b1.y + b2.m * b2.y) / M;
    this._cmTrail.push({ x: cmx, y: cmy });
    if (this._cmTrail.length > 180) this._cmTrail.shift();

    /* integrate */
    for (const b of this._b) {
      b.x += b.vx * dt; b.y += b.vy * dt;
      b.angle += (b.angVel || 0) * dt;
      if (b.angVel) b.angVel *= 0.997; // rotational air drag
    }

    /* wall bounces — when merged, use combined radius and bounce both */
    if (this._merged) {
      const r = this._mergeR;
      const fx = [
        b1.x - r < 0   ? [b => { b.x = r;     b.vx =  Math.abs(b.vx); }, 'L'] : null,
        b1.x + r > W   ? [b => { b.x = W - r; b.vx = -Math.abs(b.vx); }, 'R'] : null,
        b1.y - r < 0   ? [b => { b.y = r;     b.vy =  Math.abs(b.vy); }, 'T'] : null,
        b1.y + r > H   ? [b => { b.y = H - r; b.vy = -Math.abs(b.vy); }, 'B'] : null,
      ].filter(Boolean);
      for (const [fn, side] of fx) {
        fn(b1); fn(b2); this._wallFx(b1, side);
      }
    } else {
      const eW = Math.max(0.5, this.e); // wall restitution (at least 0.5)
      const wallMu = 0.18;             // wall surface friction <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> spin
      for (const b of this._b) {
        if (b.x - b.r < 0)   { b.x = b.r;     const vn = Math.abs(b.vx); b.vx = vn * eW;  b.angVel -= b.vy * wallMu / b.r; this._wallFx(b, 'L'); }
        if (b.x + b.r > W)   { b.x = W - b.r; const vn = Math.abs(b.vx); b.vx = -vn * eW; b.angVel += b.vy * wallMu / b.r; this._wallFx(b, 'R'); }
        if (b.y - b.r < 0)   { b.y = b.r;     const vn = Math.abs(b.vy); b.vy = vn * eW;  b.angVel += b.vx * wallMu / b.r; this._wallFx(b, 'T'); }
        if (b.y + b.r > H)   { b.y = H - b.r; const vn = Math.abs(b.vy); b.vy = -vn * eW; b.angVel -= b.vx * wallMu / b.r; this._wallFx(b, 'B'); }
      }
    }

    /* ball–ball collision — skip when already merged */
    if (this._cooldown === 0 && !this._merged) {
      const dx   = b2.x - b1.x, dy = b2.y - b1.y;
      const dist = Math.hypot(dx, dy);
      const min  = b1.r + b2.r;

      if (dist < min && dist > 0.01) {
        const nx = dx / dist, ny = dy / dist;
        const dvn = (b1.vx - b2.vx) * nx + (b1.vy - b2.vy) * ny;

        if (dvn > 0) {
          if (this._colCount === 0) this._snapBefore = this._snapshot();

          /* save pre-collision ghost arrows */
          const _gnow = performance.now();
          this._ghostArrows = this._b.map(b => ({
            x: b.x, y: b.y, vx: b.vx, vy: b.vy, rgb: b.rgb, ts: _gnow,
          }));

          const isPerfectlyInelastic = this.e < 0.02;
          if (isPerfectlyInelastic) {
            /* ── MERGE: conservation of momentum, balls stick ── */
            const M = b1.m + b2.m;
            const mvx = (b1.m * b1.vx + b2.m * b2.vx) / M;
            const mvy = (b1.m * b1.vy + b2.m * b2.vy) / M;
            b1.vx = b2.vx = mvx;
            b1.vy = b2.vy = mvy;
            this._merged      = true;
            this._mergeR      = Math.sqrt(b1.r * b1.r + b2.r * b2.r);
            this._mergeNormal = { nx, ny };
          } else {
            const J = (1 + this.e) * dvn / (1 / b1.m + 1 / b2.m);
            b1.vx -= J * nx / b1.m;  b1.vy -= J * ny / b1.m;
            b2.vx += J * nx / b2.m;  b2.vy += J * ny / b2.m;

            /* tangential friction <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> spin */
            const tx = -ny, ty = nx;
            const vRelT = (b1.vx - b2.vx) * tx + (b1.vy - b2.vy) * ty;
            if (Math.abs(vRelT) > 0.5) {
              const frMu = 0.32;
              const jtMax = frMu * Math.abs(J);
              const jt = -Math.sign(vRelT) * Math.min(jtMax, Math.abs(vRelT) / (1/b1.m + 1/b2.m));
              b1.vx += jt * tx / b1.m;  b1.vy += jt * ty / b1.m;
              b2.vx -= jt * tx / b2.m;  b2.vy -= jt * ty / b2.m;
              const I1 = 0.4 * b1.m * b1.r * b1.r;
              const I2 = 0.4 * b2.m * b2.r * b2.r;
              b1.angVel -= jt * b1.r / I1;
              b2.angVel += jt * b2.r / I2;
            }
          }

          this._snapAfter = this._snapshot();
          /* track inelastic energy loss for energy bars */
          if (this._energyOn && this._snapBefore && this._snapAfter) {
            const sumKE = arr => arr.reduce((s, b) => s + b.ke, 0);
            const dKE = sumKE(this._snapBefore) - sumKE(this._snapAfter);
            if (dKE > 0) this._frictionWork += dKE;
          }
          this._colCount++;
          this._cooldown = 8;

          const ix = (b1.x + b2.x) / 2, iy = (b1.y + b2.y) / 2;
          this._spawnCollisionFx(ix, iy, nx, ny, dvn);

          /* squish (even on merge — visible one frame) */
          this._squish[0] = { ts: performance.now(), nx,  ny,  dv: dvn };
          this._squish[1] = { ts: performance.now(), nx: -nx, ny: -ny, dv: dvn };
        }

        /* overlap resolution */
        const ov = min - dist;
        b1.x -= nx * ov / 2;  b1.y -= ny * ov / 2;
        b2.x += nx * ov / 2;  b2.y += ny * ov / 2;
      }
    }

    /* if merged, lock both balls to centre-of-mass */
    if (this._merged) {
      const M = b1.m + b2.m;
      const cmx = (b1.m * b1.x + b2.m * b2.x) / M;
      const cmy = (b1.m * b1.y + b2.m * b2.y) / M;
      b1.x = b2.x = cmx;
      b1.y = b2.y = cmy;
      b2.vx = b1.vx;
      b2.vy = b1.vy;
      /* sync rotation of merged body */
      const avgAng = (b1.angVel * b1.m + b2.angVel * b2.m) / M;
      b1.angVel = b2.angVel = avgAng;
      b1.angle += avgAng * dt; b2.angle = b1.angle;
    }

    /* expire effects */
    const now = performance.now();
    this._rings  = this._rings.filter(r  => (now - r.ts)  < (r.life  || 900));
    this._sparks = this._sparks.filter(sp => (now - sp.ts) < (sp.life || 800));
    this._dust   = this._dust.filter(d   => (now - d.ts)   < 1400);
    for (let i = 0; i < 2; i++) {
      if (this._squish[i] && (now - this._squish[i].ts) > 300) this._squish[i] = null;
    }
  }

  _wallFx(b, side) {
    const now = performance.now();
    const count = 6;
    const baseAng = { L: 0, R: Math.PI, T: Math.PI / 2, B: -Math.PI / 2 }[side] ?? 0;
    for (let k = 0; k < count; k++) {
      this._sparks.push({
        kind: 'wall',
        ang:  baseAng + (Math.random() - 0.5) * Math.PI,
        spd:  10 + Math.random() * 20,
        x: b.x, y: b.y, ts: now,
        col: b.rgb, life: 400,
      });
    }
  }

  _spawnCollisionFx(ix, iy, nx, ny, dvn) {
    const now       = performance.now();
    const intensity = Math.min(1, dvn / 22);

    this._impactPt = { x: ix, y: iy, ts: now, intensity };

    /* LabFX: collision effects */
    if (window.LabFX) {
      LabFX.sound.play('bounce');
      LabFX.particles.emit({
        ctx: this.ctx, x: ix, y: iy,
        count: 12, color: '#FFF', speed: 90,
        spread: Math.PI * 2, life: 400,
        glow: true, shape: 'spark', size: 2,
      });
      LabFX.shake(this.c, { intensity: 3, durMs: 150 });
    }

    /* 4 expanding rings (different radii, colors, lifetimes) */
    const ringDefs = [
      { life:1400, col:'255,255,255',  maxR:160 },
      { life:1000, col:'155,93,229',   maxR: 90 },
      { life: 750, col:'6,214,224',    maxR: 65 },
      { life: 500, col:'241,91,181',   maxR: 42 },
    ];
    for (const rd of ringDefs) {
      this._rings.push({ x:ix, y:iy, ts:now, kind:'collision',
                         life:rd.life, col:rd.col, maxR:rd.maxR });
    }

    /* 40 sparks — multi-color, gravity arc */
    const pal = ['255,255,255','255,220,70','155,93,229','6,214,224','241,91,181'];
    for (let k = 0; k < 40; k++) {
      this._sparks.push({
        kind: 'collision',
        ang:  (k / 40) * Math.PI * 2 + (Math.random() - 0.5) * 0.35,
        spd:  (28 + Math.random() * 95) * (0.55 + intensity * 0.45),
        len:  0.3 + Math.random() * 0.7,
        x: ix, y: iy, ts: now,
        col:  pal[Math.floor(Math.random() * pal.length)],
        grav: 35 + Math.random() * 65,
        life: 900,
      });
    }

    /* dust cloud — tiny slow particles */
    for (let k = 0; k < 20; k++) {
      this._dust.push({
        ang:  Math.random() * Math.PI * 2,
        spd:  4 + Math.random() * 12,
        x: ix + (Math.random() - 0.5) * 6,
        y: iy + (Math.random() - 0.5) * 6,
        r:    1 + Math.random() * 2.5,
        ts:   now,
        grav: 8 + Math.random() * 18,
      });
    }
  }

  /* ═══ snapshot ═══ */

  _snapshot() {
    return this._b.map(b => {
      const spd = Math.hypot(b.vx, b.vy);
      return { m: b.m, vx: b.vx, vy: b.vy, spd, ke: 0.5 * b.m * spd * spd };
    });
  }

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

  toggleGraphs(canvasOuter) {
    if (!window.LSGraphPanelUI) return false;
    this._graphsOn = !this._graphsOn;
    if (this._graphsOn) {
      this._tSim = 0;
      this._graphUI = new GraphPanelUI(canvasOuter, {
        maxPoints: 400,
        traces: ['v1', 'v2', 'cm'],
        labels: ['|v1|', '|v2|', 'v_цм'],
        units: ['м/с', 'м/с', 'м/с'],
        colors: ['#9B5DE5', '#06D6E0', '#FFD166'],
        toggleBtnId: 'btn-coll-graphs',
        title: 'Скорости'
      });
      this._graphUI.isOn = true;
      this._graphUI._build();
    } else {
      if (this._graphUI) { this._graphUI._destroy(); this._graphUI = null; }
    }
    return this._graphsOn;
  }

  /* ═══ RENDER ═══ */

  draw() {
    const W = this.c.width, H = this.c.height;
    if (!W || !H || this._b.length < 2) return;
    const ctx = this.ctx;
    const now = performance.now();

    /* ── 1. Background ── */
    /* radial dark-violet base */
    const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.hypot(W, H) / 1.7);
    bg.addColorStop(0, '#130e22');
    bg.addColorStop(1, '#080812');
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, W, H);

    /* impact bg flash */
    if (this._impactPt) {
      const el = (now - this._impactPt.ts) / 220;
      if (el < 1) {
        const fa  = Math.pow(1 - el, 2) * (this._impactPt.intensity || 0.5) * 0.22;
        const fg  = ctx.createRadialGradient(
          this._impactPt.x, this._impactPt.y, 0,
          this._impactPt.x, this._impactPt.y, Math.hypot(W, H));
        fg.addColorStop(0,   `rgba(255,255,255,${fa})`);
        fg.addColorStop(0.4, `rgba(155,93,229,${fa * 0.5})`);
        fg.addColorStop(1,   'transparent');
        ctx.fillStyle = fg;
        ctx.fillRect(0, 0, W, H);
      }
    }

    /* ── 2. Grid ── */
    ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
    for (let x = 60; x < W; x += 60) {
      ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
    }
    for (let y = 60; y < H; y += 60) {
      ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
    }

    /* ── 3. Arena border (pulses on impact) ── */
    let borderAlpha = 0.18;
    if (this._impactPt) {
      const el = (now - this._impactPt.ts) / 500;
      if (el < 1) borderAlpha = 0.18 + (1 - el) * 0.55;
    }
    ctx.strokeStyle = `rgba(155,93,229,${borderAlpha})`;
    ctx.lineWidth = 2;
    ctx.strokeRect(2, 2, W - 4, H - 4);

    /* ── 4. Center dashes ── */
    ctx.strokeStyle = 'rgba(255,255,255,.06)'; ctx.lineWidth = 1;
    ctx.setLineDash([6, 7]);
    ctx.beginPath(); ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
    ctx.setLineDash([]);

    /* ── 5. Idle pulsing halos ── */
    if (!this.playing && this._colCount === 0) {
      const pulse = 0.5 + 0.5 * Math.sin(now / 420);
      for (const b of this._b) {
        const [r, g, bl] = b.rgb.split(',').map(Number);
        for (let ring = 0; ring < 2; ring++) {
          const ph = pulse * (ring === 0 ? 1 : 1 - pulse);
          const hg = ctx.createRadialGradient(b.x, b.y, b.r * (1 + ring * 0.7),
                                              b.x, b.y, b.r * (3.2 + ring));
          hg.addColorStop(0,   `rgba(${r},${g},${bl},${0.28 * ph})`);
          hg.addColorStop(0.5, `rgba(${r},${g},${bl},${0.08 * ph})`);
          hg.addColorStop(1,   'transparent');
          ctx.fillStyle = hg;
          ctx.beginPath();
          ctx.arc(b.x, b.y, b.r * (3.2 + ring), 0, Math.PI * 2);
          ctx.fill();
        }
      }
      /* dashed approach lines */
      for (const b of this._b) {
        const spd = Math.hypot(b.vx, b.vy);
        if (spd < 0.01) continue;
        const nx  = b.vx / spd, ny = b.vy / spd;
        const opa = 0.12 + 0.1 * Math.sin(now / 420);
        ctx.strokeStyle = `rgba(255,255,255,${opa})`;
        ctx.lineWidth   = 1.5;
        ctx.setLineDash([5, 5]);
        ctx.beginPath(); ctx.moveTo(b.x, b.y);
        ctx.lineTo(b.x + nx * 60, b.y + ny * 60); ctx.stroke();
        ctx.setLineDash([]);
      }
    }

    /* ── 6. Launch burst (first 600 ms after play()) ── */
    if (this._launchTs) {
      const le = Math.min(1, (now - this._launchTs) / 600);
      if (le < 1) {
        for (const b of this._b) {
          const [r, g, bl] = b.rgb.split(',').map(Number);
          /* expanding ring */
          const lR    = le * b.r * 5.5;
          const lAlph = (1 - le) * 0.75;
          ctx.strokeStyle = `rgba(${r},${g},${bl},${lAlph})`;
          ctx.lineWidth   = 3 * (1 - le);
          ctx.beginPath(); ctx.arc(b.x, b.y, lR, 0, Math.PI * 2); ctx.stroke();
          /* 8 radial spokes */
          for (let k = 0; k < 8; k++) {
            const ang  = (k / 8) * Math.PI * 2;
            const inner = b.r + 3;
            const outer = b.r + 3 + le * 48;
            ctx.strokeStyle = `rgba(${r},${g},${bl},${(1 - le) * 0.65})`;
            ctx.lineWidth   = 1.8 * (1 - le * 0.6);
            ctx.beginPath();
            ctx.moveTo(b.x + Math.cos(ang) * inner, b.y + Math.sin(ang) * inner);
            ctx.lineTo(b.x + Math.cos(ang) * outer, b.y + Math.sin(ang) * outer);
            ctx.stroke();
          }
        }
      }
    }

    /* ── 7. Trails (speed-colored: blue<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>yellow<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>red) ── */
    const _maxSpd = Math.max(this.v1, this.v2, 0.1) * 1.6;
    for (const b of this._b) {
      for (let i = 1; i < b.trail.length; i++) {
        const frac = i / b.trail.length;
        const spd  = b.trail[i].spd || 0;
        const tr   = frac * Math.min(8, 1.5 + spd * 0.18);
        /* hue: 220 (blue) <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 60 (yellow) <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 0 (red) */
        const t    = Math.min(1, spd / _maxSpd);
        const hue  = 220 - t * 220;
        const sat  = 80 + t * 20;
        ctx.fillStyle = `hsla(${hue},${sat}%,65%,${frac * 0.6})`;
        ctx.beginPath();
        ctx.arc(b.trail[i].x, b.trail[i].y, tr, 0, Math.PI * 2);
        ctx.fill();
      }
    }

    /* ── 7a. MotionTrail overlay ── */
    if (this.showTCTrails) {
      for (const b of this._b) { if (b._trail2) b._trail2.draw(ctx); }
    }

    /* ── 7b. Centre-of-mass trail ── */
    for (let i = 1; i < this._cmTrail.length; i++) {
      const frac = i / this._cmTrail.length;
      const p    = this._cmTrail[i];
      ctx.fillStyle = `rgba(255,255,255,${frac * 0.35})`;
      ctx.beginPath(); ctx.arc(p.x, p.y, 1.5 + frac, 0, Math.PI * 2); ctx.fill();
    }
    if (this._cmTrail.length > 0) {
      const cm = this._cmTrail[this._cmTrail.length - 1];
      ctx.strokeStyle = 'rgba(255,255,255,.65)'; ctx.lineWidth = 1.5;
      const cs = 7;
      ctx.beginPath();
      ctx.moveTo(cm.x - cs, cm.y); ctx.lineTo(cm.x + cs, cm.y);
      ctx.moveTo(cm.x, cm.y - cs); ctx.lineTo(cm.x, cm.y + cs);
      ctx.stroke();
      ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.arc(cm.x, cm.y, 5, 0, Math.PI * 2); ctx.stroke();
      ctx.fillStyle = 'rgba(255,255,255,.35)';
      ctx.font = '8px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
      ctx.fillText('CM', cm.x, cm.y + 8);
    }

    /* ── 8. Impact rings ── */
    for (const ring of this._rings) {
      const el   = (now - ring.ts) / ring.life;
      if (el >= 1) continue;
      const maxR = ring.maxR || 110;
      const ra   = Math.pow(1 - el, ring.kind === 'launch' ? 1.2 : 1.6);
      ctx.strokeStyle = `rgba(${ring.col},${ra * 0.7})`;
      ctx.lineWidth   = (ring.kind === 'launch' ? 1.5 : 2.8) * (1 - el * 0.75);
      ctx.beginPath(); ctx.arc(ring.x, ring.y, el * maxR, 0, Math.PI * 2); ctx.stroke();
    }

    /* ── 9. Sparks ── */
    ctx.lineCap = 'round';
    for (const sp of this._sparks) {
      const el = (now - sp.ts) / sp.life;
      if (el >= 1) continue;
      const sa   = Math.pow(1 - el, sp.kind === 'launch' ? 1.1 : 1.4);
      const dist = sp.spd * el;
      const grav = sp.grav ? sp.grav * el * el : 0;
      const ex   = sp.x + Math.cos(sp.ang) * dist;
      const ey   = sp.y + Math.sin(sp.ang) * dist + grav;
      const sx   = sp.x + Math.cos(sp.ang) * dist * 0.4;
      const sy   = sp.y + Math.sin(sp.ang) * dist * 0.4 + grav * 0.16;
      ctx.strokeStyle = `rgba(${sp.col},${sa * 0.92})`;
      ctx.lineWidth   = (sp.len || 1) * (sp.kind === 'launch' ? 1.2 : 2) * (1 - el * 0.4);
      ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
      /* bright tip */
      if (el < 0.45) {
        ctx.fillStyle = `rgba(255,255,255,${sa * 0.75})`;
        ctx.beginPath(); ctx.arc(ex, ey, 1.4, 0, Math.PI * 2); ctx.fill();
      }
    }
    ctx.lineCap = 'butt';

    /* ── 10. Dust cloud ── */
    for (const d of this._dust) {
      const el = (now - d.ts) / 1400;
      if (el >= 1) continue;
      const da  = Math.pow(1 - el, 2) * 0.55;
      const dist = d.spd * el;
      const grav = d.grav * el * el;
      ctx.fillStyle = `rgba(200,180,255,${da})`;
      ctx.beginPath();
      ctx.arc(d.x + Math.cos(d.ang) * dist,
              d.y + Math.sin(d.ang) * dist + grav,
              d.r * (1 + el * 0.5), 0, Math.PI * 2);
      ctx.fill();
    }

    /* ── 11. Central impact flash ── */
    if (this._impactPt) {
      const el = (now - this._impactPt.ts) / 320;
      if (el < 1) {
        const fa  = Math.pow(1 - el, 2.2);
        const fgR = 90 * (1 + el * 0.6);
        const fg  = ctx.createRadialGradient(
          this._impactPt.x, this._impactPt.y, 0,
          this._impactPt.x, this._impactPt.y, fgR);
        fg.addColorStop(0,    `rgba(255,255,255,${fa})`);
        fg.addColorStop(0.22, `rgba(255,215,80,${fa * 0.55})`);
        fg.addColorStop(0.6,  `rgba(155,93,229,${fa * 0.18})`);
        fg.addColorStop(1,    'transparent');
        ctx.fillStyle = fg;
        ctx.beginPath(); ctx.arc(this._impactPt.x, this._impactPt.y, fgR, 0, Math.PI * 2);
        ctx.fill();
      }
    }

    /* ── 12. Velocity arrows ── */
    for (const b of this._b) {
      const [r, g, bl] = b.rgb.split(',').map(Number);
      const spd = Math.hypot(b.vx, b.vy);
      if (spd < 0.05) continue;
      const pLen = Math.min(68, spd * b.m * 0.75 + 8);
      const nx   = b.vx / spd, ny = b.vy / spd;
      const ox   = nx * (b.r + 7),  oy = ny * (b.r + 7);
      _colArrow(ctx,
        b.x + ox,           b.y + oy,
        b.x + ox + nx*pLen, b.y + oy + ny*pLen,
        b.color, 2.5);
      ctx.save();
      ctx.shadowColor = b.color; ctx.shadowBlur = 5;
      ctx.fillStyle = `rgba(${r},${g},${bl},.9)`;
      ctx.font = 'bold 10px Manrope';
      ctx.textAlign    = nx > 0 ? 'left' : 'right';
      ctx.textBaseline = 'middle';
      ctx.fillText(spd.toFixed(1) + ' м/с',
        b.x + ox + nx * (pLen + 8),
        b.y + oy + ny * (pLen + 8));
      ctx.restore();
    }

    /* ── 12b. Ghost velocity arrows (pre-collision, fade out 1.5 s) ── */
    if (this._ghostArrows.length > 0) {
      const ghostAge = (now - this._ghostArrows[0].ts) / 1500;
      if (ghostAge < 1) {
        const alpha = Math.pow(1 - ghostAge, 1.5) * 0.5;
        for (const ga of this._ghostArrows) {
          const spd = Math.hypot(ga.vx, ga.vy);
          if (spd < 0.1) continue;
          const pLen = Math.min(64, spd * 2.5 + 8);
          const nx = ga.vx / spd, ny = ga.vy / spd;
          ctx.save();
          ctx.globalAlpha = alpha;
          ctx.strokeStyle = `rgba(${ga.rgb},.9)`;
          ctx.lineWidth = 2; ctx.setLineDash([5, 4]);
          ctx.beginPath();
          ctx.moveTo(ga.x, ga.y);
          ctx.lineTo(ga.x + nx * pLen, ga.y + ny * pLen);
          ctx.stroke();
          ctx.setLineDash([]);
          ctx.restore();
        }
      } else {
        this._ghostArrows = [];
      }
    }

    /* ── 12c. ΔKE loss badge near impact ── */
    if (this._snapBefore && this._snapAfter && this._impactPt) {
      const keBefore = this._snapBefore.reduce((s, b) => s + b.ke, 0);
      const keAfter  = this._snapAfter.reduce((s,  b) => s + b.ke, 0);
      const lossPct  = keBefore > 0.1 ? Math.round((1 - keAfter / keBefore) * 100) : 0;
      if (lossPct > 0) {
        const ix = this._impactPt.x, iy = this._impactPt.y - 42;
        const label = 'ΔKE −' + lossPct + '%';
        ctx.font = 'bold 10px Manrope';
        const tw = ctx.measureText(label).width;
        ctx.fillStyle = 'rgba(239,71,111,.18)';
        _roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.fill();
        ctx.strokeStyle = 'rgba(239,71,111,.4)'; ctx.lineWidth = 1;
        _roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.stroke();
        ctx.fillStyle = '#EF476F';
        ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
        ctx.fillText(label, ix, iy);
      } else if (lossPct === 0 && keBefore > 0.1) {
        const ix = this._impactPt.x, iy = this._impactPt.y - 42;
        const label = 'KE сохранена ✓';
        ctx.font = 'bold 10px Manrope';
        const tw = ctx.measureText(label).width;
        ctx.fillStyle = 'rgba(123,245,164,.15)';
        _roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.fill();
        ctx.strokeStyle = 'rgba(123,245,164,.4)'; ctx.lineWidth = 1;
        _roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.stroke();
        ctx.fillStyle = '#7BF5A4';
        ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
        ctx.fillText(label, ix, iy);
      }
    }

    /* ── 12d. Merged-body special rendering ── */
    if (this._merged && this._mergeNormal) {
      const mn  = this._mergeNormal;
      const b1_ = this._b[0], b2_ = this._b[1];
      const cx  = b1_.x, cy = b1_.y;          // both at same CM pos
      const off = (b1_.r + b2_.r) * 0.52;      // visual separation

      /* connection beam — shimmering gradient bar */
      const bx1 = cx - mn.nx * off, by1 = cy - mn.ny * off;
      const bx2 = cx + mn.nx * off, by2 = cy + mn.ny * off;
      const pulse = 0.55 + 0.45 * Math.sin(now / 200);
      const beamG = ctx.createLinearGradient(bx1, by1, bx2, by2);
      beamG.addColorStop(0,   `rgba(155,93,229,${pulse * 0.9})`);
      beamG.addColorStop(0.5, `rgba(255,255,255,${pulse * 0.55})`);
      beamG.addColorStop(1,   `rgba(6,214,224,${pulse * 0.9})`);
      ctx.save();
      ctx.strokeStyle = beamG;
      ctx.lineWidth   = Math.max(b1_.r, b2_.r) * 0.7;
      ctx.lineCap     = 'round';
      ctx.shadowColor = '#fff'; ctx.shadowBlur = 18 * pulse;
      ctx.globalAlpha = 0.7;
      ctx.beginPath(); ctx.moveTo(bx1, by1); ctx.lineTo(bx2, by2); ctx.stroke();
      ctx.restore();

      /* outer merged-body glow */
      const gloBig = ctx.createRadialGradient(cx, cy, this._mergeR * 0.3,
                                               cx, cy, this._mergeR * 3.2);
      gloBig.addColorStop(0,   `rgba(255,220,100,${0.2 * pulse})`);
      gloBig.addColorStop(0.4, `rgba(155,93,229,${0.1 * pulse})`);
      gloBig.addColorStop(1,   'transparent');
      ctx.save(); ctx.fillStyle = gloBig;
      ctx.beginPath(); ctx.arc(cx, cy, this._mergeR * 3.2, 0, Math.PI * 2); ctx.fill();
      ctx.restore();
    }

    /* ── 13. Balls (with squish deform) ── */
    for (let i = 0; i < this._b.length; i++) {
      const b  = this._b[i];
      const [r, g, bl] = b.rgb.split(',').map(Number);
      const sq = this._squish[i];

      /* visual offset when merged — draw at slightly separated positions */
      let drawX = b.x, drawY = b.y;
      if (this._merged && this._mergeNormal) {
        const mn  = this._mergeNormal;
        const off = (this._b[0].r + this._b[1].r) * 0.52;
        const sign = i === 0 ? -1 : 1;
        drawX = b.x + sign * mn.nx * off;
        drawY = b.y + sign * mn.ny * off;
      }

      /* outer glow */
      const glo = ctx.createRadialGradient(drawX, drawY, b.r * 0.2, drawX, drawY, b.r * 3.4);
      glo.addColorStop(0,   `rgba(${r},${g},${bl},.55)`);
      glo.addColorStop(0.35,`rgba(${r},${g},${bl},.16)`);
      glo.addColorStop(1,   'transparent');
      ctx.fillStyle = glo;
      ctx.beginPath(); ctx.arc(drawX, drawY, b.r * 3.4, 0, Math.PI * 2); ctx.fill();

      /* squish transform */
      let sqEl   = 0;
      let sqAngle = 0;
      if (sq) {
        sqEl    = Math.min(1, (now - sq.ts) / 300);
        sqAngle = Math.atan2(sq.ny, sq.nx);
      }

      ctx.save();
      ctx.translate(drawX, drawY);
      if (sqEl > 0 && sqEl < 1) {
        const squeeze = 1 - 0.38 * Math.sin(sqEl * Math.PI);
        ctx.rotate(sqAngle);
        ctx.scale(1 + (1 - squeeze) * 0.5, squeeze);
        ctx.rotate(-sqAngle);
      }

      /* body */
      const bodyG = ctx.createRadialGradient(
        -b.r * 0.33, -b.r * 0.33, b.r * 0.06,
         0, 0, b.r);
      bodyG.addColorStop(0,    '#ffffff');
      bodyG.addColorStop(0.18, b.color);
      bodyG.addColorStop(1,    `rgba(${Math.round(r*0.3)},${Math.round(g*0.3)},${Math.round(bl*0.3)},1)`);
      ctx.fillStyle = bodyG;
      ctx.beginPath(); ctx.arc(0, 0, b.r, 0, Math.PI * 2); ctx.fill();

      /* rim */
      ctx.strokeStyle = 'rgba(255,255,255,.5)'; ctx.lineWidth = 1.5;
      ctx.stroke();

      /* rotation indicator */
      const bAng = b.angle || 0;
      ctx.strokeStyle = 'rgba(255,255,255,.18)'; ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.lineTo(Math.cos(bAng) * b.r * 0.78, Math.sin(bAng) * b.r * 0.78);
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(Math.cos(bAng) * b.r * 0.58, Math.sin(bAng) * b.r * 0.58, 2.2, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(255,255,255,.28)'; ctx.fill();

      /* primary highlight */
      ctx.fillStyle = 'rgba(255,255,255,.25)';
      ctx.beginPath();
      ctx.ellipse(-b.r * 0.3, -b.r * 0.3, b.r * 0.35, b.r * 0.2, -0.6, 0, Math.PI * 2);
      ctx.fill();

      /* secondary glint */
      ctx.fillStyle = 'rgba(255,255,255,.1)';
      ctx.beginPath();
      ctx.ellipse(b.r * 0.22, b.r * 0.28, b.r * 0.14, b.r * 0.07, 0.9, 0, Math.PI * 2);
      ctx.fill();

      ctx.restore();

      /* mass label (not squished) */
      ctx.fillStyle = 'rgba(255,255,255,.95)';
      ctx.font = `bold ${Math.max(10, Math.round(b.r * 0.60))}px Manrope`;
      ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      ctx.fillText(b.m + ' кг', drawX, drawY);
    }

    /* ── 14. Total momentum vector from CoM ── */
    const [b1, b2] = this._b;
    const px   = b1.m * b1.vx + b2.m * b2.vx;
    const py   = b1.m * b1.vy + b2.m * b2.vy;
    const pMag = Math.hypot(px, py);
    if (pMag > 0.1) {
      const cmx  = (b1.m * b1.x + b2.m * b2.x) / (b1.m + b2.m);
      const cmy  = (b1.m * b1.y + b2.m * b2.y) / (b1.m + b2.m);
      const pLen = Math.min(68, pMag * 1.5);
      const pnx  = px / pMag, pny = py / pMag;
      ctx.save(); ctx.globalAlpha = 0.42;
      _colArrow(ctx, cmx, cmy, cmx + pnx * pLen, cmy + pny * pLen, '#F15BB5', 1.5);
      ctx.restore();
      ctx.fillStyle = 'rgba(241,91,181,.5)';
      ctx.font = '9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
      ctx.fillText('p⃗ = ' + pMag.toFixed(1), cmx + pnx * (pLen / 2 + 12), cmy + pny * (pLen / 2));
    }

    /* ── 15. Collision count badge ── */
    if (this._colCount > 0) {
      /* badge bg pill */
      const txt = 'Столкновений: ' + this._colCount;
      ctx.font = 'bold 11px Manrope';
      const tw  = ctx.measureText(txt).width;
      const bx  = W - 14 - tw, by = 8;
      ctx.fillStyle = 'rgba(255,200,50,.15)';
      _roundRect(ctx, bx - 8, by - 2, tw + 16, 20, 6);
      ctx.fill();
      ctx.strokeStyle = 'rgba(255,200,50,.3)'; ctx.lineWidth = 1;
      _roundRect(ctx, bx - 8, by - 2, tw + 16, 20, 6);
      ctx.stroke();
      ctx.fillStyle = 'rgba(255,200,50,.95)';
      ctx.textAlign = 'left'; ctx.textBaseline = 'top';
      ctx.fillText(txt, bx, by);
    }

    /* ── 15b. СЛИПАНИЕ badge ── */
    if (this._merged) {
      const pulse2 = 0.65 + 0.35 * Math.sin(now / 380);
      const txt2 = 'СЛИПАНИЕ';
      ctx.font = 'bold 12px Manrope';
      const tw2 = ctx.measureText(txt2).width;
      ctx.fillStyle = `rgba(255,220,100,${0.22 * pulse2})`;
      _roundRect(ctx, W/2 - tw2/2 - 12, 8, tw2 + 24, 22, 8); ctx.fill();
      ctx.strokeStyle = `rgba(255,220,100,${0.55 * pulse2})`; ctx.lineWidth = 1.5;
      _roundRect(ctx, W/2 - tw2/2 - 12, 8, tw2 + 24, 22, 8); ctx.stroke();
      ctx.fillStyle = `rgba(255,220,100,${pulse2})`;
      ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      ctx.fillText(txt2, W/2, 19);
    }

    /* ── 16. Speed badge (top-left when speed ≠ 1×) ── */
    if (Math.abs(this.speed - 1) > 0.05) {
      const label = this.speed.toFixed(2) + '×';
      ctx.font = 'bold 11px Manrope';
      const tw  = ctx.measureText(label).width;
      ctx.fillStyle = 'rgba(6,214,224,.12)';
      _roundRect(ctx, 8, 8, tw + 14, 20, 6); ctx.fill();
      ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1;
      _roundRect(ctx, 8, 8, tw + 14, 20, 6); ctx.stroke();
      ctx.fillStyle = 'rgba(6,214,224,.9)';
      ctx.textAlign = 'left'; ctx.textBaseline = 'top';
      ctx.fillText(label, 15, 10);
    }

    /* ── 17. Hover ball tooltip ── */
    if (this._hoverBall) {
      this._drawBallTooltip(ctx, this._hoverBall, W, H);
    }

    /* ── 18. FBD velocity / impulse overlays ── */
    if (this._fbdOn && window.LSPhysFX) {
      for (const b of this._b) {
        const spd = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
        if (spd > 0.5) {
          const vLen = Math.min(60, spd * 3);
          LSPhysFX.drawForceArrow(ctx, b.x, b.y - b.r - 8,
            (b.vx / spd) * vLen, (b.vy / spd) * vLen,
            'velocity', 'v=' + spd.toFixed(1));
        }
      }
      /* impulse flash at impact */
      if (this._impactPt) {
        const iEl = (performance.now() - this._impactPt.ts) / 300;
        if (iEl < 1) {
          const ip = this._impactPt;
          LSPhysFX.drawForceArrow(ctx, ip.x, ip.y, 0, -40 * (1 - iEl), 'impulse', 'J');
        }
      }
    }

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

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

  /* ── Energy bars: collision (1D) ── */

  _drawEnergyBarsColl(ctx, W, H) {
    var ke = 0;
    for (var i = 0; i < this._b.length; i++) {
      var b = this._b[i];
      var spd = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
      ke += 0.5 * b.m * spd * spd;
    }
    var tot = ke + this._frictionWork;
    if (tot > this._energyScale) this._energyScale = tot;
    var PW = 188, MARGIN = 12;
    LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0,
      { ke: ke, friction: this._frictionWork, total: this._energyScale }, {});
  }

  /* ── hover inspector ── */

  _onMouseMove(e) {
    const r  = this.c.getBoundingClientRect();
    const mx = (e.clientX - r.left) * (this.c.width  / r.width);
    const my = (e.clientY - r.top)  * (this.c.height / r.height);
    let found = null;
    for (const b of this._b) {
      if (Math.hypot(mx - b.x, my - b.y) < b.r + 18) { found = b; break; }
    }
    if (found !== this._hoverBall) { this._hoverBall = found; this.draw(); }
  }

  _drawBallTooltip(ctx, b, W, H) {
    const [r, g, bl] = b.rgb.split(',').map(Number);
    const spd = Math.hypot(b.vx, b.vy);
    const ke  = 0.5 * b.m * spd * spd;
    const p   = b.m * spd;
    const ang = Math.atan2(b.vy, b.vx) * 180 / Math.PI;

    const rows = [
      { label: 'Масса m', val: b.m + ' кг',                     color: b.color },
      { label: '|v|',     val: spd.toFixed(2) + ' м/с',          color: '#ffffff' },
      { label: 'vx',      val: b.vx.toFixed(2) + ' м/с',         color: '#06D6E0' },
      { label: 'vy',      val: b.vy.toFixed(2) + ' м/с',         color: '#9B5DE5' },
      { label: 'KE',      val: ke.toFixed(1) + ' Дж',            color: '#FFD166' },
      { label: 'p = mv',  val: p.toFixed(1) + ' кг·м/с',        color: '#F15BB5' },
      { label: 'угол',    val: ang.toFixed(1) + '°',             color: 'rgba(255,255,255,.55)' },
      { label: 'ω',       val: (b.angVel || 0).toFixed(1) + ' рад/с', color: '#F15BB5' },
    ];

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

    let tx = b.x + b.r + 14, ty = b.y - th / 2;
    if (tx + tw > W - 8) tx = b.x - b.r - tw - 14;
    if (ty < 8)          ty = 8;
    if (ty + th > H - 8) ty = H - th - 8;

    ctx.save();
    ctx.shadowColor = 'rgba(0,0,0,.65)'; ctx.shadowBlur = 14;
    ctx.fillStyle   = 'rgba(8,8,18,.93)';
    ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.fill();
    ctx.restore();

    ctx.strokeStyle = `rgba(${r},${g},${bl},.5)`; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.stroke();

    ctx.strokeStyle = `rgba(${r},${g},${bl},.8)`; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(tx + 9, ty + 1); ctx.lineTo(tx + tw - 9, ty + 1); ctx.stroke();

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

/* ═══════════════════════════════════════════════
   Collision2DSim — 2D angle collision (mode 2D)
   ═══════════════════════════════════════════════ */
class Collision2DSim {
  constructor(canvas) {
    this.c   = canvas;
    this.ctx = canvas.getContext('2d');
    this.m1 = 4; this.m2 = 4;
    this.v1 = 8; this.v2 = 8;
    this.a1 = 0; this.a2 = 180;  // angles in degrees
    this.e  = 1;
    this.speed = 1;
    this.cmFrame = false;   // centre-of-mass reference frame toggle

    this.playing    = false;
    this._raf       = null;
    this._lastTs    = null;
    this._b         = [];
    this._sparks    = [];
    this._rings     = [];
    this._colCount  = 0;
    this._cooldown  = 0;
    this._impactPt  = null;
    this._snapBefore = null;
    this._snapAfter  = null;
    this.onUpdate   = null;
    this.onPlayPause = null;

    this._activeMode = false;
    canvas.addEventListener('click',     () => { if (this._activeMode && this.onPlayPause) this.onPlayPause(); });
    new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement);
  }

  fit() {
    const r = this.c.parentElement.getBoundingClientRect();
    this.c.width  = r.width  || 700;
    this.c.height = r.height || 420;
  }

  getParams() { return { m1:this.m1, m2:this.m2, v1:this.v1, v2:this.v2, a1:this.a1, a2:this.a2, e:this.e }; }
  setParams(p) {
    if (p.m1 !== undefined) this.m1 = +p.m1;
    if (p.m2 !== undefined) this.m2 = +p.m2;
    if (p.v1 !== undefined) this.v1 = +p.v1;
    if (p.v2 !== undefined) this.v2 = +p.v2;
    if (p.a1 !== undefined) this.a1 = +p.a1;
    if (p.a2 !== undefined) this.a2 = +p.a2;
    if (p.e  !== undefined) this.e  = +p.e;
    this.reset();
  }
  setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); }

  _r(m) { return Math.max(16, Math.min(42, 12 + m * 2.2)); }

  _initBalls() {
    const W = this.c.width || 700, H = this.c.height || 420;
    const r1 = this._r(this.m1), r2 = this._r(this.m2);
    const cx = W / 2, cy = H / 2;
    const gap = Math.max(r1 + r2 + 80, W * 0.35);
    const rad1 = this.a1 * Math.PI / 180;
    const rad2 = this.a2 * Math.PI / 180;

    this._b = [
      { id:1, m:this.m1, r:r1,
        x: cx - gap/2 * Math.cos(rad1), y: cy - gap/2 * Math.sin(rad1),
        vx: this.v1 * Math.cos(rad1),   vy: this.v1 * Math.sin(rad1),
        color:'#9B5DE5', rgb:'155,93,229', trail:[] },
      { id:2, m:this.m2, r:r2,
        x: cx - gap/2 * Math.cos(rad2), y: cy - gap/2 * Math.sin(rad2),
        vx: this.v2 * Math.cos(rad2),   vy: this.v2 * Math.sin(rad2),
        color:'#06D6E0', rgb:'6,214,224', trail:[] },
    ];
    this._cooldown = 0;
    this._colCount = 0;
    this._snapBefore = null;
    this._snapAfter  = null;
    this._sparks = [];
    this._rings  = [];
    this._impactPt = null;
  }

  play() {
    if (this.playing) return;
    this.playing = true; this._lastTs = null;
    this._tick();
  }
  pause() {
    this.playing = false;
    if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
  }
  reset() {
    this.pause();
    this._sparks = []; this._rings = []; this._impactPt = null;
    this._initBalls(); this.draw(); this._emit();
  }

  stats() {
    if (this._b.length < 2) return { colCount:0, before:null, after:null };
    return { colCount: this._colCount, before: this._snapBefore, after: this._snapAfter };
  }

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

  _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;
      const dt = rawDt * this.speed;
      if (window.LabFX) LabFX.particles.update(rawDt);
      this._step(dt);
      this.draw();
      this._emit();
      if (this.playing) this._tick();
    });
  }

  _step(dt) {
    const W = this.c.width, H = this.c.height;
    if (this._cooldown > 0) this._cooldown--;

    /* trails */
    for (const b of this._b) {
      b.trail.push({ x:b.x, y:b.y });
      if (b.trail.length > 80) b.trail.shift();
    }

    /* integrate */
    for (const b of this._b) { b.x += b.vx * dt; b.y += b.vy * dt; }

    /* wall bounces */
    const eW = Math.max(0.5, this.e);
    for (const b of this._b) {
      if (b.x - b.r < 0)   { b.x = b.r;     b.vx =  Math.abs(b.vx) * eW; _c2dWallFx(this, b, 'L'); }
      if (b.x + b.r > W)   { b.x = W - b.r; b.vx = -Math.abs(b.vx) * eW; _c2dWallFx(this, b, 'R'); }
      if (b.y - b.r < 0)   { b.y = b.r;     b.vy =  Math.abs(b.vy) * eW; _c2dWallFx(this, b, 'T'); }
      if (b.y + b.r > H)   { b.y = H - b.r; b.vy = -Math.abs(b.vy) * eW; _c2dWallFx(this, b, 'B'); }
    }

    /* ball-ball collision */
    if (this._cooldown === 0) {
      const [b1, b2] = this._b;
      const dx = b2.x - b1.x, dy = b2.y - b1.y;
      const dist = Math.hypot(dx, dy);
      const min  = b1.r + b2.r;
      if (dist < min && dist > 0.01) {
        const nx = dx / dist, ny = dy / dist;
        // relative velocity along normal
        const dvn = (b1.vx - b2.vx) * nx + (b1.vy - b2.vy) * ny;
        if (dvn > 0) {
          if (this._colCount === 0) this._snapBefore = this._snapshot();

          /* 2D elastic/inelastic: decompose into normal + tangential */
          // v_n1, v_n2 (scalars along n̂)
          const v1n = b1.vx * nx + b1.vy * ny;
          const v2n = b2.vx * nx + b2.vy * ny;
          // tangential components (unchanged)
          const v1tx = b1.vx - v1n * nx, v1ty = b1.vy - v1n * ny;
          const v2tx = b2.vx - v2n * nx, v2ty = b2.vy - v2n * ny;
          // new normal velocities (1D formula with e)
          const M = b1.m + b2.m;
          const v1nNew = ((b1.m - b2.m * this.e) * v1n + b2.m * (1 + this.e) * v2n) / M;
          const v2nNew = ((b2.m - b1.m * this.e) * v2n + b1.m * (1 + this.e) * v1n) / M;
          b1.vx = v1tx + v1nNew * nx; b1.vy = v1ty + v1nNew * ny;
          b2.vx = v2tx + v2nNew * nx; b2.vy = v2ty + v2nNew * ny;

          this._snapAfter = this._snapshot();
          this._colCount++;
          this._cooldown = 8;
          const ix = (b1.x + b2.x) / 2, iy = (b1.y + b2.y) / 2;
          this._impactPt = { x:ix, y:iy, ts:performance.now() };
          _c2dSpawnFx(this, ix, iy);
        }
        /* overlap fix */
        const ov = min - dist;
        b1.x -= nx * ov / 2; b1.y -= ny * ov / 2;
        b2.x += nx * ov / 2; b2.y += ny * ov / 2;
      }
    }

    const now = performance.now();
    this._rings  = this._rings.filter(r => (now - r.ts) < (r.life || 900));
    this._sparks = this._sparks.filter(s => (now - s.ts) < (s.life || 800));
  }

  _snapshot() {
    return this._b.map(b => {
      const spd = Math.hypot(b.vx, b.vy);
      return { m:b.m, vx:b.vx, vy:b.vy, spd, ke: 0.5 * b.m * spd * spd };
    });
  }

  draw() {
    const W = this.c.width, H = this.c.height;
    if (!W || !H || this._b.length < 2) return;
    const ctx = this.ctx;
    const now = performance.now();

    /* bg */
    const bg = ctx.createRadialGradient(W/2,H/2,0, W/2,H/2, Math.hypot(W,H)/1.7);
    bg.addColorStop(0,'#130e22'); bg.addColorStop(1,'#080812');
    ctx.fillStyle = bg; ctx.fillRect(0,0,W,H);

    /* grid */
    ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1;
    for (let x = 60; x < W; x += 60) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
    for (let y = 60; y < H; y += 60) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
    ctx.strokeStyle = 'rgba(155,93,229,.18)'; ctx.lineWidth = 2;
    ctx.strokeRect(2,2,W-4,H-4);

    /* rings */
    for (const ring of this._rings) {
      const el = (now - ring.ts) / ring.life;
      if (el >= 1) continue;
      ctx.strokeStyle = `rgba(${ring.col},${Math.pow(1-el,1.6) * 0.7})`;
      ctx.lineWidth = 2.8 * (1 - el * 0.75);
      ctx.beginPath(); ctx.arc(ring.x, ring.y, el * (ring.maxR || 90), 0, Math.PI*2); ctx.stroke();
    }

    /* sparks */
    ctx.lineCap = 'round';
    for (const sp of this._sparks) {
      const el = (now - sp.ts) / sp.life;
      if (el >= 1) continue;
      const dist = sp.spd * el;
      const grav = (sp.grav || 0) * el * el;
      const ex = sp.x + Math.cos(sp.ang) * dist;
      const ey = sp.y + Math.sin(sp.ang) * dist + grav;
      ctx.strokeStyle = `rgba(${sp.col},${Math.pow(1-el,1.4) * 0.9})`;
      ctx.lineWidth = 2 * (1 - el * 0.4);
      ctx.beginPath(); ctx.moveTo(sp.x, sp.y); ctx.lineTo(ex, ey); ctx.stroke();
    }
    ctx.lineCap = 'butt';

    /* trails */
    for (const b of this._b) {
      for (let i = 1; i < b.trail.length; i++) {
        const frac = i / b.trail.length;
        ctx.fillStyle = `rgba(${b.rgb},${frac * 0.45})`;
        ctx.beginPath();
        ctx.arc(b.trail[i].x, b.trail[i].y, frac * 4, 0, Math.PI*2);
        ctx.fill();
      }
    }

    /* balls */
    for (const b of this._b) {
      const [r, g, bl] = b.rgb.split(',').map(Number);
      /* glow */
      const glo = ctx.createRadialGradient(b.x,b.y, b.r*0.2, b.x,b.y, b.r*3.2);
      glo.addColorStop(0, `rgba(${r},${g},${bl},.5)`);
      glo.addColorStop(1, 'transparent');
      ctx.fillStyle = glo; ctx.beginPath(); ctx.arc(b.x,b.y, b.r*3.2, 0, Math.PI*2); ctx.fill();
      /* body */
      const bodyG = ctx.createRadialGradient(-b.r*0.33+b.x, -b.r*0.33+b.y, b.r*0.06, b.x, b.y, b.r);
      bodyG.addColorStop(0,'#ffffff'); bodyG.addColorStop(0.18, b.color);
      bodyG.addColorStop(1, `rgba(${Math.round(r*0.3)},${Math.round(g*0.3)},${Math.round(bl*0.3)},1)`);
      ctx.fillStyle = bodyG; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill();
      ctx.strokeStyle='rgba(255,255,255,.5)'; ctx.lineWidth=1.5; ctx.stroke();
      /* label */
      ctx.fillStyle='rgba(255,255,255,.95)';
      ctx.font = `bold ${Math.max(10,Math.round(b.r*0.6))}px Manrope`;
      ctx.textAlign='center'; ctx.textBaseline='middle';
      ctx.fillText(b.m + ' кг', b.x, b.y);
      /* velocity arrow */
      const spd = Math.hypot(b.vx, b.vy);
      if (spd > 0.05) {
        const nx = b.vx/spd, ny = b.vy/spd;
        const pLen = Math.min(68, spd * b.m * 0.75 + 8);
        _colArrow(ctx, b.x+nx*(b.r+7), b.y+ny*(b.r+7),
                       b.x+nx*(b.r+7+pLen), b.y+ny*(b.r+7+pLen), b.color, 2.5);
      }
    }

    /* HUD — momentum px,py + KE before/after */
    _c2dDrawHUD(ctx, this, W, H, now);

    /* CM frame label */
    if (this.cmFrame) {
      ctx.fillStyle = 'rgba(255,200,50,.7)';
      ctx.font = 'bold 11px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
      ctx.fillText('Система ЦМ', W/2, 8);
    }

    if (window.LabFX) LabFX.particles.draw(this.ctx);
  }
}

/* ═══ Collision2DSim helpers ═══ */
function _c2dWallFx(sim, b, side) {
  const now = performance.now();
  const baseAng = { L:0, R:Math.PI, T:Math.PI/2, B:-Math.PI/2 }[side] ?? 0;
  for (let k = 0; k < 6; k++) {
    sim._sparks.push({ ang: baseAng + (Math.random()-0.5)*Math.PI, spd: 10+Math.random()*20,
      x:b.x, y:b.y, ts:now, col:b.rgb, life:400 });
  }
}

function _c2dSpawnFx(sim, ix, iy) {
  const now = performance.now();
  if (window.LabFX) { LabFX.sound.play('bounce'); }
  const pal = ['255,255,255','255,220,70','155,93,229','6,214,224','241,91,181'];
  for (let k = 0; k < 30; k++) {
    sim._sparks.push({ ang:(k/30)*Math.PI*2+(Math.random()-0.5)*0.4, spd:25+Math.random()*75,
      x:ix, y:iy, ts:now, col:pal[k%pal.length], life:800, grav:30+Math.random()*50 });
  }
  const ringDefs = [
    {life:1200,col:'255,255,255',maxR:130},{life:800,col:'155,93,229',maxR:80},{life:600,col:'6,214,224',maxR:55}
  ];
  for (const rd of ringDefs) sim._rings.push({ x:ix, y:iy, ts:now, life:rd.life, col:rd.col, maxR:rd.maxR });
}

function _c2dDrawHUD(ctx, sim, W, H, now) {
  const before = sim._snapBefore, after = sim._snapAfter;
  const rows = [];
  if (before) {
    const px = before.reduce((s,b)=>s+b.m*b.vx,0), py = before.reduce((s,b)=>s+b.m*b.vy,0);
    const ke = before.reduce((s,b)=>s+b.ke,0);
    rows.push({ label:'p_x до:', val: px.toFixed(2) + ' кг·м/с', c:'#9B5DE5' });
    rows.push({ label:'p_y до:', val: py.toFixed(2) + ' кг·м/с', c:'#06D6E0' });
    rows.push({ label:'KE до:', val: ke.toFixed(2) + ' Дж', c:'#FFD166' });
  }
  if (after) {
    const px = after.reduce((s,b)=>s+b.m*b.vx,0), py = after.reduce((s,b)=>s+b.m*b.vy,0);
    const ke = after.reduce((s,b)=>s+b.ke,0);
    rows.push({ label:'p_x после:', val: px.toFixed(2) + ' кг·м/с', c:'#9B5DE5' });
    rows.push({ label:'p_y после:', val: py.toFixed(2) + ' кг·м/с', c:'#06D6E0' });
    rows.push({ label:'KE после:', val: ke.toFixed(2) + ' Дж', c:'#FFD166' });
  }
  if (rows.length === 0) return;
  const lh = 17, pad = 8, tw = 188, th = pad*2 + rows.length * lh;
  const bx = W - tw - 10, by = H - th - 10;
  ctx.fillStyle = 'rgba(8,8,18,.88)';
  ctx.beginPath(); ctx.roundRect(bx, by, tw, th, 8); ctx.fill();
  ctx.strokeStyle = 'rgba(155,93,229,.4)'; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.roundRect(bx, by, tw, th, 8); ctx.stroke();
  ctx.font = '10px Manrope';
  for (let i = 0; i < rows.length; i++) {
    const ry = by + pad + i * lh + lh/2;
    ctx.fillStyle = 'rgba(255,255,255,.4)'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillText(rows[i].label, bx + pad, ry);
    ctx.fillStyle = rows[i].c; ctx.textAlign = 'right';
    ctx.fillText(rows[i].val, bx + tw - pad, ry);
  }
}

/* ═══════════════════════════════════════════════
   MultiBodySim — N bodies (mode multi)
   ═══════════════════════════════════════════════ */
class MultiBodySim {
  constructor(canvas) {
    this.c   = canvas;
    this.ctx = canvas.getContext('2d');
    this.N    = 5;
    this.e    = 1;
    this.speed = 1;
    this.cmFrame = false;

    this.playing  = false;
    this._raf     = null;
    this._lastTs  = null;
    this._b       = [];
    this._sparks  = [];
    this._rings   = [];
    this._colCount = 0;
    this.onUpdate = null;
    this.onPlayPause = null;
    this._activeMode = false;

    canvas.addEventListener('click', () => { if (this._activeMode && this.onPlayPause) this.onPlayPause(); });
    new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement);
  }

  fit() {
    const r = this.c.parentElement.getBoundingClientRect();
    this.c.width  = r.width  || 700;
    this.c.height = r.height || 420;
  }

  setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); }

  _r(m) { return Math.max(14, Math.min(36, 12 + m * 2)); }

  _initBalls() {
    const W = this.c.width || 700, H = this.c.height || 420;
    const COLORS = [
      { color:'#9B5DE5', rgb:'155,93,229' }, { color:'#06D6E0', rgb:'6,214,224' },
      { color:'#F15BB5', rgb:'241,91,181' }, { color:'#FFD166', rgb:'255,209,102' },
      { color:'#06D6A0', rgb:'6,214,160' }, { color:'#EF476F', rgb:'239,71,111' },
      { color:'#118AB2', rgb:'17,138,178' }, { color:'#FFB347', rgb:'255,179,71' },
      { color:'#B5EAD7', rgb:'181,234,215' }, { color:'#C7CEEA', rgb:'199,206,234' },
    ];
    const masses = [3,4,5,6,5,4,3,5,4,6];
    this._b = [];
    for (let i = 0; i < this.N; i++) {
      const m  = masses[i % masses.length];
      const r  = this._r(m);
      const col = COLORS[i % COLORS.length];
      let x, y, tries = 0;
      do {
        x = r + Math.random() * (W - 2*r);
        y = r + Math.random() * (H - 2*r);
        tries++;
      } while (tries < 50 && this._b.some(b => Math.hypot(b.x-x, b.y-y) < b.r + r + 5));
      const ang = Math.random() * Math.PI * 2;
      const spd = 5 + Math.random() * 10;
      this._b.push({ id:i, m, r, x, y, vx:Math.cos(ang)*spd, vy:Math.sin(ang)*spd,
                     color:col.color, rgb:col.rgb, trail:[] });
    }
    this._colCount = 0;
    this._sparks = [];
    this._rings  = [];
  }

  shuffle() {
    this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats());
  }

  play() {
    if (this.playing) return;
    this.playing = true; this._lastTs = null; this._tick();
  }
  pause() {
    this.playing = false;
    if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
  }
  reset() { this.pause(); this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats()); }

  stats() {
    const totalP = { x:0, y:0 }, totalKE = { v:0 };
    for (const b of this._b) {
      totalP.x += b.m * b.vx; totalP.y += b.m * b.vy;
      totalKE.v += 0.5 * b.m * (b.vx*b.vx + b.vy*b.vy);
    }
    return { colCount: this._colCount, px: totalP.x, py: totalP.y, ke: totalKE.v };
  }

  _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;
      const dt = rawDt * this.speed;
      if (window.LabFX) LabFX.particles.update(rawDt);
      this._step(dt);
      this.draw();
      if (this.onUpdate) this.onUpdate(this.stats());
      if (this.playing) this._tick();
    });
  }

  _step(dt) {
    const W = this.c.width, H = this.c.height;

    /* apply CM frame: subtract v_cm from display velocities if toggled */
    let vcmx = 0, vcmy = 0;
    if (this.cmFrame) {
      let M = 0;
      for (const b of this._b) { M += b.m; vcmx += b.m*b.vx; vcmy += b.m*b.vy; }
      vcmx /= M; vcmy /= M;
    }

    /* trails */
    for (const b of this._b) {
      b.trail.push({ x:b.x, y:b.y });
      if (b.trail.length > 60) b.trail.shift();
    }

    /* integrate */
    for (const b of this._b) { b.x += b.vx * dt; b.y += b.vy * dt; }

    /* walls */
    const eW = Math.max(0.5, this.e);
    for (const b of this._b) {
      if (b.x - b.r < 0)   { b.x = b.r;     b.vx =  Math.abs(b.vx) * eW; }
      if (b.x + b.r > W)   { b.x = W - b.r; b.vx = -Math.abs(b.vx) * eW; }
      if (b.y - b.r < 0)   { b.y = b.r;     b.vy =  Math.abs(b.vy) * eW; }
      if (b.y + b.r > H)   { b.y = H - b.r; b.vy = -Math.abs(b.vy) * eW; }
    }

    /* pair collisions */
    const now = performance.now();
    for (let i = 0; i < this._b.length; i++) {
      for (let j = i+1; j < this._b.length; j++) {
        const b1 = this._b[i], b2 = this._b[j];
        const dx = b2.x - b1.x, dy = b2.y - b1.y;
        const dist = Math.hypot(dx, dy);
        const min  = b1.r + b2.r;
        if (dist < min && dist > 0.01) {
          const nx = dx/dist, ny = dy/dist;
          const v1n = b1.vx*nx + b1.vy*ny;
          const v2n = b2.vx*nx + b2.vy*ny;
          const dvn = v1n - v2n;
          if (dvn > 0) {
            const v1tx = b1.vx - v1n*nx, v1ty = b1.vy - v1n*ny;
            const v2tx = b2.vx - v2n*nx, v2ty = b2.vy - v2n*ny;
            const M = b1.m + b2.m;
            const v1nNew = ((b1.m - b2.m*this.e)*v1n + b2.m*(1+this.e)*v2n)/M;
            const v2nNew = ((b2.m - b1.m*this.e)*v2n + b1.m*(1+this.e)*v1n)/M;
            b1.vx = v1tx + v1nNew*nx; b1.vy = v1ty + v1nNew*ny;
            b2.vx = v2tx + v2nNew*nx; b2.vy = v2ty + v2nNew*ny;
            this._colCount++;
            const ix = (b1.x+b2.x)/2, iy = (b1.y+b2.y)/2;
            if (window.LabFX) LabFX.sound.play('bounce');
            for (let k = 0; k < 3; k++) {
              this._rings.push({ x:ix, y:iy, ts:now, life:600, col:'255,255,255', maxR:50 });
            }
          }
          const ov = min - dist;
          b1.x -= nx*ov/2; b1.y -= ny*ov/2;
          b2.x += nx*ov/2; b2.y += ny*ov/2;
        }
      }
    }

    this._rings  = this._rings.filter(r => (now - r.ts) < (r.life||900));
    this._sparks = this._sparks.filter(s => (now - s.ts) < (s.life||800));
  }

  draw() {
    const W = this.c.width, H = this.c.height;
    if (!W || !H) return;
    const ctx = this.ctx;
    const now = performance.now();

    /* bg */
    const bg = ctx.createRadialGradient(W/2,H/2,0, W/2,H/2, Math.hypot(W,H)/1.7);
    bg.addColorStop(0,'#0d0a1a'); bg.addColorStop(1,'#060610');
    ctx.fillStyle = bg; ctx.fillRect(0,0,W,H);
    ctx.strokeStyle='rgba(255,255,255,.04)'; ctx.lineWidth=1;
    for (let x=60;x<W;x+=60){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
    for (let y=60;y<H;y+=60){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
    ctx.strokeStyle='rgba(155,93,229,.18)'; ctx.lineWidth=2; ctx.strokeRect(2,2,W-4,H-4);

    /* rings */
    for (const ring of this._rings) {
      const el=(now-ring.ts)/ring.life; if(el>=1) continue;
      ctx.strokeStyle=`rgba(${ring.col},${Math.pow(1-el,1.6)*0.6})`;
      ctx.lineWidth=2*(1-el*0.7);
      ctx.beginPath(); ctx.arc(ring.x,ring.y,el*(ring.maxR||50),0,Math.PI*2); ctx.stroke();
    }

    /* trails */
    for (const b of this._b) {
      for (let i=1;i<b.trail.length;i++) {
        const frac=i/b.trail.length;
        ctx.fillStyle=`rgba(${b.rgb},${frac*0.35})`;
        ctx.beginPath(); ctx.arc(b.trail[i].x,b.trail[i].y,frac*3.5,0,Math.PI*2); ctx.fill();
      }
    }

    /* compute vcm if needed */
    let vcmx=0, vcmy=0;
    if (this.cmFrame) {
      let M=0;
      for (const b of this._b){M+=b.m;vcmx+=b.m*b.vx;vcmy+=b.m*b.vy;}
      vcmx/=M; vcmy/=M;
    }

    /* balls */
    for (const b of this._b) {
      const [r,g,bl] = b.rgb.split(',').map(Number);
      const glo = ctx.createRadialGradient(b.x,b.y,b.r*0.2,b.x,b.y,b.r*3);
      glo.addColorStop(0,`rgba(${r},${g},${bl},.45)`); glo.addColorStop(1,'transparent');
      ctx.fillStyle=glo; ctx.beginPath(); ctx.arc(b.x,b.y,b.r*3,0,Math.PI*2); ctx.fill();
      const bG=ctx.createRadialGradient(b.x-b.r*0.33,b.y-b.r*0.33,b.r*0.06,b.x,b.y,b.r);
      bG.addColorStop(0,'#fff'); bG.addColorStop(0.18,b.color);
      bG.addColorStop(1,`rgba(${Math.round(r*0.3)},${Math.round(g*0.3)},${Math.round(bl*0.3)},1)`);
      ctx.fillStyle=bG; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill();
      ctx.strokeStyle='rgba(255,255,255,.5)'; ctx.lineWidth=1.5; ctx.stroke();
      ctx.fillStyle='rgba(255,255,255,.9)';
      ctx.font=`bold ${Math.max(9,Math.round(b.r*0.55))}px Manrope`;
      ctx.textAlign='center'; ctx.textBaseline='middle';
      ctx.fillText(b.m+'кг', b.x, b.y);
      /* velocity arrow (in CM frame if toggled) */
      const dvx = b.vx - vcmx, dvy = b.vy - vcmy;
      const spd = Math.hypot(dvx,dvy);
      if (spd > 0.1) {
        const nx=dvx/spd, ny=dvy/spd;
        const pLen=Math.min(55,spd*1.8+6);
        _colArrow(ctx, b.x+nx*(b.r+5), b.y+ny*(b.r+5), b.x+nx*(b.r+5+pLen), b.y+ny*(b.r+5+pLen), b.color, 2);
      }
    }

    /* HUD: total p + KE */
    const st = this.stats();
    const pMag = Math.hypot(st.px, st.py);
    const hudLines = [
      { l:'p суммарный:', v: pMag.toFixed(2)+' кг·м/с', c:'#F15BB5' },
      { l:'KE суммарная:', v: st.ke.toFixed(1)+' Дж', c:'#FFD166' },
      { l:'Столкновений:', v: String(st.colCount), c:'#fff' },
    ];
    const tw=200, lh=18, pad=8, th=pad*2+hudLines.length*lh;
    ctx.fillStyle='rgba(8,8,18,.88)';
    ctx.beginPath(); ctx.roundRect(8, H-th-8, tw, th, 8); ctx.fill();
    ctx.strokeStyle='rgba(241,91,181,.35)'; ctx.lineWidth=1;
    ctx.beginPath(); ctx.roundRect(8, H-th-8, tw, th, 8); ctx.stroke();
    ctx.font='10px Manrope';
    for (let i=0;i<hudLines.length;i++) {
      const ry = H-th-8+pad+i*lh+lh/2;
      ctx.fillStyle='rgba(255,255,255,.4)'; ctx.textAlign='left'; ctx.textBaseline='middle';
      ctx.fillText(hudLines[i].l, 16, ry);
      ctx.fillStyle=hudLines[i].c; ctx.textAlign='right';
      ctx.fillText(hudLines[i].v, 8+tw-pad, ry);
    }

    if (this.cmFrame) {
      ctx.fillStyle='rgba(255,200,50,.7)';
      ctx.font='bold 11px Manrope'; ctx.textAlign='center'; ctx.textBaseline='top';
      ctx.fillText('Система ЦМ', W/2, 8);
    }

    if (window.LabFX) LabFX.particles.draw(this.ctx);
  }
}

/* ═══════════════════════════════════════════════
   BilliardSim — billiard table (mode billiard)
   ═══════════════════════════════════════════════ */
class BilliardSim {
  constructor(canvas) {
    this.c   = canvas;
    this.ctx = canvas.getContext('2d');
    this.cueForce = 15;
    this.speed    = 1;

    this.playing   = false;
    this._raf      = null;
    this._lastTs   = null;
    this._b        = [];
    this._pocketed = [];
    this._rings    = [];
    this._sparks   = [];
    this._drag     = null;   // { x,y } — aiming start
    this._aimEnd   = null;   // current mouse position
    this._cueShot  = false;  // cue ball is in motion after shot
    this._colCount = 0;
    this.onUpdate  = null;
    this.onPlayPause = null;
    this._activeMode = false;

    canvas.addEventListener('mousedown', e => { if (this._activeMode) this._onDown(e); });
    canvas.addEventListener('mousemove', e => { if (this._activeMode) this._onMove(e); });
    canvas.addEventListener('mouseup',   e => { if (this._activeMode) this._onUp(e); });
    canvas.addEventListener('mouseleave',()  => { if (this._activeMode) { this._drag = null; this._aimEnd = null; this.draw(); } });
    new ResizeObserver(() => { if (this._activeMode) { this.fit(); this._initBalls(); this.draw(); } }).observe(canvas.parentElement);
  }

  fit() {
    const r = this.c.parentElement.getBoundingClientRect();
    this.c.width  = r.width  || 700;
    this.c.height = r.height || 420;
  }

  setSpeed(s) { this.speed = Math.max(0.1, Math.min(4, +s)); }

  /* Table geometry helpers */
  _table() {
    const W=this.c.width||700, H=this.c.height||420;
    const pad = 36;
    return { x:pad, y:pad, w:W-2*pad, h:H-2*pad, W, H, pad };
  }

  _pockets() {
    const t = this._table();
    return [
      { x:t.x,         y:t.y },
      { x:t.x+t.w/2,   y:t.y },
      { x:t.x+t.w,     y:t.y },
      { x:t.x,         y:t.y+t.h },
      { x:t.x+t.w/2,   y:t.y+t.h },
      { x:t.x+t.w,     y:t.y+t.h },
    ];
  }

  /* Build billiard rack */
  _initBalls() {
    const t = this._table();
    const R = 11;
    this._b = [];
    this._pocketed = [];
    this._rings = []; this._sparks = [];
    this._drag = null; this._aimEnd = null; this._cueShot = false;
    this._colCount = 0;

    /* Cue ball */
    this._b.push({ id:0, m:5, r:R, x:t.x+t.w*0.28, y:t.y+t.h*0.5,
                   vx:0, vy:0, color:'#FFFFFF', rgb:'255,255,255',
                   isCue:true, pocketed:false, trail:[] });

    /* Triangle rack: 6 colored balls */
    const COLORS_RACK = ['#F15BB5','#FFD166','#06D6A0','#EF476F','#118AB2','#9B5DE5'];
    const RGBS        = ['241,91,181','255,209,102','6,214,160','239,71,111','17,138,178','155,93,229'];
    const apex = { x: t.x + t.w*0.65, y: t.y + t.h*0.5 };
    const rows = [[0],[1,2],[3,4,5]];
    let idx = 0;
    for (let row = 0; row < rows.length; row++) {
      for (let col = 0; col < rows[row].length; col++) {
        const px = apex.x + row * (R*2 + 1);
        const py = apex.y + (col - (rows[row].length-1)/2) * (R*2 + 1);
        this._b.push({ id:idx+1, m:5, r:R, x:px, y:py, vx:0, vy:0,
                       color:COLORS_RACK[idx], rgb:RGBS[idx],
                       isCue:false, pocketed:false, trail:[] });
        idx++;
      }
    }
  }

  reset() { this.pause(); this._initBalls(); this.draw(); if (this.onUpdate) this.onUpdate(this.stats()); }

  stats() { return { colCount:this._colCount, pocketed:this._pocketed.length }; }

  play() {
    if (this.playing) return;
    this.playing = true; this._lastTs = null; this._tick();
  }
  pause() {
    this.playing = false;
    if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
  }

  _canvasXY(e) {
    const r = this.c.getBoundingClientRect();
    return { x: (e.clientX - r.left)*(this.c.width/r.width),
             y: (e.clientY - r.top)*(this.c.height/r.height) };
  }

  _cueBall() { return this._b.find(b => b.isCue && !b.pocketed); }

  _onDown(e) {
    const cue = this._cueBall();
    if (!cue) return;
    const p = this._canvasXY(e);
    if (Math.hypot(p.x - cue.x, p.y - cue.y) < cue.r + 20) {
      this._drag = { x:cue.x, y:cue.y };
      this._aimEnd = p;
      this.draw();
    }
  }
  _onMove(e) {
    if (!this._drag) return;
    this._aimEnd = this._canvasXY(e);
    this.draw();
  }
  _onUp(e) {
    if (!this._drag) return;
    const cue = this._cueBall();
    if (!cue) { this._drag = null; return; }
    const p = this._canvasXY(e);
    /* shoot in opposite direction of drag (pull-back mechanic) */
    const dx = this._drag.x - p.x, dy = this._drag.y - p.y;
    const dist = Math.hypot(dx, dy);
    if (dist > 4) {
      const forceMag = Math.min(dist * 0.35, this.cueForce * 1.2) * (this.cueForce / 15);
      cue.vx = (dx / dist) * forceMag;
      cue.vy = (dy / dist) * forceMag;
      this._cueShot = true;
      if (!this.playing) this.play();
    }
    this._drag = null; this._aimEnd = null;
    this.draw();
  }

  _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.04);
      this._lastTs = ts;
      const dt = rawDt * this.speed;
      if (window.LabFX) LabFX.particles.update(rawDt);
      this._step(dt);
      this.draw();
      if (this.onUpdate) this.onUpdate(this.stats());
      /* stop when all balls nearly still */
      const moving = this._b.some(b => !b.pocketed && Math.hypot(b.vx,b.vy) > 0.3);
      if (!moving) { this.pause(); this.draw(); }
      else if (this.playing) this._tick();
    });
  }

  _step(dt) {
    const t   = this._table();
    const now = performance.now();
    const friction = 0.985;  // per frame rolling friction

    /* trails */
    for (const b of this._b) {
      if (b.pocketed) continue;
      b.trail.push({ x:b.x, y:b.y });
      if (b.trail.length > 50) b.trail.shift();
    }

    /* integrate + friction */
    for (const b of this._b) {
      if (b.pocketed) continue;
      b.x += b.vx * dt; b.y += b.vy * dt;
      b.vx *= friction; b.vy *= friction;
      if (Math.hypot(b.vx,b.vy) < 0.05) { b.vx = 0; b.vy = 0; }
    }

    /* wall bounces (cushion) */
    for (const b of this._b) {
      if (b.pocketed) continue;
      if (b.x - b.r < t.x)       { b.x = t.x + b.r;       b.vx =  Math.abs(b.vx)*0.8; }
      if (b.x + b.r > t.x + t.w) { b.x = t.x+t.w - b.r;   b.vx = -Math.abs(b.vx)*0.8; }
      if (b.y - b.r < t.y)       { b.y = t.y + b.r;       b.vy =  Math.abs(b.vy)*0.8; }
      if (b.y + b.r > t.y + t.h) { b.y = t.y+t.h - b.r;   b.vy = -Math.abs(b.vy)*0.8; }
    }

    /* ball-ball collisions */
    const active = this._b.filter(b => !b.pocketed);
    for (let i = 0; i < active.length; i++) {
      for (let j = i+1; j < active.length; j++) {
        const b1 = active[i], b2 = active[j];
        const dx = b2.x-b1.x, dy = b2.y-b1.y;
        const dist = Math.hypot(dx,dy);
        const min  = b1.r + b2.r;
        if (dist < min && dist > 0.01) {
          const nx=dx/dist, ny=dy/dist;
          const dvn = (b1.vx-b2.vx)*nx + (b1.vy-b2.vy)*ny;
          if (dvn > 0) {
            const M=b1.m+b2.m;
            const J = (1+0.85)*dvn / (1/b1.m + 1/b2.m);
            b1.vx -= J*nx/b1.m; b1.vy -= J*ny/b1.m;
            b2.vx += J*nx/b2.m; b2.vy += J*ny/b2.m;
            this._colCount++;
            if (window.LabFX) LabFX.sound.play('bounce');
            const ix=(b1.x+b2.x)/2, iy=(b1.y+b2.y)/2;
            this._rings.push({x:ix,y:iy,ts:now,life:500,col:'255,255,255',maxR:35});
          }
          const ov=min-dist;
          b1.x-=nx*ov/2; b1.y-=ny*ov/2;
          b2.x+=nx*ov/2; b2.y+=ny*ov/2;
        }
      }
    }

    /* pocket detection */
    const pockets = this._pockets();
    const PR = 14;  // pocket radius
    for (const b of this._b) {
      if (b.pocketed) continue;
      for (const p of pockets) {
        if (Math.hypot(b.x-p.x, b.y-p.y) < PR) {
          b.pocketed = true; b.vx=0; b.vy=0;
          this._pocketed.push(b.id);
          if (window.LabFX) LabFX.sound.play('chime');
          this._rings.push({x:p.x,y:p.y,ts:now,life:700,col:'255,220,50',maxR:40});
          break;
        }
      }
    }

    this._rings  = this._rings.filter(r=>(now-r.ts)<(r.life||900));
    this._sparks = this._sparks.filter(s=>(now-s.ts)<(s.life||800));
  }

  draw() {
    const W=this.c.width||700, H=this.c.height||420;
    const ctx=this.ctx, now=performance.now();
    const t=this._table();

    /* bg */
    ctx.fillStyle='#1a1020'; ctx.fillRect(0,0,W,H);

    /* table felt */
    ctx.fillStyle='#1a5c2e';
    ctx.beginPath(); ctx.roundRect(t.x,t.y,t.w,t.h,6); ctx.fill();
    /* felt texture lines */
    ctx.strokeStyle='rgba(255,255,255,.04)'; ctx.lineWidth=1;
    for (let x=t.x+30;x<t.x+t.w;x+=30){ctx.beginPath();ctx.moveTo(x,t.y);ctx.lineTo(x,t.y+t.h);ctx.stroke();}
    /* cushion border */
    ctx.strokeStyle='#4a2c0a'; ctx.lineWidth=t.pad*0.7;
    ctx.beginPath(); ctx.roundRect(t.x-t.pad*0.35,t.y-t.pad*0.35,t.w+t.pad*0.7,t.h+t.pad*0.7,8); ctx.stroke();
    /* inner cushion line */
    ctx.strokeStyle='rgba(255,255,255,.15)'; ctx.lineWidth=1.5;
    ctx.beginPath(); ctx.roundRect(t.x,t.y,t.w,t.h,4); ctx.stroke();
    /* centre line */
    ctx.strokeStyle='rgba(255,255,255,.12)'; ctx.lineWidth=1; ctx.setLineDash([8,8]);
    ctx.beginPath(); ctx.moveTo(t.x+t.w/2,t.y); ctx.lineTo(t.x+t.w/2,t.y+t.h); ctx.stroke();
    ctx.setLineDash([]);
    /* centre circle */
    ctx.strokeStyle='rgba(255,255,255,.1)'; ctx.lineWidth=1;
    ctx.beginPath(); ctx.arc(t.x+t.w/2,t.y+t.h/2,t.h*0.18,0,Math.PI*2); ctx.stroke();

    /* pockets */
    const pockets = this._pockets();
    for (const p of pockets) {
      ctx.fillStyle='#000';
      ctx.beginPath(); ctx.arc(p.x,p.y,14,0,Math.PI*2); ctx.fill();
      ctx.strokeStyle='rgba(255,255,255,.25)'; ctx.lineWidth=1;
      ctx.beginPath(); ctx.arc(p.x,p.y,14,0,Math.PI*2); ctx.stroke();
    }

    /* rings */
    for (const ring of this._rings) {
      const el=(now-ring.ts)/ring.life; if(el>=1) continue;
      ctx.strokeStyle=`rgba(${ring.col},${Math.pow(1-el,1.6)*0.8})`;
      ctx.lineWidth=2.5*(1-el*0.7);
      ctx.beginPath(); ctx.arc(ring.x,ring.y,el*(ring.maxR||40),0,Math.PI*2); ctx.stroke();
    }

    /* trails */
    for (const b of this._b) {
      if (b.pocketed) continue;
      for (let i=1;i<b.trail.length;i++) {
        const frac=i/b.trail.length;
        ctx.fillStyle=`rgba(${b.rgb},${frac*0.3})`;
        ctx.beginPath(); ctx.arc(b.trail[i].x,b.trail[i].y,frac*b.r*0.5,0,Math.PI*2); ctx.fill();
      }
    }

    /* balls */
    for (const b of this._b) {
      if (b.pocketed) continue;
      const [r,g,bl]=b.rgb.split(',').map(Number);
      const glo=ctx.createRadialGradient(b.x,b.y,b.r*0.2,b.x,b.y,b.r*2.5);
      glo.addColorStop(0,`rgba(${r},${g},${bl},.4)`); glo.addColorStop(1,'transparent');
      ctx.fillStyle=glo; ctx.beginPath(); ctx.arc(b.x,b.y,b.r*2.5,0,Math.PI*2); ctx.fill();
      const bG=ctx.createRadialGradient(b.x-b.r*0.3,b.y-b.r*0.3,b.r*0.05,b.x,b.y,b.r);
      bG.addColorStop(0,'#fff'); bG.addColorStop(0.15,b.color); bG.addColorStop(1,`rgba(${Math.round(r*0.25)},${Math.round(g*0.25)},${Math.round(bl*0.25)},1)`);
      ctx.fillStyle=bG; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill();
      ctx.strokeStyle='rgba(255,255,255,.45)'; ctx.lineWidth=1.2; ctx.stroke();
    }

    /* aim line when dragging */
    if (this._drag && this._aimEnd) {
      const cue = this._cueBall();
      if (cue) {
        const dx=this._drag.x - this._aimEnd.x, dy=this._drag.y - this._aimEnd.y;
        const dist=Math.hypot(dx,dy);
        if (dist > 4) {
          const nx=dx/dist, ny=dy/dist;
          /* cue stick */
          ctx.strokeStyle='rgba(210,160,80,.9)'; ctx.lineWidth=4; ctx.lineCap='round';
          ctx.beginPath();
          ctx.moveTo(cue.x + nx*(cue.r+8), cue.y + ny*(cue.r+8));
          ctx.lineTo(cue.x + nx*(cue.r+8+Math.min(dist*1.2,120)), cue.y + ny*(cue.r+8+Math.min(dist*1.2,120)));
          ctx.stroke(); ctx.lineCap='butt';
          /* aim trajectory dash */
          ctx.strokeStyle='rgba(255,255,255,.3)'; ctx.lineWidth=1; ctx.setLineDash([6,6]);
          ctx.beginPath();
          ctx.moveTo(cue.x - nx*(cue.r+4), cue.y - ny*(cue.r+4));
          ctx.lineTo(cue.x - nx*180, cue.y - ny*180);
          ctx.stroke(); ctx.setLineDash([]);
          /* power indicator */
          const power = Math.min(1, dist * 0.35 / (this.cueForce * 1.2) * (15/this.cueForce));
          const pw = 80, ph = 8, px2 = 8, py2 = H - 28;
          ctx.fillStyle='rgba(8,8,18,.75)';
          ctx.beginPath(); ctx.roundRect(px2,py2,pw,ph,4); ctx.fill();
          ctx.fillStyle=`hsl(${120-power*120},85%,55%)`;
          ctx.beginPath(); ctx.roundRect(px2,py2,pw*power,ph,4); ctx.fill();
          ctx.strokeStyle='rgba(255,255,255,.2)'; ctx.lineWidth=1;
          ctx.beginPath(); ctx.roundRect(px2,py2,pw,ph,4); ctx.stroke();
          ctx.fillStyle='rgba(255,255,255,.6)'; ctx.font='9px Manrope';
          ctx.textAlign='left'; ctx.textBaseline='top';
          ctx.fillText('Сила', px2, py2-12);
        }
      }
    }

    /* HUD */
    const active = this._b.filter(b=>!b.pocketed&&!b.isCue).length;
    const hudTxt = `Шаров в лузе: ${this._pocketed.length} | Осталось: ${active}`;
    ctx.fillStyle='rgba(8,8,18,.75)';
    const htw=ctx.measureText(hudTxt).width+20;
    ctx.beginPath(); ctx.roundRect(W-htw-8,8,htw,24,6); ctx.fill();
    ctx.fillStyle='rgba(255,255,255,.7)'; ctx.font='11px Manrope';
    ctx.textAlign='right'; ctx.textBaseline='middle';
    ctx.fillText(hudTxt, W-18, 20);

    /* "drag cue" hint */
    const cue=this._cueBall();
    if (cue && !this.playing && !this._drag) {
      ctx.fillStyle='rgba(255,255,255,.4)'; ctx.font='11px Manrope';
      ctx.textAlign='center'; ctx.textBaseline='top';
      ctx.fillText('Тяните от биткa для удара', W/2, t.y+t.h+6);
    }

    if (window.LabFX) LabFX.particles.draw(this.ctx);
  }
}

/* ═══ helpers ═══ */

function _colArrow(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  = 9;
  ctx.lineCap = 'round';
  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.42), y2 - 9 * Math.sin(ang - 0.42));
  ctx.lineTo(x2 - 9 * Math.cos(ang + 0.42), y2 - 9 * Math.sin(ang + 0.42));
  ctx.closePath(); ctx.fill();
  ctx.restore();
}

function _roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);    ctx.arcTo(x+w, y,   x+w, y+r,   r);
  ctx.lineTo(x + w, y + h - r); ctx.arcTo(x+w, y+h, x+w-r, y+h, r);
  ctx.lineTo(x + r, y + h);     ctx.arcTo(x,   y+h, x,   y+h-r, r);
  ctx.lineTo(x, y + r);         ctx.arcTo(x,   y,   x+r, y,      r);
  ctx.closePath();
}

/* ─── lab UI init ─────────────────────────────────── */

  /* active collision simulation instances (cSim declared in lab-init.js) */
  var cSim2D   = null;   /* 2D mode (Collision2DSim) */
  var cSimMB   = null;   /* multi-body mode (MultiBodySim) */
  var cSimBL   = null;   /* billiard mode (BilliardSim) */
  var _collMode = '1d';  /* '1d' | '2d' | 'multi' | 'billiard' */

  /* Return whichever sim is currently active */
  function _activeSim() {
    if (_collMode === '1d')       return cSim;
    if (_collMode === '2d')       return cSim2D;
    if (_collMode === 'multi')    return cSimMB;
    if (_collMode === 'billiard') return cSimBL;
    return null;
  }

  function _openCollision() {
    document.getElementById('sim-topbar-title').textContent = 'Столкновение шаров';
    _simShow('sim-coll');
    _simShow('ctrl-coll');
    _registerSimState('collision', () => cSim?.getParams(), st => cSim?.setParams(st));
    if (_embedMode) _startStateEmit('collision');

    requestAnimationFrame(() => requestAnimationFrame(() => {
      const canvas = document.getElementById('coll-canvas');

      if (!cSim) {
        cSim = new CollisionSim(canvas);
        cSim.onUpdate    = _collUpdateUI;
        cSim.onPlayPause = collPlayPause;
      }
      if (!cSim2D) {
        cSim2D = new Collision2DSim(canvas);
        cSim2D.onUpdate    = _collUpdateUI;
        cSim2D.onPlayPause = collPlayPause;
      }
      if (!cSimMB) {
        cSimMB = new MultiBodySim(canvas);
        cSimMB.onUpdate    = _collUpdateUI;
        cSimMB.onPlayPause = collPlayPause;
      }
      if (!cSimBL) {
        cSimBL = new BilliardSim(canvas);
        cSimBL.onUpdate    = _collUpdateUI;
        cSimBL.onPlayPause = collPlayPause;
      }

      /* inject mode-selector + mode panels if not yet present */
      if (!document.getElementById('coll-mode-bar')) {
        _collInjectModeUI();
      }

      _collMode = '1d';
      _collApplyMode();
    }));
  }

  /* Build the mode selector row + mode-specific panels */
  function _collInjectModeUI() {
    const simBody = document.getElementById('sim-coll');
    const bodyWrap = simBody.querySelector('.sim-body-wrap');
    const projPanel = bodyWrap ? bodyWrap.querySelector('.proj-panel') : null;
    if (!projPanel) return;

    /* ── mode selector bar (injected above params) ── */
    const modeBar = document.createElement('div');
    modeBar.id = 'coll-mode-bar';
    modeBar.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px';
    modeBar.innerHTML =
      '<button class="proj-preset-chip coll-mode-btn active" data-mode="1d" onclick="collSetMode(\'1d\',this)">1D</button>' +
      '<button class="proj-preset-chip coll-mode-btn" data-mode="2d" onclick="collSetMode(\'2d\',this)">2D угол</button>' +
      '<button class="proj-preset-chip coll-mode-btn" data-mode="multi" onclick="collSetMode(\'multi\',this)">Multi-ball</button>' +
      '<button class="proj-preset-chip coll-mode-btn" data-mode="billiard" onclick="collSetMode(\'billiard\',this)">Бильярд</button>';
    projPanel.insertBefore(modeBar, projPanel.firstChild);

    /* ── CM-frame toggle (universal) ── */
    const cmRow = document.createElement('div');
    cmRow.id = 'coll-cm-row';
    cmRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin:6px 0;';
    cmRow.innerHTML =
      '<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:0.8rem;color:rgba(255,255,255,.7)">' +
      '<input type="checkbox" id="coll-cm-toggle" onchange="collCMFrame(this.checked)" style="accent-color:#FFD166">' +
      'Система ЦМ</label>';
    projPanel.insertBefore(cmRow, projPanel.firstChild.nextSibling);

    /* ── 2D-specific panel ── */
    const p2d = document.createElement('div');
    p2d.id = 'coll-panel-2d';
    p2d.style.display = 'none';
    p2d.innerHTML =
      '<div class="gp-section-title">2D — углы движения</div>' +
      '<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--violet)">Угол v₁</span><span class="param-val" id="c2d-a1">0°</span></div>' +
      '<input type="range" class="param-slider" id="sl-2da1" min="0" max="359" value="0" oninput="coll2DParam()"></div>' +
      '<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--cyan)">Угол v₂</span><span class="param-val" id="c2d-a2">180°</span></div>' +
      '<input type="range" class="param-slider" id="sl-2da2" min="0" max="359" value="180" oninput="coll2DParam()"></div>' +
      '<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--violet)">Масса m₁</span><span class="param-val" id="c2d-m1">4 кг</span></div>' +
      '<input type="range" class="param-slider" id="sl-2dm1" min="1" max="20" value="4" oninput="coll2DParam()"></div>' +
      '<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--cyan)">Масса m₂</span><span class="param-val" id="c2d-m2">4 кг</span></div>' +
      '<input type="range" class="param-slider" id="sl-2dm2" min="1" max="20" value="4" oninput="coll2DParam()"></div>' +
      '<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--violet)">|v₁|</span><span class="param-val" id="c2d-v1">8 м/с</span></div>' +
      '<input type="range" class="param-slider" id="sl-2dv1" min="0" max="30" value="8" oninput="coll2DParam()"></div>' +
      '<div class="param-block"><div class="param-header"><span class="param-name" style="color:var(--cyan)">|v₂|</span><span class="param-val" id="c2d-v2">8 м/с</span></div>' +
      '<input type="range" class="param-slider" id="sl-2dv2" min="0" max="30" value="8" oninput="coll2DParam()"></div>' +
      '<div class="param-block"><div class="param-header"><span class="param-name">Упругость e</span><span class="param-val" id="c2d-e">1.00</span></div>' +
      '<input type="range" class="param-slider" id="sl-2de" min="0" max="1" step="0.01" value="1" oninput="coll2DParam()"></div>';
    projPanel.appendChild(p2d);

    /* ── Multi-body panel ── */
    const pmb = document.createElement('div');
    pmb.id = 'coll-panel-multi';
    pmb.style.display = 'none';
    pmb.innerHTML =
      '<div class="gp-section-title">Multi-ball</div>' +
      '<div class="param-block"><div class="param-header"><span class="param-name">Шаров N</span><span class="param-val" id="cmb-n">5</span></div>' +
      '<input type="range" class="param-slider" id="sl-mb-n" min="2" max="10" value="5" oninput="collMBParam()"></div>' +
      '<div class="param-block"><div class="param-header"><span class="param-name">Упругость e</span><span class="param-val" id="cmb-e">1.00</span></div>' +
      '<input type="range" class="param-slider" id="sl-mb-e" min="0" max="1" step="0.01" value="1" oninput="collMBParam()"></div>' +
      '<div style="margin-top:8px">' +
      '<button class="proj-preset-chip" onclick="cSimMB&&cSimMB.shuffle()">Перемешать</button>' +
      '</div>';
    projPanel.appendChild(pmb);

    /* ── Billiard panel ── */
    const pbl = document.createElement('div');
    pbl.id = 'coll-panel-billiard';
    pbl.style.display = 'none';
    pbl.innerHTML =
      '<div class="gp-section-title">Бильярд</div>' +
      '<div class="param-block"><div class="param-header"><span class="param-name">Сила удара</span><span class="param-val" id="cbl-force">15</span></div>' +
      '<input type="range" class="param-slider" id="sl-bl-force" min="5" max="30" value="15" oninput="collBLParam()"></div>' +
      '<div style="margin-top:8px">' +
      '<button class="proj-preset-chip" onclick="cSimBL&&cSimBL.reset()">Reset rack</button>' +
      '</div>';
    projPanel.appendChild(pbl);

    /* first fit + draw */
    cSim.fit(); cSim.setSpeed(+document.getElementById('sl-speed').value);
    collParam(); cSim.draw(); _collUpdateUI(cSim.stats());

    /* ── Inject TimeControl bar ── */
    _collInjectTCBar();
  }

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

    /* Build a shared TC that controls whichever sim is active */
    var proxyTC = {
      scale: 1, paused: false,
      advance: function(dt) { return this.paused ? 0 : dt * this.scale; },
      setScale: function(s) {
        this.scale = +s;
        /* propagate to all sims */
        [cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s && s._tc) s._tc.setScale(proxyTC.scale); });
      },
      togglePause: function() {
        this.paused = !this.paused;
        [cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s && s._tc) s._tc.paused = proxyTC.paused; });
        return this.paused;
      },
    };

    /* Trail toggle */
    var trailBtn = document.createElement('button');
    trailBtn.className = 'zoom-btn';
    trailBtn.style.cssText = 'display:inline-flex;align-items:center;gap:3px;padding:3px 8px;border:1px solid rgba(255,255,255,0.15);border-radius:7px;background:rgba(28,18,48,0.8);color:#ccc;font-size:.68rem;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;font-family:Manrope,sans-serif';
    trailBtn.innerHTML = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><path d="M3 12 Q6 3 12 12 Q18 21 21 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Следы';
    trailBtn.title = 'Следы движения';
    trailBtn.addEventListener('click', function() {
      var on = !((cSim && cSim.showTCTrails));
      [cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s) s.showTCTrails = on; });
      trailBtn.style.background  = on ? 'rgba(6,214,224,0.2)' : 'rgba(28,18,48,0.8)';
      trailBtn.style.color       = on ? '#06D6E0' : '#ccc';
      trailBtn.style.borderColor = on ? 'rgba(6,214,224,0.5)' : 'rgba(255,255,255,0.15)';
    });

    /* dummy sim adapter for LSBuildTimeControlUI */
    var simProxy = { draw: function() { var a = cSim || cSim2D || cSimMB || cSimBL; if (a) a.draw(); } };
    var tcBar = window.LSBuildTimeControlUI(simProxy, proxyTC, { scrubSupported: false });
    var sep = document.createElement('div');
    sep.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
    tcBar.appendChild(sep);
    tcBar.appendChild(trailBtn);

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

  /* Switch active mode */
  function collSetMode(mode, btn) {
    /* pause all */
    if (cSim)    cSim.pause();
    if (cSim2D)  cSim2D.pause();
    if (cSimMB)  cSimMB.pause();
    if (cSimBL)  cSimBL.pause();

    _collMode = mode;

    /* update tab buttons */
    document.querySelectorAll('.coll-mode-btn').forEach(b => b.classList.remove('active'));
    if (btn) btn.classList.add('active');

    /* show/hide mode-specific panels */
    const show = id => { const el=document.getElementById(id); if(el) el.style.display=''; };
    const hide = id => { const el=document.getElementById(id); if(el) el.style.display='none'; };
    const p1d  = document.getElementById('coll-params-1d');   /* original param blocks */
    const presets1d = document.getElementById('coll-presets-1d');

    /* toggle visibility of original 1D controls */
    const orig1d = ['sl-m1','sl-m2','sl-cv1','sl-cv2','sl-cangle','sl-e'].map(id => {
      const el = document.getElementById(id);
      return el ? el.closest('.param-block') : null;
    }).filter(Boolean);
    orig1d.forEach(el => el.style.display = (mode === '1d') ? '' : 'none');

    /* hide presets section if not 1d (find by traversal) */
    const presetsDiv = document.querySelector('#sim-coll .gp-section-title + div[style*="flex-wrap"]');
    if (presetsDiv) presetsDiv.style.display = (mode === '1d') ? '' : 'none';
    const presetsTitle = presetsDiv ? presetsDiv.previousElementSibling : null;
    if (presetsTitle) presetsTitle.style.display = (mode === '1d') ? '' : 'none';

    /* mode-specific panels */
    hide('coll-panel-2d'); hide('coll-panel-multi'); hide('coll-panel-billiard');
    if (mode === '2d')       show('coll-panel-2d');
    if (mode === 'multi')    show('coll-panel-multi');
    if (mode === 'billiard') show('coll-panel-billiard');

    /* show/hide launch+reset buttons (not needed for billiard) */
    const launchWrap = document.querySelector('#sim-coll .proj-launch-btn')?.parentElement;
    if (launchWrap) launchWrap.style.display = (mode === 'billiard') ? 'none' : '';

    /* CM toggle only for 1d/2d/multi */
    const cmRow = document.getElementById('coll-cm-row');
    if (cmRow) cmRow.style.display = (mode === 'billiard') ? 'none' : '';

    _collApplyMode();
  }

  function _collApplyMode() {
    const canvas = document.getElementById('coll-canvas');
    if (!canvas) return;
    const mode = _collMode;

    /* Mark which sim is active so ResizeObserver callbacks skip inactive sims */
    if (cSim)   cSim._activeMode   = (mode === '1d');
    if (cSim2D) cSim2D._activeMode = (mode === '2d');
    if (cSimMB) cSimMB._activeMode = (mode === 'multi');
    if (cSimBL) cSimBL._activeMode = (mode === 'billiard');

    if (mode === '1d') {
      cSim.fit(); collParam(); cSim.draw(); _collUpdateUI(cSim.stats());
    } else if (mode === '2d') {
      cSim2D.fit(); coll2DParam(); cSim2D.draw(); _collUpdateUI(cSim2D.stats());
    } else if (mode === 'multi') {
      cSimMB.fit(); cSimMB._initBalls(); collMBParam(); cSimMB.draw(); _collUpdateUI(cSimMB.stats());
    } else if (mode === 'billiard') {
      cSimBL.fit(); cSimBL._initBalls(); cSimBL.draw(); _collUpdateUI(cSimBL.stats());
    }
    _collSyncBtn();
  }

  /* CM-frame toggle (universal) */
  function collCMFrame(on) {
    if (cSim2D) cSim2D.cmFrame = on;
    if (cSimMB) cSimMB.cmFrame = on;
    const act = _activeSim();
    if (act && !act.playing) act.draw();
  }

  function collPlayPause() {
    const sim = _activeSim();
    if (!sim) return;
    if (sim.playing) { sim.pause(); } else { sim.play(); }
    _collSyncBtn();
  }

  function _collSyncBtn() {
    const sim = _activeSim();
    const playing = sim ? sim.playing : false;
    const tb  = document.getElementById('coll-play-btn');
    const lb  = document.getElementById('coll-launch-main');
    const lbl = document.getElementById('coll-launch-label');
    const lic = document.getElementById('coll-launch-icon');

    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);
    }
    if (lb && lbl && lic) {
      lb.classList.toggle('paused', playing);
      lb.classList.remove('done');
      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 {
        lic.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
        lbl.textContent = 'Запустить';
      }
    }
  }

  /* ── 1D param handler (existing) ── */
  function collParam() {
    const m1    = +document.getElementById('sl-m1').value;
    const m2    = +document.getElementById('sl-m2').value;
    const v1    = +document.getElementById('sl-cv1').value;
    const v2    = +document.getElementById('sl-cv2').value;
    const angle = +document.getElementById('sl-cangle').value;
    const e     = +document.getElementById('sl-e').value;
    const spd   = +document.getElementById('sl-speed').value;

    const el = id => document.getElementById(id);
    if (el('c-m1'))    el('c-m1').textContent    = m1 + ' кг';
    if (el('c-m2'))    el('c-m2').textContent    = m2 + ' кг';
    if (el('c-v1'))    el('c-v1').textContent    = v1 + ' м/с';
    if (el('c-v2'))    el('c-v2').textContent    = v2 + ' м/с';
    if (el('c-angle')) el('c-angle').textContent = angle + '°';
    if (el('c-e'))     el('c-e').textContent     = e.toFixed(2);
    if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';

    if (cSim) {
      const speedChanged = Math.abs(cSim.speed - spd) > 0.001;
      if (speedChanged) cSim.setSpeed(spd);
      const physChanged = cSim.m1 !== m1 || cSim.m2 !== m2 ||
                          cSim.v1 !== v1 || cSim.v2 !== v2 ||
                          cSim.angle !== angle || cSim.e !== e;
      if (physChanged) cSim.setParams({ m1, m2, v1, v2, angle, e });
      _collSyncBtn();
    }
  }

  /* ── 2D param handler ── */
  function coll2DParam() {
    if (!cSim2D) return;
    const el = id => document.getElementById(id);
    const a1 = +(el('sl-2da1')?.value ?? 0);
    const a2 = +(el('sl-2da2')?.value ?? 180);
    const m1 = +(el('sl-2dm1')?.value ?? 4);
    const m2 = +(el('sl-2dm2')?.value ?? 4);
    const v1 = +(el('sl-2dv1')?.value ?? 8);
    const v2 = +(el('sl-2dv2')?.value ?? 8);
    const e  = +(el('sl-2de')?.value ?? 1);
    const spd = +(el('sl-speed')?.value ?? 1);

    if (el('c2d-a1')) el('c2d-a1').textContent = a1 + '°';
    if (el('c2d-a2')) el('c2d-a2').textContent = a2 + '°';
    if (el('c2d-m1')) el('c2d-m1').textContent = m1 + ' кг';
    if (el('c2d-m2')) el('c2d-m2').textContent = m2 + ' кг';
    if (el('c2d-v1')) el('c2d-v1').textContent = v1 + ' м/с';
    if (el('c2d-v2')) el('c2d-v2').textContent = v2 + ' м/с';
    if (el('c2d-e'))  el('c2d-e').textContent  = e.toFixed(2);
    if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';

    cSim2D.setSpeed(spd);
    const changed = cSim2D.a1!==a1||cSim2D.a2!==a2||cSim2D.m1!==m1||cSim2D.m2!==m2||
                    cSim2D.v1!==v1||cSim2D.v2!==v2||cSim2D.e!==e;
    if (changed) cSim2D.setParams({ a1, a2, m1, m2, v1, v2, e });
    _collSyncBtn();
  }

  /* ── Multi-body param handler ── */
  function collMBParam() {
    if (!cSimMB) return;
    const el = id => document.getElementById(id);
    const N   = +(el('sl-mb-n')?.value ?? 5);
    const e   = +(el('sl-mb-e')?.value ?? 1);
    const spd = +(el('sl-speed')?.value ?? 1);

    if (el('cmb-n')) el('cmb-n').textContent = N;
    if (el('cmb-e')) el('cmb-e').textContent = e.toFixed(2);
    if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';

    cSimMB.setSpeed(spd);
    const changed = cSimMB.N !== N || cSimMB.e !== e;
    if (changed) { cSimMB.N = N; cSimMB.e = e; cSimMB.reset(); }
    _collSyncBtn();
  }

  /* ── Billiard param handler ── */
  function collBLParam() {
    if (!cSimBL) return;
    const el = id => document.getElementById(id);
    const force = +(el('sl-bl-force')?.value ?? 15);
    const spd   = +(el('sl-speed')?.value ?? 1);
    if (el('cbl-force')) el('cbl-force').textContent = force;
    if (el('c-speed')) el('c-speed').textContent = spd.toFixed(2) + '×';
    cSimBL.cueForce = force;
    cSimBL.setSpeed(spd);
  }

  function collPreset(m1, m2, v1, v2, angle, e) {
    document.getElementById('sl-m1').value     = m1;
    document.getElementById('sl-m2').value     = m2;
    document.getElementById('sl-cv1').value    = v1;
    document.getElementById('sl-cv2').value    = v2;
    document.getElementById('sl-cangle').value = angle;
    document.getElementById('sl-e').value      = e;
    collParam();
  }

  function _collUpdateUI(s) {
    if (_collMode === 'billiard') {
      /* billiard: show pocketed count */
      const el = id => document.getElementById(id);
      if (el('cs-pbefore'))  el('cs-pbefore').textContent  = '—';
      if (el('cs-pafter'))   el('cs-pafter').textContent   = '—';
      if (el('cs-kebefore')) el('cs-kebefore').textContent = '—';
      if (el('cs-keafter'))  el('cs-keafter').textContent  = '—';
      if (el('cs-count'))    el('cs-count').textContent    = 'Луз: ' + (s.pocketed || 0);
      _collSyncBtn();
      return;
    }
    if (_collMode === 'multi') {
      const el = id => document.getElementById(id);
      if (el('cs-pbefore'))  el('cs-pbefore').textContent  = s.px ? s.px.toFixed(2)+' кг·м/с' : '—';
      if (el('cs-pafter'))   el('cs-pafter').textContent   = s.py ? s.py.toFixed(2)+' кг·м/с' : '—';
      if (el('cs-kebefore')) el('cs-kebefore').textContent = s.ke ? s.ke.toFixed(1)+' Дж' : '—';
      if (el('cs-keafter'))  el('cs-keafter').textContent  = '—';
      if (el('cs-count'))    el('cs-count').textContent    = s.colCount || 0;
      _collSyncBtn();
      return;
    }
    /* 1D / 2D: same before/after display */
    function snapKE(arr) { return arr ? arr.reduce((t, b) => t + b.ke, 0) : null; }
    function snapP(arr)  {
      if (!arr) return null;
      return Math.hypot(arr.reduce((t, b) => t + b.m * b.vx, 0),
                        arr.reduce((t, b) => t + b.m * b.vy, 0));
    }
    const bKE = snapKE(s.before), bP = snapP(s.before);
    const aKE = snapKE(s.after),  aP = snapP(s.after);
    const f2 = v => v !== null ? v.toFixed(2) : '—';
    const el = id => document.getElementById(id);
    if (el('cs-pbefore'))  el('cs-pbefore').textContent  = bP  !== null ? f2(bP)  + ' кг·м/с' : '—';
    if (el('cs-pafter'))   el('cs-pafter').textContent   = aP  !== null ? f2(aP)  + ' кг·м/с' : '—';
    if (el('cs-kebefore')) el('cs-kebefore').textContent = bKE !== null ? f2(bKE) + ' Дж'      : '—';
    if (el('cs-keafter'))  el('cs-keafter').textContent  = aKE !== null ? f2(aKE) + ' Дж'      : '—';
    if (el('cs-count'))    el('cs-count').textContent    = s.colCount || 0;
    _collSyncBtn();
  }

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

  /* ── magnetic ── */



function collToggleGraphs() {
  const as = _activeSim && _activeSim();
  if (!as || typeof as.toggleGraphs !== 'function') return;
  const canvasOuter = document.querySelector('#sim-coll .proj-canvas-outer');
  if (!canvasOuter) return;
  const on = as.toggleGraphs(canvasOuter);
  const btn = document.getElementById('btn-coll-graphs');
  if (btn) btn.classList.toggle('active', on);
}