'use strict';
/* ═══════════════════════════════════════════════════════════════════
   LSPhysFX — shared physics visualization helpers
   Provides: drawVector, drawForceArrow, drawSpring, drawRope,
             drawSurface, drawCoordSystem, drawScale, drawClock,
             drawAngleArc, drawPivot
   ═══════════════════════════════════════════════════════════════════ */
(function(global) {
  if (!global.LSPhysFX) global.LSPhysFX = {};

  /* ── Standard force colours ─────────────────────────────────── */
  LSPhysFX.FORCE_COLORS = {
    gravity:  '#EF476F',  // mg — red
    normal:   '#06D6E0',  // N — cyan
    friction: '#FF8C42',  // F_тр — orange
    tension:  '#9B5DE5',  // T — violet
    elastic:  '#7BF5A4',  // F_упр — green
    drag:     '#888',     // F_сопр — gray
    applied:  '#FFD166',  // F_прил — gold
    impulse:  '#FF6B6B',  // удар — bright red
    velocity: '#4CC9F0',  // v vector — light blue
    accel:    '#F15BB5',  // a vector — pink
  };

  /* ── drawVector ─────────────────────────────────────────────── */
  /*   Draws an arrow from (x0, y0) with components (vx, vy).
       opts: { color, label, scale=1, lineWidth=2, dashed=false, arrowSize=8 } */
  LSPhysFX.drawVector = function(ctx, x0, y0, vx, vy, opts) {
    const o = opts || {};
    const color     = o.color     || '#fff';
    const scale     = (o.scale    !== undefined) ? o.scale     : 1;
    const lw        = (o.lineWidth !== undefined) ? o.lineWidth : 2;
    const arrowSize = (o.arrowSize !== undefined) ? o.arrowSize : 8;
    const label     = o.label     || '';
    const dashed    = o.dashed    || false;

    const ex = x0 + vx * scale;
    const ey = y0 + vy * scale;
    const dx = ex - x0, dy = ey - y0;
    const len = Math.sqrt(dx * dx + dy * dy);
    if (len < 2) return;

    const ux = dx / len, uy = dy / len;
    const hw = arrowSize * 0.55, hl = arrowSize * 1.4;

    ctx.save();
    ctx.strokeStyle = color;
    ctx.fillStyle   = color;
    ctx.lineWidth   = lw;
    ctx.lineCap     = 'round';
    ctx.shadowColor = color;
    ctx.shadowBlur  = 5;

    if (dashed) ctx.setLineDash([5, 4]);

    /* shaft — stop before arrowhead */
    ctx.beginPath();
    ctx.moveTo(x0, y0);
    ctx.lineTo(ex - ux * hl, ey - uy * hl);
    ctx.stroke();

    ctx.setLineDash([]);

    /* arrowhead */
    ctx.beginPath();
    ctx.moveTo(ex, ey);
    ctx.lineTo(ex - ux * hl - uy * hw, ey - uy * hl + ux * hw);
    ctx.lineTo(ex - ux * hl + uy * hw, ey - uy * hl - ux * hw);
    ctx.closePath();
    ctx.fill();

    /* label at tip offset perpendicular */
    if (label) {
      ctx.shadowBlur  = 0;
      ctx.font        = 'bold 10px Manrope, monospace';
      ctx.fillStyle   = color;
      ctx.textAlign   = 'center';
      ctx.textBaseline = 'middle';
      const lx = (x0 + ex) / 2 - uy * 14;
      const ly = (y0 + ey) / 2 + ux * 14;
      ctx.fillText(label, lx, ly);
      ctx.textBaseline = 'alphabetic';
      ctx.textAlign   = 'left';
    }

    ctx.restore();
  };

  /* ── drawForceArrow ─────────────────────────────────────────── */
  /*   Convenience wrapper: type = key from FORCE_COLORS.
       (fx, fy) are canvas-space pixel components of the arrow.  */
  LSPhysFX.drawForceArrow = function(ctx, x0, y0, fx, fy, type, label) {
    const col = LSPhysFX.FORCE_COLORS[type] || '#fff';
    LSPhysFX.drawVector(ctx, x0, y0, fx, fy, {
      color: col, label: label || '', scale: 1,
      lineWidth: 2.5, arrowSize: 8,
    });
  };

  /* ── drawSpring ─────────────────────────────────────────────── */
  /*   Draws a zigzag spring between two endpoints.
       opts: { coils=10, amp=8, color='#FFD166', lineWidth=2 } */
  LSPhysFX.drawSpring = function(ctx, x1, y1, x2, y2, opts) {
    const o = opts || {};
    const coils = (o.coils !== undefined) ? o.coils : 10;
    const amp   = (o.amp   !== undefined) ? o.amp   : 8;
    const color = o.color  || '#FFD166';
    const lw    = (o.lineWidth !== undefined) ? o.lineWidth : 2;

    const dx  = x2 - x1, dy = y2 - y1;
    const len = Math.sqrt(dx * dx + dy * dy);
    if (len < 2) return;

    /* unit vectors along and perpendicular to spring axis */
    const ux = dx / len, uy = dy / len;
    const nx = -uy, ny = ux;

    /* end pads: 10% each side */
    const pad = Math.min(len * 0.08, 8);

    ctx.save();
    ctx.strokeStyle = color;
    ctx.lineWidth   = lw;
    ctx.lineCap     = 'round';
    ctx.lineJoin    = 'round';

    const seg   = coils * 2 + 2;
    const bodyL = len - 2 * pad;

    ctx.beginPath();
    ctx.moveTo(x1, y1);
    /* short pad line */
    ctx.lineTo(x1 + ux * pad, y1 + uy * pad);

    for (let i = 1; i <= seg; i++) {
      const t    = i / seg;
      const side = (i % 2 === 0) ? amp : -amp;
      const bx   = x1 + ux * pad + t * bodyL * ux + nx * side;
      const bya  = y1 + uy * pad + t * bodyL * uy + ny * side;
      ctx.lineTo(bx, bya);
    }

    /* short pad line at end */
    ctx.lineTo(x2, y2);
    ctx.stroke();
    ctx.restore();
  };

  /* ── drawRope ───────────────────────────────────────────────── */
  /*   Draws a rope (straight or catenary-like if sag > 0).
       opts: { color='rgba(210,225,255,0.6)', sag=0, lineWidth=2, dashed=false } */
  LSPhysFX.drawRope = function(ctx, x1, y1, x2, y2, opts) {
    const o     = opts || {};
    const color = o.color   || 'rgba(210,225,255,0.6)';
    const sag   = (o.sag   !== undefined) ? o.sag   : 0;
    const lw    = (o.lineWidth !== undefined) ? o.lineWidth : 2;
    const dashed = o.dashed || false;

    ctx.save();
    ctx.strokeStyle = color;
    ctx.lineWidth   = lw;
    ctx.lineCap     = 'round';
    if (dashed) ctx.setLineDash([5, 4]);

    ctx.beginPath();
    if (sag > 0) {
      const mx = (x1 + x2) / 2;
      const my = (y1 + y2) / 2 + sag;
      ctx.moveTo(x1, y1);
      ctx.quadraticCurveTo(mx, my, x2, y2);
    } else {
      ctx.moveTo(x1, y1);
      ctx.lineTo(x2, y2);
    }
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.restore();
  };

  /* ── drawSurface ────────────────────────────────────────────── */
  /*   Draws a floor/wall/ramp with diagonal hatching.
       (x, y) = left endpoint of the surface line; w = length.
       opts: { angle=0 (radians), hatch=true, color='rgba(255,255,255,0.25)', thick=4 } */
  LSPhysFX.drawSurface = function(ctx, x, y, w, opts) {
    const o     = opts || {};
    const angle = (o.angle !== undefined) ? o.angle : 0;
    const hatch = (o.hatch !== undefined) ? o.hatch : true;
    const color = o.color || 'rgba(255,255,255,0.25)';
    const thick = (o.thick !== undefined) ? o.thick : 4;

    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(angle);

    /* main surface line */
    ctx.strokeStyle = color;
    ctx.lineWidth   = thick;
    ctx.lineCap     = 'butt';
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(w, 0);
    ctx.stroke();

    /* hatching below */
    if (hatch) {
      ctx.strokeStyle = 'rgba(255,255,255,0.10)';
      ctx.lineWidth   = 1;
      const step = 10;
      const depth = 12;
      for (let xi = 0; xi < w + depth; xi += step) {
        const xs = Math.min(xi, w);
        const xe = Math.max(0, xi - depth);
        if (xs === xe) continue;
        ctx.beginPath();
        ctx.moveTo(xs, 0);
        ctx.lineTo(xe, depth);
        ctx.stroke();
      }
    }

    ctx.restore();
  };

  /* ── drawCoordSystem ────────────────────────────────────────── */
  /*   Draws XY axes with optional grid inside a rect (ox,oy)–(ox+w,oy+h).
       opts: { labelX='x', labelY='y', gridSize=20, gridColor, axisColor, labelColor } */
  LSPhysFX.drawCoordSystem = function(ctx, ox, oy, w, h, opts) {
    const o           = opts || {};
    const labelX      = o.labelX    || 'x';
    const labelY      = o.labelY    || 'y';
    const gridSize    = (o.gridSize !== undefined) ? o.gridSize : 20;
    const gridColor   = o.gridColor  || 'rgba(255,255,255,0.05)';
    const axisColor   = o.axisColor  || 'rgba(255,255,255,0.4)';
    const labelColor  = o.labelColor || 'rgba(255,255,255,0.6)';

    ctx.save();

    /* grid */
    ctx.strokeStyle = gridColor;
    ctx.lineWidth   = 1;
    for (let gx = ox; gx <= ox + w; gx += gridSize) {
      ctx.beginPath(); ctx.moveTo(gx, oy); ctx.lineTo(gx, oy + h); ctx.stroke();
    }
    for (let gy = oy; gy <= oy + h; gy += gridSize) {
      ctx.beginPath(); ctx.moveTo(ox, gy); ctx.lineTo(ox + w, gy); ctx.stroke();
    }

    /* axes */
    ctx.strokeStyle = axisColor;
    ctx.lineWidth   = 1.5;
    /* X axis */
    ctx.beginPath(); ctx.moveTo(ox, oy + h); ctx.lineTo(ox + w + 10, oy + h); ctx.stroke();
    /* Y axis */
    ctx.beginPath(); ctx.moveTo(ox, oy + h); ctx.lineTo(ox, oy - 10); ctx.stroke();

    /* axis labels */
    ctx.fillStyle    = labelColor;
    ctx.font         = 'bold 11px Manrope, monospace';
    ctx.textAlign    = 'left';
    ctx.textBaseline = 'middle';
    ctx.fillText(labelX, ox + w + 12, oy + h);
    ctx.textAlign    = 'center';
    ctx.textBaseline = 'bottom';
    ctx.fillText(labelY, ox, oy - 12);
    ctx.textBaseline = 'alphabetic';
    ctx.textAlign    = 'left';

    ctx.restore();
  };

  /* ── drawScale ──────────────────────────────────────────────── */
  /*   Ruler with tick marks.
       opts: { horizontal=true, label='', color='rgba(255,255,255,0.5)' } */
  LSPhysFX.drawScale = function(ctx, x, y, length, divisions, opts) {
    const o          = opts || {};
    const horiz      = (o.horizontal !== undefined) ? o.horizontal : true;
    const label      = o.label || '';
    const color      = o.color || 'rgba(255,255,255,0.5)';

    ctx.save();
    ctx.strokeStyle = color;
    ctx.fillStyle   = color;
    ctx.lineWidth   = 1.5;
    ctx.lineCap     = 'round';
    ctx.font        = '9px Manrope, monospace';
    ctx.textAlign   = 'center';
    ctx.textBaseline = 'top';

    const step = length / divisions;

    if (horiz) {
      ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + length, y); ctx.stroke();
      for (let i = 0; i <= divisions; i++) {
        const tx   = x + i * step;
        const tall = (i % 5 === 0) ? 8 : 4;
        ctx.beginPath(); ctx.moveTo(tx, y); ctx.lineTo(tx, y + tall); ctx.stroke();
        if (i % 5 === 0) ctx.fillText(String(i), tx, y + 10);
      }
    } else {
      ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + length); ctx.stroke();
      ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
      for (let i = 0; i <= divisions; i++) {
        const ty   = y + i * step;
        const tall = (i % 5 === 0) ? 8 : 4;
        ctx.beginPath(); ctx.moveTo(x, ty); ctx.lineTo(x - tall, ty); ctx.stroke();
        if (i % 5 === 0) ctx.fillText(String(i), x - 10, ty);
      }
    }

    if (label) {
      ctx.textAlign = 'center'; ctx.textBaseline = 'top';
      ctx.fillText(label, horiz ? x + length / 2 : x - 22, horiz ? y + 22 : y + length + 4);
    }

    ctx.restore();
  };

  /* ── drawClock ──────────────────────────────────────────────── */
  /*   Stopwatch face showing time t (seconds). */
  LSPhysFX.drawClock = function(ctx, x, y, r, t) {
    ctx.save();

    /* face */
    ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5; ctx.stroke();

    /* ticks */
    ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1;
    for (let i = 0; i < 12; i++) {
      const a  = (i / 12) * Math.PI * 2 - Math.PI / 2;
      const r1 = r * 0.80, r2 = r * 0.92;
      ctx.beginPath();
      ctx.moveTo(x + Math.cos(a) * r1, y + Math.sin(a) * r1);
      ctx.lineTo(x + Math.cos(a) * r2, y + Math.sin(a) * r2);
      ctx.stroke();
    }

    /* second hand — completes one revolution per 60 s */
    const secAngle = (t % 60) / 60 * Math.PI * 2 - Math.PI / 2;
    ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x + Math.cos(secAngle) * r * 0.75, y + Math.sin(secAngle) * r * 0.75);
    ctx.stroke();

    /* minute hand */
    const minAngle = (t % 3600) / 3600 * Math.PI * 2 - Math.PI / 2;
    ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x + Math.cos(minAngle) * r * 0.55, y + Math.sin(minAngle) * r * 0.55);
    ctx.stroke();

    /* time label */
    ctx.fillStyle    = 'rgba(255,255,255,0.8)';
    ctx.font         = `bold ${Math.max(8, r * 0.28)}px Manrope, monospace`;
    ctx.textAlign    = 'center';
    ctx.textBaseline = 'middle';
    const mins = Math.floor(t / 60);
    const secs = (t % 60).toFixed(1);
    ctx.fillText((mins > 0 ? mins + ':' : '') + (mins > 0 && t % 60 < 10 ? '0' : '') + secs + 'с', x, y + r * 0.38);
    ctx.textBaseline = 'alphabetic';
    ctx.textAlign    = 'left';

    ctx.restore();
  };

  /* ── drawAngleArc ───────────────────────────────────────────── */
  /*   Arc from angle a1 to a2 (radians) with optional label.
       opts: { color='rgba(255,200,60,0.5)', label='', lineWidth=1.5 } */
  LSPhysFX.drawAngleArc = function(ctx, cx, cy, r, a1, a2, opts) {
    const o     = opts || {};
    const color = o.color || 'rgba(255,200,60,0.5)';
    const label = o.label || '';
    const lw    = (o.lineWidth !== undefined) ? o.lineWidth : 1.5;

    ctx.save();
    ctx.strokeStyle = color;
    ctx.lineWidth   = lw;
    ctx.beginPath();
    ctx.arc(cx, cy, r, a1, a2);
    ctx.stroke();

    if (label) {
      const mid  = (a1 + a2) / 2;
      const lx   = cx + Math.cos(mid) * (r + 12);
      const ly   = cy + Math.sin(mid) * (r + 12);
      ctx.fillStyle    = color;
      ctx.font         = 'bold 10px Manrope, monospace';
      ctx.textAlign    = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(label, lx, ly);
      ctx.textBaseline = 'alphabetic';
      ctx.textAlign    = 'left';
    }

    ctx.restore();
  };

  /* ── drawEnergyBars ─────────────────────────────────────────── */
  /*   Universal stacked energy-bar widget for mech sims.
       Renders inside a semi-transparent panel at canvas position (x, y).

       energies : {
         ke      — kinetic energy, J  (always shown)
         pe      — gravitational PE, J
         elastic — spring/elastic PE, J
         friction — cumulative heat loss (W_тр), J
         total   — optional: external scale anchor (if provided and > sum, used for normalization)
       }
       opts : {
         w          — panel width in px (default 185)
         bgColor    — background fill
         borderColor — border stroke
         colors     — { ke, pe, elastic, friction }
       }
  */
  LSPhysFX.drawEnergyBars = function(ctx, x, y, w, _h, energies, opts) {
    opts = opts || {};
    var ke       = Math.max(0, energies.ke       || 0);
    var pe       = Math.max(0, energies.pe       || 0);
    var friction = Math.max(0, energies.friction || 0);
    var elastic  = Math.max(0, energies.elastic  || 0);
    var mechSum  = ke + pe + elastic;
    var total    = mechSum + friction;

    /* external scale anchor for stable bars */
    if (energies.total && energies.total > total) total = energies.total;
    if (total < 0.001) return;

    var cols = opts.colors || {};
    var CKE  = cols.ke       || '#06D6E0';
    var CPE  = cols.pe       || '#FFD166';
    var CEL  = cols.elastic  || '#7BF5A4';
    var CFR  = cols.friction || '#888888';

    /* layout */
    var PAD_L = 40;   /* left label column */
    var PAD_R = 52;   /* right value column */
    var ROW_H = 14;
    var GAP   = 5;
    var panelW = (w && w > 0 ? w : 185);

    /* rows — always show ke and W_тр, conditionally PE and elastic */
    var rows = [];
    rows.push({ label: 'Eк',   val: ke,       color: CKE });
    if (pe > 0.001)      rows.push({ label: 'Eп',   val: pe,       color: CPE });
    if (elastic > 0.001) rows.push({ label: 'Eупр', val: elastic,  color: CEL });
    rows.push({ label: 'Wтр', val: friction, color: CFR });

    var panelH = GAP + (ROW_H + GAP) * (rows.length + 1); /* +1 for Sigma */
    var barX = x + PAD_L;
    var barW = panelW - PAD_L - PAD_R;

    /* background panel */
    ctx.save();
    ctx.fillStyle   = opts.bgColor    || 'rgba(10,12,26,0.82)';
    ctx.strokeStyle = opts.borderColor || 'rgba(255,255,255,0.13)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    if (ctx.roundRect) ctx.roundRect(x, y, panelW, panelH, 8);
    else               ctx.rect(x, y, panelW, panelH);
    ctx.fill();
    ctx.stroke();

    ctx.font = 'bold 9px Manrope, monospace';
    ctx.textBaseline = 'middle';

    /* ── Sigma row ── */
    var ry = y + GAP;
    var sigmaW = (total / total) * barW; /* always full = reference */
    ctx.fillStyle = 'rgba(255,255,255,0.18)';
    _lsfx_rrect2(ctx, barX, ry + 1, barW, ROW_H - 2, 3); ctx.fill();
    /* mech portion highlighted */
    var mechW = (mechSum / total) * barW;
    ctx.fillStyle = 'rgba(255,255,255,0.38)';
    if (mechW > 0.5) { _lsfx_rrect2(ctx, barX, ry + 1, mechW, ROW_H - 2, 3); ctx.fill(); }

    ctx.fillStyle = 'rgba(255,255,255,0.5)';
    ctx.textAlign = 'right';
    ctx.fillText('ΣE', x + PAD_L - 4, ry + ROW_H / 2);
    ctx.textAlign = 'left';
    ctx.fillText(_lsfx_fmt2(mechSum + friction), barX + barW + 4, ry + ROW_H / 2);
    ry += ROW_H + GAP;

    /* ── individual rows ── */
    for (var i = 0; i < rows.length; i++) {
      var row = rows[i];
      var bw  = barW * (row.val / total);

      ctx.fillStyle = 'rgba(255,255,255,0.07)';
      _lsfx_rrect2(ctx, barX, ry + 1, barW, ROW_H - 2, 3); ctx.fill();

      if (bw > 0.5) {
        ctx.fillStyle = row.color;
        _lsfx_rrect2(ctx, barX, ry + 1, Math.max(bw, 1.5), ROW_H - 2, 3); ctx.fill();
      }

      ctx.fillStyle = row.color;
      ctx.textAlign = 'right';
      ctx.fillText(row.label, x + PAD_L - 4, ry + ROW_H / 2);

      ctx.fillStyle = 'rgba(255,255,255,0.55)';
      ctx.textAlign = 'left';
      ctx.fillText(_lsfx_fmt2(row.val), barX + barW + 4, ry + ROW_H / 2);

      ry += ROW_H + GAP;
    }

    ctx.restore();
  };

  function _lsfx_rrect2(ctx, x, y, w, h, r) {
    ctx.beginPath();
    if (ctx.roundRect) ctx.roundRect(x, y, w, h, r);
    else               ctx.rect(x, y, w, h);
  }

  function _lsfx_fmt2(v) {
    if (v >= 1000) return (v / 1000).toFixed(1) + 'кДж';
    if (v >= 10)   return v.toFixed(0)  + ' Дж';
    if (v >= 0.1)  return v.toFixed(1)  + ' Дж';
    return v.toFixed(2) + ' Дж';
  }

  /* ── drawPivot ──────────────────────────────────────────────── */
  /*   Pinned joint circle (pivot) for pendulum/lever. */
  LSPhysFX.drawPivot = function(ctx, x, y, r) {
    const rad = r || 5;
    ctx.save();
    ctx.beginPath();
    ctx.arc(x, y, rad, 0, Math.PI * 2);
    ctx.fillStyle   = '#444';
    ctx.strokeStyle = 'rgba(255,255,255,0.4)';
    ctx.lineWidth   = 1.5;
    ctx.fill();
    ctx.stroke();

    /* crosshair */
    ctx.strokeStyle = 'rgba(255,255,255,0.25)';
    ctx.lineWidth   = 1;
    ctx.beginPath();
    ctx.moveTo(x - rad, y); ctx.lineTo(x + rad, y);
    ctx.moveTo(x, y - rad); ctx.lineTo(x, y + rad);
    ctx.stroke();

    ctx.restore();
  };

})(window);

