'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;

    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:[] },
      { 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:[] },
    ];

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

  /* ═══ 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 dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed;
      this._lastTs = ts;
      this._step(dt);
      this.draw();
      this._emit();
      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();
    }

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

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

  /* ═══ 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();
      }
    }

    /* ── 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 сохранена <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
        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);
    }
  }

  /* ── 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);
    }
  }
}

/* ═══ 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 ─────────────────────────────────── */
  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(() => {
      if (!cSim) {
        cSim = new CollisionSim(document.getElementById('coll-canvas'));
        cSim.onUpdate    = _collUpdateUI;
        cSim.onPlayPause = collPlayPause;
      }
      cSim.fit();
      cSim.setSpeed(+document.getElementById('sl-speed').value);
      collParam();
      cSim.draw();
      _collUpdateUI(cSim.stats());
    }));
  }

  function collPlayPause() {
    if (!cSim) return;
    if (cSim.playing) { cSim.pause(); } else { cSim.play(); }
    _collSyncBtn();
  }

  function _collSyncBtn() {
    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 (!cSim) return;
    const playing = cSim.playing;

    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 = 'Запустить';
      }
    }
  }

  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;

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

    if (cSim) {
      /* speed change doesn't require a reset */
      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();
    }
  }

  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) {
    // before/after are arrays [{m, vx, vy, ke}, ...]
    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) : '—';

    document.getElementById('cs-pbefore').textContent  = bP  !== null ? f2(bP)  + ' кг·м/с' : '—';
    document.getElementById('cs-pafter').textContent   = aP  !== null ? f2(aP)  + ' кг·м/с' : '—';
    document.getElementById('cs-kebefore').textContent = bKE !== null ? f2(bKE) + ' Дж'      : '—';
    document.getElementById('cs-keafter').textContent  = aKE !== null ? f2(aKE) + ' Дж'      : '—';
    document.getElementById('cs-count').textContent    = s.colCount;
    _collSyncBtn();
  }

  /* ── magnetic ── */