/* ══════════════════════════════════════════════════════════════
   TimeControl — playback speed + history scrubber
   Manages slow-motion, fast-forward and history replay.

   Integration in a sim's RAF tick:
     const scaledDt = this._tc.advance(rawDt);
     if (scaledDt === 0) { this.draw(); return; }
     this._step(scaledDt);
     this._tc.record(this.getState());
   ══════════════════════════════════════════════════════════════ */
(function(global) {
  if (global.LSTimeControl) return; // guard: another agent may have loaded

  function _pvClone(obj) {
    try {
      if (typeof structuredClone === 'function') return structuredClone(obj);
    } catch (e) { /* ignore */ }
    return JSON.parse(JSON.stringify(obj));
  }

  class TimeControl {
    constructor(sim, opts) {
      opts = opts || {};
      this.sim        = sim;
      this.scale      = 1;
      this.paused     = false;
      this.history    = [];
      this.historyMax = opts.historyMax || 600; // 10 s @ 60 fps
      this.scrubbing  = false;
      this.scrubIndex = null;
    }

    /* Returns scaled dt (0 when paused or scrubbing) */
    advance(dtRaw) {
      if (this.paused)    return 0;
      if (this.scrubbing) return 0;
      return dtRaw * this.scale;
    }

    record(state) {
      if (this.scrubbing) return;
      this.history.push({ t: performance.now(), state: _pvClone(state) });
      while (this.history.length > this.historyMax) this.history.shift();
    }

    setScrub(idx) {
      this.scrubbing  = true;
      this.scrubIndex = idx;
      if (this.sim.applyState && this.history[idx]) {
        this.sim.applyState(this.history[idx].state);
      }
    }

    exitScrub() { this.scrubbing = false; }

    setScale(s) { this.scale = +s; }

    togglePause() { this.paused = !this.paused; return this.paused; }

    stepBack() {
      if (!this.history.length) return;
      const idx = (this.scrubIndex !== null)
        ? Math.max(0, this.scrubIndex - 1)
        : this.history.length - 1;
      this.setScrub(idx);
    }

    stepForward() {
      if (!this.history.length) return;
      if (this.scrubIndex === null) return;
      const idx = Math.min(this.history.length - 1, this.scrubIndex + 1);
      this.setScrub(idx);
      if (idx >= this.history.length - 1) this.exitScrub();
    }

    reset() {
      this.history    = [];
      this.scrubbing  = false;
      this.scrubIndex = null;
      this.paused     = false;
    }
  }

  global.LSTimeControl = TimeControl;


  /* ════════════════════════════════════════════════════════════
     MotionTrail — fading gradient trail for moving bodies

     Usage each frame:
       trail.push(x, y);
       trail.tick();
       trail.draw(ctx);
     ════════════════════════════════════════════════════════════ */
  class MotionTrail {
    constructor(opts) {
      opts        = opts || {};
      this.points = [];
      this.maxAge = opts.maxAge || 60;
      this.maxLen = opts.maxLen || 120;
      this.color  = opts.color  || '#06D6E0';
      this.width  = opts.width  || 3;
    }

    push(x, y) {
      this.points.push({ x, y, age: 0 });
      while (this.points.length > this.maxLen) this.points.shift();
    }

    tick() {
      for (let i = 0; i < this.points.length; i++) this.points[i].age++;
      while (this.points.length && this.points[0].age > this.maxAge) {
        this.points.shift();
      }
    }

    clear() { this.points = []; }

    draw(ctx) {
      const pts = this.points;
      if (pts.length < 2) return;
      ctx.save();
      ctx.lineCap  = 'round';
      ctx.lineJoin = 'round';

      for (let i = 1; i < pts.length; i++) {
        const p0    = pts[i - 1];
        const p1    = pts[i];
        const ratio = p1.age / this.maxAge;        // 0=fresh, 1=gone
        const alpha = Math.max(0, (1 - ratio) * 0.72);
        if (alpha < 0.01) continue;

        ctx.globalAlpha = alpha;
        ctx.strokeStyle = this.color;
        ctx.lineWidth   = Math.max(0.5, this.width * (1 - ratio * 0.6));
        ctx.shadowColor = this.color;
        ctx.shadowBlur  = 6 * (1 - ratio);

        ctx.beginPath();
        ctx.moveTo(p0.x, p0.y);
        ctx.lineTo(p1.x, p1.y);
        ctx.stroke();
      }
      ctx.restore();
    }
  }

  global.LSMotionTrail = MotionTrail;


  /* ════════════════════════════════════════════════════════════
     buildTimeControlUI(sim, tc, opts)
     Builds and returns a <div> with the full time-control bar.
     opts: { scrubSupported, onPause, onScale }
     ════════════════════════════════════════════════════════════ */
  function buildTimeControlUI(sim, tc, opts) {
    opts = opts || {};
    const scrubSupported = !!opts.scrubSupported;

    const wrap = document.createElement('div');
    wrap.className = 'tc-bar';
    wrap.style.cssText = [
      'display:flex',
      'align-items:center',
      'gap:4px',
      'padding:5px 10px',
      'background:rgba(12,8,28,0.9)',
      'border-top:1px solid rgba(255,255,255,0.07)',
      'flex-wrap:wrap',
      'flex-shrink:0',
    ].join(';');

    const BTN = [
      'display:inline-flex',
      'align-items:center',
      'justify-content: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',
    ].join(';');

    const IC_PAUSE = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>';
    const IC_PLAY  = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
    const IC_BACK  = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><polygon points="19 20 9 12 19 4 19 20"/><line x1="5" y1="4" x2="5" y2="20"/></svg>';
    const IC_FWD   = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="4" x2="19" y2="20"/></svg>';

    function mkBtn(inner, title, onclick) {
      const b = document.createElement('button');
      b.className = 'zoom-btn';
      b.style.cssText = BTN;
      b.title = title || '';
      b.innerHTML = inner;
      b.addEventListener('click', onclick);
      return b;
    }

    function divider() {
      const d = document.createElement('div');
      d.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
      return d;
    }

    /* Pause/Play */
    const playBtn = mkBtn(IC_PAUSE + ' Пауза', 'Пауза / Воспроизведение', function() {
      const paused = tc.togglePause();
      playBtn.innerHTML = paused
        ? IC_PLAY  + ' Играть'
        : IC_PAUSE + ' Пауза';
      if (paused && sim.draw) sim.draw();
      if (opts.onPause) opts.onPause(paused);
    });
    wrap.appendChild(playBtn);
    wrap.appendChild(divider());

    /* Speed buttons */
    const SPEEDS = [0.1, 0.25, 0.5, 1, 2, 5];
    const speedBtns = [];
    SPEEDS.forEach(s => {
      const label = (s === 0.1 ? '0.1' : s === 0.25 ? '¼' : s === 0.5 ? '½' : s) + '×';
      const b = mkBtn(label, s + '× скорость', function() {
        tc.setScale(s);
        speedBtns.forEach(sb => {
          sb.style.background  = 'rgba(28,18,48,0.8)';
          sb.style.color       = '#ccc';
          sb.style.borderColor = 'rgba(255,255,255,0.15)';
        });
        b.style.background  = 'rgba(155,93,229,0.28)';
        b.style.color       = '#c9a0ff';
        b.style.borderColor = 'rgba(155,93,229,0.55)';
        if (opts.onScale) opts.onScale(s);
      });
      if (s === 1) {
        b.style.background  = 'rgba(155,93,229,0.28)';
        b.style.color       = '#c9a0ff';
        b.style.borderColor = 'rgba(155,93,229,0.55)';
      }
      speedBtns.push(b);
      wrap.appendChild(b);
    });

    /* Step + scrubber (only when scrubSupported) */
    if (scrubSupported) {
      wrap.appendChild(divider());

      const backBtn = mkBtn(IC_BACK, 'Шаг назад', function() {
        tc.stepBack();
        if (sim.draw) sim.draw();
        sync();
      });
      wrap.appendChild(backBtn);

      const fwdBtn = mkBtn(IC_FWD, 'Шаг вперёд', function() {
        tc.stepForward();
        if (sim.draw) sim.draw();
        sync();
      });
      wrap.appendChild(fwdBtn);

      wrap.appendChild(divider());

      const scrubWrap = document.createElement('div');
      scrubWrap.style.cssText = 'display:flex;align-items:center;gap:5px;flex:1;min-width:80px;max-width:200px';

      const lbl = document.createElement('span');
      lbl.style.cssText = 'font-size:.62rem;color:rgba(255,255,255,0.4);flex-shrink:0;font-family:Manrope,sans-serif';
      lbl.textContent = 'История:';

      const slider = document.createElement('input');
      slider.type  = 'range';
      slider.min   = '0';
      slider.max   = '0';
      slider.value = '0';
      slider.style.cssText = 'flex:1;accent-color:#9B5DE5;cursor:pointer';

      slider.addEventListener('pointerdown', () => { tc.scrubbing = true; });
      slider.addEventListener('input', function() {
        const idx = parseInt(this.value, 10);
        tc.setScrub(idx);
        if (sim.draw) sim.draw();
      });
      slider.addEventListener('pointerup', function() {
        if (parseInt(this.value, 10) >= tc.history.length - 1) tc.exitScrub();
        sync();
      });

      function sync() {
        const len = tc.history.length;
        if (!len) return;
        slider.max = String(len - 1);
        slider.value = (tc.scrubIndex !== null)
          ? String(tc.scrubIndex)
          : String(len - 1);
      }

      const iv = setInterval(() => {
        if (!document.body.contains(wrap)) { clearInterval(iv); return; }
        sync();
      }, 400);

      scrubWrap.appendChild(lbl);
      scrubWrap.appendChild(slider);
      wrap.appendChild(scrubWrap);
    }

    return wrap;
  }

  global.LSBuildTimeControlUI = buildTimeControlUI;

})(window);
