'use strict';
/* ══════════════════════════════════════════════════════════
   MagneticSim — magnetic field of current-carrying wires
   • Click canvas to place wire (• out / × in)
   • Drag to reposition, double-click / right-click to remove
   • Layers: colour map, field lines, vector arrows
   • Particle: charged particle with Lorentz force (circular)
   B field of wire at (x0,y0) with current I:
     Bx = -k·I·(y-y0)/r²,  By = k·I·(x-x0)/r²
   Colour map maps angle of B <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> hue, magnitude <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> brightness
══════════════════════════════════════════════════════════ */

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

    this.sources  = [];   // {id, x, y, I}
    this._nextId  = 1;
    this.curI     = 6;    // current magnitude (user-adjustable)
    this.addMode  = 'out'; // 'out' | 'in'

    /* particle */
    this._particle  = null;
    this.particleOn = false;
    this._pRaf      = null;
    this._pLast     = 0;

    /* layers */
    this.layers = { colormap: true, fieldlines: true, vectors: false };

    /* conductor (проводник в поле) */
    this._cond = {
      on: false,
      x1: 0, y1: 0, x2: 0, y2: 0,   // set in fit()
      I: 8,                            // conductor current
      _dragEndpoint: null,             // 0 | 1 | 'body' | null
    };

    /* magnetic flux indicator (круг потока) */
    this._flux = {
      on: false,
      x: 0, y: 0,                     // set in fit()
      r: 55,
      _dragging: false,
    };

    /* cursor B reading */
    this._cursorB  = null;             // {x, y, bx, by, mag}
    this._mousePos = null;

    /* interaction */
    this._drag    = null;   // index into sources[]
    this._hovered = null;

    /* offscreen canvas for pixel-level colour map */
    this._oc  = null;   // created in fit()
    this._ocW = 0;
    this._ocH = 0;

    this.W = 0; this.H = 0;
    this.onUpdate = null;

    this._bindEvents();
  }

  /* ────────────────────────────────
     Sizing
  ──────────────────────────────── */

  fit() {
    const rect = this.canvas.getBoundingClientRect();
    const dpr  = window.devicePixelRatio || 1;
    this.canvas.width  = rect.width  * dpr;
    this.canvas.height = rect.height * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.W = rect.width;
    this.H = rect.height;

    const DS = 4;                          // downsample factor for colour map
    this._ocW = Math.ceil(this.W / DS);
    this._ocH = Math.ceil(this.H / DS);
    this._oc  = document.createElement('canvas');
    this._oc.width  = this._ocW;
    this._oc.height = this._ocH;

    this._initOverlays();

    /* position conductor + flux relative to canvas */
    if (this.W) {
      this._cond.x1 = this.W * 0.25; this._cond.y1 = this.H * 0.5;
      this._cond.x2 = this.W * 0.75; this._cond.y2 = this.H * 0.5;
      this._flux.x  = this.W * 0.5;  this._flux.y  = this.H * 0.35;
    }

    this.draw();
  }

  /* init conductor / flux positions on first fit */
  _initOverlays() {
    this._cond.x1 = this.W * 0.25; this._cond.y1 = this.H * 0.5;
    this._cond.x2 = this.W * 0.75; this._cond.y2 = this.H * 0.5;
    this._flux.x  = this.W * 0.5;  this._flux.y  = this.H * 0.35;
  }

  /* ────────────────────────────────
     Field calculation
  ──────────────────────────────── */

  _K = 8000;   // visual scaling constant

  _field(px, py) {
    let bx = 0, by = 0;
    for (const s of this.sources) {
      const dx = px - s.x, dy = py - s.y;
      const r2 = dx * dx + dy * dy;
      if (r2 < 4) continue;
      const k = this._K * s.I / r2;
      bx -= k * dy;
      by += k * dx;
    }
    const mag = Math.hypot(bx, by);
    return { bx, by, mag };
  }

  _fieldNorm(px, py) {
    const { bx, by, mag } = this._field(px, py);
    if (mag < 1e-12) return { nx: 0, ny: 0, mag: 0 };
    return { nx: bx / mag, ny: by / mag, mag };
  }

  /* RK4 step for field-line tracing */
  _rk4(x, y, step) {
    const f = (xx, yy) => this._fieldNorm(xx, yy);
    const k1 = f(x, y);
    const k2 = f(x + step * k1.nx * 0.5, y + step * k1.ny * 0.5);
    const k3 = f(x + step * k2.nx * 0.5, y + step * k2.ny * 0.5);
    const k4 = f(x + step * k3.nx,       y + step * k3.ny);
    return {
      nx: (k1.nx + 2*k2.nx + 2*k3.nx + k4.nx) / 6,
      ny: (k1.ny + 2*k2.ny + 2*k3.ny + k4.ny) / 6,
    };
  }

  /* ────────────────────────────────
     Source management
  ──────────────────────────────── */

  addSource(x, y, dir) {
    this.sources.push({
      id: this._nextId++,
      x, y,
      I: dir === 'out' ? +this.curI : -this.curI,
    });
    this._invalidateCache();
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

  removeSource(id) {
    this.sources = this.sources.filter(s => s.id !== id);
    this._invalidateCache();
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

  clearAll() {
    this.sources = [];
    this._particle = null;
    this._invalidateCache();
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

  setCurrentAll(I) {
    this.curI = I;
    this.sources.forEach(s => { s.I = s.I > 0 ? I : -I; });
    this._invalidateCache();
    this.draw();
  }

  /* Invalidate precomputed colour map cache */
  _invalidateCache() { this._cmapDirty = true; }

  /* ── conductor & flux toggles ── */

  toggleConductor() {
    this._cond.on = !this._cond.on;
    this.draw();
  }

  setConductorI(I) {
    this._cond.I = I;
    this.draw();
  }

  toggleFlux() {
    this._flux.on = !this._flux.on;
    this.draw();
  }

  /* Ampere force on conductor: F = I·(L×B)
     L = conductor vector, B from wire sources at midpoint
     In 3D with B in xy-plane:  F = (0, 0, I*(Lx*By - Ly*Bx))  [force in z]
     We display Fz magnitude + direction (⊙ out / ⊗ in) */
  _ampereForce() {
    const c = this._cond;
    const Lx = c.x2 - c.x1, Ly = c.y2 - c.y1;
    const L  = Math.hypot(Lx, Ly);
    if (L < 1) return { Fz: 0, L, B: 0 };
    const mx = (c.x1 + c.x2) / 2, my = (c.y1 + c.y2) / 2;
    const { bx, by, mag } = this._field(mx, my);
    // F_z = I*(Lx*By - Ly*Bx)  — in "visual units"
    const Fz = c.I * (Lx * by - Ly * bx) * 0.0001;
    return { Fz, L: L / 100, B: mag, bx, by, mx, my };
  }

  /* Magnetic flux through indicator circle: Φ ≈ |B_avg|·πr² */
  _fluxValue() {
    const f = this._flux;
    const { mag } = this._field(f.x, f.y);
    return mag * Math.PI * f.r * f.r * 0.000001;  // visual units
  }

  /* Preset arrangements */
  preset(name) {
    this.sources = [];
    const cx = this.W / 2, cy = this.H / 2, d = 90;
    switch (name) {
      case 'single':
        this.sources.push({ id: this._nextId++, x: cx, y: cy, I: +this.curI });
        break;
      case 'parallel':
        this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI });
        this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: +this.curI });
        break;
      case 'anti':
        this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI });
        this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: -this.curI });
        break;
      case 'solenoid': {
        const cols = 5, rows = 2, gx = 60, gy = 70;
        for (let c = 0; c < cols; c++) {
          const x = cx + (c - (cols-1)/2) * gx;
          this.sources.push({ id: this._nextId++, x, y: cy - gy/2, I: +this.curI });
          this.sources.push({ id: this._nextId++, x, y: cy + gy/2, I: -this.curI });
        }
        break;
      }
      case 'quadrupole':
        this.sources.push({ id: this._nextId++, x: cx - d, y: cy,     I: +this.curI });
        this.sources.push({ id: this._nextId++, x: cx + d, y: cy,     I: +this.curI });
        this.sources.push({ id: this._nextId++, x: cx,     y: cy - d, I: -this.curI });
        this.sources.push({ id: this._nextId++, x: cx,     y: cy + d, I: -this.curI });
        break;
      case 'ring': {
        const n = 8, r = 110;
        for (let i = 0; i < n; i++) {
          const a = (i / n) * Math.PI * 2;
          const dir = i % 2 === 0 ? +this.curI : -this.curI;
          this.sources.push({ id: this._nextId++,
            x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r, I: dir });
        }
        break;
      }
      case 'dipole':
        this.sources.push({ id: this._nextId++, x: cx - 60, y: cy, I: +this.curI * 1.5 });
        this.sources.push({ id: this._nextId++, x: cx + 60, y: cy, I: -this.curI * 1.5 });
        this.sources.push({ id: this._nextId++, x: cx - 60, y: cy - 50, I: +this.curI * 0.5 });
        this.sources.push({ id: this._nextId++, x: cx + 60, y: cy - 50, I: -this.curI * 0.5 });
        this.sources.push({ id: this._nextId++, x: cx - 60, y: cy + 50, I: +this.curI * 0.5 });
        this.sources.push({ id: this._nextId++, x: cx + 60, y: cy + 50, I: -this.curI * 0.5 });
        break;
    }
    this._invalidateCache();
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

  /* ────────────────────────────────
     Particle (Lorentz force)
     F = q(v × B)
     Treat |B_xy| as Bz (educational approximation for 2D):
       Fx = q·vy·Bz,  Fy = -q·vx·Bz
  ──────────────────────────────── */

  toggleParticle() {
    this.particleOn = !this.particleOn;
    if (this.particleOn) {
      this._initParticle();
      this._pLast = performance.now();
      this._tickParticle();
    } else {
      if (this._pRaf) cancelAnimationFrame(this._pRaf);
      this._pRaf = null;
      this._particle = null;
      this.draw();
    }
    if (this.onUpdate) this.onUpdate(this.info());
  }

  _initParticle() {
    this._particle = {
      x: this.W * 0.18, y: this.H * 0.5,
      vx: 2.2, vy: 0,
      q: 1,
      trail: [],
    };
  }

  _tickParticle() {
    if (!this.particleOn || !this._particle) return;
    const now = performance.now();
    const dt  = Math.min((now - this._pLast) * 0.06, 2.5);
    this._pLast = now;

    const p  = this._particle;
    const { mag } = this._field(p.x, p.y);
    const Bz = mag * 0.00012 * p.q;

    const spd = Math.hypot(p.vx, p.vy);

    // Lorentz (2D): Fx = q·vy·Bz, Fy = -q·vx·Bz
    p.vx += p.q * p.vy * Bz * dt;
    p.vy -= p.q * p.vx * Bz * dt;

    // Conserve speed (magnetic force does no work)
    const newSpd = Math.hypot(p.vx, p.vy);
    if (newSpd > 1e-6) { p.vx = p.vx / newSpd * spd; p.vy = p.vy / newSpd * spd; }

    p.x += p.vx * dt;
    p.y += p.vy * dt;

    // Bounce walls
    if (p.x < 4)          { p.vx =  Math.abs(p.vx); p.x = 4; }
    if (p.x > this.W - 4) { p.vx = -Math.abs(p.vx); p.x = this.W - 4; }
    if (p.y < 4)          { p.vy =  Math.abs(p.vy); p.y = 4; }
    if (p.y > this.H - 4) { p.vy = -Math.abs(p.vy); p.y = this.H - 4; }

    p.trail.push({ x: p.x, y: p.y });
    if (p.trail.length > 350) p.trail.shift();

    this.draw();
    this._pRaf = requestAnimationFrame(() => this._tickParticle());
  }

  /* ────────────────────────────────
     Info
  ──────────────────────────────── */

  info() {
    const out = this.sources.filter(s => s.I > 0).length;
    const inn = this.sources.filter(s => s.I < 0).length;
    const condOn  = this._cond.on;
    const fluxOn  = this._flux.on;
    const ampere  = condOn ? this._ampereForce() : null;
    const Fz      = ampere ? ampere.Fz : 0;
    const flux    = fluxOn ? this._fluxValue() : 0;
    const cursorB = this._cursorB ? this._cursorB.mag : null;
    return { total: this.sources.length, out, inn, particleOn: this.particleOn,
             condOn, fluxOn, Fz, flux, cursorB };
  }

  /* ────────────────────────────────
     Events
  ──────────────────────────────── */

  _bindEvents() {
    const c = this.canvas;

    const pos = e => {
      const r = c.getBoundingClientRect();
      const s = e.touches ? e.touches[0] : e;
      return { x: s.clientX - r.left, y: s.clientY - r.top };
    };

    const hitIdx = p => {
      for (let i = this.sources.length - 1; i >= 0; i--) {
        if (Math.hypot(p.x - this.sources[i].x, p.y - this.sources[i].y) < 22) return i;
      }
      return -1;
    };

    /* hit test conductor endpoints / body */
    const hitCond = p => {
      if (!this._cond.on) return null;
      const { x1, y1, x2, y2 } = this._cond;
      if (Math.hypot(p.x - x1, p.y - y1) < 16) return 0;
      if (Math.hypot(p.x - x2, p.y - y2) < 16) return 1;
      // check midpoint drag
      const mx = (x1+x2)/2, my = (y1+y2)/2;
      if (Math.hypot(p.x - mx, p.y - my) < 14) return 'body';
      return null;
    };

    /* hit test flux circle */
    const hitFlux = p => {
      if (!this._flux.on) return false;
      return Math.hypot(p.x - this._flux.x, p.y - this._flux.y) < this._flux.r + 12;
    };

    let _mousedownPos = null;
    let _condDragOffset = null;

    c.addEventListener('mousedown', e => {
      if (e.button !== 0) return;
      const p  = pos(e);
      _mousedownPos = p;

      /* conductor endpoint drag */
      const ch = hitCond(p);
      if (ch !== null) {
        this._cond._dragEndpoint = ch;
        if (ch === 'body') {
          _condDragOffset = { dx: p.x - this._cond.x1, dy: p.y - this._cond.y1,
                              len: Math.hypot(this._cond.x2-this._cond.x1, this._cond.y2-this._cond.y1) };
        }
        c.style.cursor = 'grabbing';
        return;
      }

      /* flux drag */
      if (hitFlux(p)) {
        this._flux._dragging = true;
        c.style.cursor = 'grabbing';
        return;
      }

      const i  = hitIdx(p);
      if (i >= 0) {
        this._drag = i;
        c.style.cursor = 'grabbing';
      }
    });

    c.addEventListener('mousemove', e => {
      const p = pos(e);

      /* update cursor B reading */
      if (!e.buttons) {
        if (this.sources.length > 0) {
          const f = this._field(p.x, p.y);
          this._cursorB = { x: p.x, y: p.y, ...f };
          if (this.onUpdate) this.onUpdate(this.info());
        }
        this._mousePos = p;
      }

      /* conductor drag */
      if (this._cond._dragEndpoint !== null) {
        const ep = this._cond._dragEndpoint;
        if (ep === 0) { this._cond.x1 = p.x; this._cond.y1 = p.y; }
        else if (ep === 1) { this._cond.x2 = p.x; this._cond.y2 = p.y; }
        else if (ep === 'body') {
          const L  = _condDragOffset.len;
          const dx = this._cond.x2 - this._cond.x1, dy = this._cond.y2 - this._cond.y1;
          const nx = dx / Math.hypot(dx, dy), ny = dy / Math.hypot(dx, dy);
          this._cond.x1 = p.x - _condDragOffset.dx;
          this._cond.y1 = p.y - _condDragOffset.dy;
          this._cond.x2 = this._cond.x1 + nx * L;
          this._cond.y2 = this._cond.y1 + ny * L;
        }
        this.draw();
        return;
      }

      /* flux drag */
      if (this._flux._dragging) {
        this._flux.x = p.x; this._flux.y = p.y;
        this.draw(); return;
      }

      if (this._drag !== null) {
        this.sources[this._drag].x = p.x;
        this.sources[this._drag].y = p.y;
        this._invalidateCache();
        this.draw();
        return;
      }
      const i   = hitIdx(p);
      const ch  = hitCond(p);
      const fh  = hitFlux(p);
      this._hovered = i >= 0 ? i : null;
      c.style.cursor = (i >= 0 || ch !== null || fh) ? 'grab' : 'crosshair';
    });

    c.addEventListener('mouseup', e => {
      const p   = pos(e);
      const moved = _mousedownPos &&
        Math.hypot(p.x - _mousedownPos.x, p.y - _mousedownPos.y) > 5;

      if (this._cond._dragEndpoint !== null) {
        this._cond._dragEndpoint = null; c.style.cursor = 'crosshair'; this.draw(); return;
      }
      if (this._flux._dragging) {
        this._flux._dragging = false; c.style.cursor = 'crosshair'; return;
      }

      if (this._drag !== null) {
        this._invalidateCache();
        this._drag = null;
        c.style.cursor = 'crosshair';
        this.draw();
        if (this.onUpdate) this.onUpdate(this.info());
        return;
      }

      // Click (not drag) on empty space <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> add source
      if (!moved && e.button === 0 && hitIdx(p) < 0 &&
          hitCond(p) === null && !hitFlux(p)) {
        this.addSource(p.x, p.y, this.addMode);
      }
    });

    c.addEventListener('dblclick', e => {
      const p = pos(e);
      const i = hitIdx(p);
      if (i >= 0) this.removeSource(this.sources[i].id);
    });

    c.addEventListener('contextmenu', e => {
      e.preventDefault();
      const p = pos(e);
      const i = hitIdx(p);
      if (i >= 0) this.removeSource(this.sources[i].id);
    });

    c.addEventListener('touchstart', e => {
      e.preventDefault();
      _mousedownPos = pos(e);
      const i = hitIdx(_mousedownPos);
      if (i >= 0) this._drag = i;
    }, { passive: false });

    c.addEventListener('touchmove', e => {
      e.preventDefault();
      if (this._drag === null) return;
      const p = pos(e);
      this.sources[this._drag].x = p.x;
      this.sources[this._drag].y = p.y;
      this._invalidateCache();
      this.draw();
    }, { passive: false });

    c.addEventListener('touchend', e => {
      const p = e.changedTouches ? pos({ ...e, touches: e.changedTouches }) : null;
      const moved = _mousedownPos && p &&
        Math.hypot(p.x - _mousedownPos.x, p.y - _mousedownPos.y) > 8;
      if (this._drag === null && !moved && p) {
        this.addSource(p.x, p.y, this.addMode);
      }
      this._drag = null;
      if (this.onUpdate) this.onUpdate(this.info());
    });
  }

  /* ────────────────────────────────
     Drawing
  ──────────────────────────────── */

  draw() {
    const ctx = this.ctx;
    const W = this.W, H = this.H;
    if (!W || !H) return;

    ctx.clearRect(0, 0, W, H);

    // Background
    const bg = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, Math.max(W, H) * 0.7);
    bg.addColorStop(0, '#080818');
    bg.addColorStop(1, '#030308');
    ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);

    this._drawGrid(ctx, W, H);

    if (this.sources.length > 0) {
      if (this.layers.colormap)   this._drawColormap(ctx);
      if (this.layers.fieldlines) this._drawFieldLines(ctx);
      if (this.layers.vectors)    this._drawVectors(ctx);
    }

    if (this._flux.on)            this._drawFlux(ctx);
    if (this._cond.on)            this._drawConductor(ctx);
    if (this._particle)           this._drawParticle(ctx);
    this._drawSources(ctx);
    if (this._cursorB && this.sources.length > 0) this._drawCursorB(ctx);

    if (this.sources.length === 0) this._drawHint(ctx, W, H);
  }

  /* ── grid ── */
  _drawGrid(ctx, W, H) {
    ctx.save();
    ctx.strokeStyle = 'rgba(155,93,229,0.055)'; ctx.lineWidth = 1;
    for (let x = 0; x <= W; x += 50) {
      ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
    }
    for (let y = 0; y <= H; y += 50) {
      ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
    }
    ctx.restore();
  }

  /* ── colour map (hue = angle of B, brightness = log|B|) ── */
  _cmapDirty = true;

  _drawColormap(ctx) {
    if (!this._oc) return;
    const DS = 4;
    const oc  = this._oc;
    const oct = oc.getContext('2d');
    const w   = this._ocW, h = this._ocH;

    if (this._cmapDirty) {
      const imgData = oct.createImageData(w, h);
      const data = imgData.data;

      for (let py = 0; py < h; py++) {
        for (let px = 0; px < w; px++) {
          const wx = px * DS, wy = py * DS;
          const { bx, by, mag } = this._field(wx, wy);
          if (mag < 0.5) continue;

          const angle = Math.atan2(by, bx);          // -π…π
          const hue   = ((angle / (2 * Math.PI) + 1) % 1) * 360;
          const bright = Math.min(1, Math.log10(1 + mag * 0.005) * 0.55);
          const alpha  = Math.round(bright * 210);

          const [r, g, b] = this._hsl(hue / 360, 0.90, 0.38 + bright * 0.28);
          const idx = (py * w + px) * 4;
          data[idx]   = r;
          data[idx+1] = g;
          data[idx+2] = b;
          data[idx+3] = alpha;
        }
      }
      oct.putImageData(imgData, 0, 0);
      this._cmapDirty = false;
    }

    ctx.save();
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    ctx.drawImage(oc, 0, 0, w * DS, h * DS);
    ctx.restore();
  }

  _hsl(h, s, l) {
    let r, g, b;
    if (s === 0) { r = g = b = l; }
    else {
      const q = l < 0.5 ? l*(1+s) : l+s-l*s, p = 2*l - q;
      const hue2 = (p, q, t) => {
        t = ((t % 1) + 1) % 1;
        if (t < 1/6) return p + (q-p)*6*t;
        if (t < 1/2) return q;
        if (t < 2/3) return p + (q-p)*(2/3-t)*6;
        return p;
      };
      r = hue2(p, q, h + 1/3); g = hue2(p, q, h); b = hue2(p, q, h - 1/3);
    }
    return [Math.round(r*255), Math.round(g*255), Math.round(b*255)];
  }

  /* ── field lines ── */
  _drawFieldLines(ctx) {
    if (!this.sources.length) return;
    const maxSteps = 700;
    const step     = 5;
    const killR    = 24;

    ctx.save();

    for (const src of this.sources) {
      const isOut  = src.I > 0;
      const col    = isOut ? '6,214,224' : '241,91,181';
      const nLines = 14;
      const seedR  = 26;

      for (let li = 0; li < nLines; li++) {
        const ang = (li / nLines) * Math.PI * 2;
        let x = src.x + Math.cos(ang) * seedR;
        let y = src.y + Math.sin(ang) * seedR;

        const pts = [{ x, y }];
        let travelledSq = 0;

        for (let st = 0; st < maxSteps; st++) {
          const { nx, ny } = this._rk4(x, y, step);
          x += step * nx;
          y += step * ny;
          travelledSq += step * step;

          if (x < -60 || x > this.W + 60 || y < -60 || y > this.H + 60) break;

          /* stop near another source */
          let nearOther = false;
          for (const s2 of this.sources) {
            if (s2 === src) continue;
            if (Math.hypot(x - s2.x, y - s2.y) < killR) { nearOther = true; break; }
          }
          if (nearOther) { pts.push({ x, y }); break; }

          /* stop looping back to origin */
          if (st > 20 && Math.hypot(x - src.x, y - src.y) < killR) break;

          pts.push({ x, y });
        }

        if (pts.length < 3) continue;

        /* draw with glow */
        ctx.shadowColor = `rgba(${col},0.5)`;
        ctx.shadowBlur  = 7;
        ctx.strokeStyle = `rgba(${col},0.65)`;
        ctx.lineWidth   = 1.6;

        ctx.beginPath();
        ctx.moveTo(pts[0].x, pts[0].y);
        for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y);
        ctx.stroke();

        /* arrowheads every ~85 px */
        this._drawArrows(ctx, pts, col);
      }
    }
    ctx.restore();
  }

  _drawArrows(ctx, pts, col) {
    let acc = 0, next = 80;
    for (let i = 1; i < pts.length; i++) {
      const dx = pts[i].x - pts[i-1].x, dy = pts[i].y - pts[i-1].y;
      acc += Math.hypot(dx, dy);
      if (acc < next) continue;
      next += 85;
      const ang = Math.atan2(dy, dx);
      ctx.save();
      ctx.translate(pts[i].x, pts[i].y);
      ctx.rotate(ang);
      ctx.shadowColor = `rgba(${col},0.9)`; ctx.shadowBlur = 6;
      ctx.fillStyle   = `rgba(${col},0.90)`;
      ctx.beginPath();
      ctx.moveTo(7, 0); ctx.lineTo(-5, -4); ctx.lineTo(-3, 0); ctx.lineTo(-5, 4);
      ctx.closePath(); ctx.fill();
      ctx.restore();
    }
  }

  /* ── vector field ── */
  _drawVectors(ctx) {
    if (!this.sources.length) return;
    const step = 42;
    ctx.save();
    for (let px = step * 0.5; px < this.W; px += step) {
      for (let py = step * 0.5; py < this.H; py += step) {
        const { bx, by, mag } = this._field(px, py);
        if (mag < 1) continue;
        const t   = Math.min(1, Math.log10(1 + mag * 0.006) / 1.4);
        const len = 8 + t * 14;
        const nx  = bx / mag, ny = by / mag;
        const alp = 0.28 + t * 0.6;

        ctx.save();
        ctx.translate(px, py);
        ctx.rotate(Math.atan2(ny, nx));
        ctx.globalAlpha = alp;
        ctx.strokeStyle = `rgba(${Math.round(155+t*100)},${Math.round(93+t*121)},229,1)`;
        ctx.lineWidth   = 1.1 + t * 0.6;
        ctx.beginPath(); ctx.moveTo(-len/2, 0); ctx.lineTo(len/2, 0); ctx.stroke();
        ctx.fillStyle = ctx.strokeStyle;
        ctx.beginPath();
        ctx.moveTo(len/2, 0); ctx.lineTo(len/2-5, -2.5); ctx.lineTo(len/2-5, 2.5);
        ctx.closePath(); ctx.fill();
        ctx.restore();
      }
    }
    ctx.restore();
  }

  /* ── sources ── */
  _drawSources(ctx) {
    this.sources.forEach((s, i) => {
      const isOut  = s.I > 0;
      const col    = isOut ? '#06D6E0' : '#F15BB5';
      const rgb    = isOut ? '6,214,224' : '241,91,181';
      const isHov  = this._hovered === i || this._drag === i;
      const R      = isHov ? 19 : 16;

      ctx.save();
      ctx.shadowColor = col; ctx.shadowBlur = isHov ? 32 : 18;

      /* halo ring */
      ctx.beginPath(); ctx.arc(s.x, s.y, R + 6, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(${rgb},0.08)`; ctx.fill();

      /* body disc */
      ctx.beginPath(); ctx.arc(s.x, s.y, R, 0, Math.PI * 2);
      ctx.fillStyle = isHov ? `rgba(${rgb},0.25)` : 'rgba(5,5,20,0.9)';
      ctx.fill();
      ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.stroke();

      /* symbol */
      if (isOut) {
        /* dot = current toward viewer */
        ctx.beginPath(); ctx.arc(s.x, s.y, 5, 0, Math.PI * 2);
        ctx.fillStyle = col; ctx.shadowBlur = 8; ctx.fill();
      } else {
        /* × = current away from viewer */
        const d = 5.5;
        ctx.strokeStyle = col; ctx.lineWidth = 2.2; ctx.shadowBlur = 6;
        ctx.beginPath();
        ctx.moveTo(s.x - d, s.y - d); ctx.lineTo(s.x + d, s.y + d);
        ctx.moveTo(s.x + d, s.y - d); ctx.lineTo(s.x - d, s.y + d);
        ctx.stroke();
      }

      /* current label below */
      ctx.shadowBlur = 0;
      ctx.font = '10px Manrope, sans-serif';
      ctx.fillStyle = `rgba(${rgb},0.75)`;
      ctx.textAlign = 'center'; ctx.textBaseline = 'top';
      ctx.fillText((isOut ? '↑' : '↓') + ' ' + Math.abs(s.I).toFixed(0) + ' А', s.x, s.y + R + 5);

      ctx.restore();
    });
  }

  /* ── particle ── */
  _drawParticle(ctx) {
    const p = this._particle;
    if (!p) return;

    /* trail */
    if (p.trail.length > 1) {
      ctx.save();
      for (let i = 1; i < p.trail.length; i++) {
        const t = i / p.trail.length;
        ctx.beginPath();
        ctx.moveTo(p.trail[i-1].x, p.trail[i-1].y);
        ctx.lineTo(p.trail[i].x, p.trail[i].y);
        ctx.strokeStyle = `rgba(255,255,80,${t * 0.55})`;
        ctx.lineWidth = t * 2.5;
        ctx.stroke();
      }
      ctx.restore();
    }

    /* glow aura */
    const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 16);
    grad.addColorStop(0, 'rgba(255,255,80,0.35)');
    grad.addColorStop(1, 'rgba(255,255,80,0)');
    ctx.save(); ctx.fillStyle = grad;
    ctx.beginPath(); ctx.arc(p.x, p.y, 16, 0, Math.PI*2); ctx.fill(); ctx.restore();

    /* body */
    ctx.save();
    ctx.shadowColor = '#ffff50'; ctx.shadowBlur = 18;
    ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, Math.PI*2);
    ctx.fillStyle = '#ffff50'; ctx.fill();
    ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.8; ctx.stroke();
    ctx.restore();

    /* velocity arrow */
    const spd = Math.hypot(p.vx, p.vy);
    if (spd > 0.01) {
      const s = 22;
      ctx.save();
      ctx.strokeStyle = 'rgba(255,255,80,0.7)'; ctx.lineWidth = 1.8;
      ctx.shadowColor = '#ffff50'; ctx.shadowBlur = 8;
      ctx.beginPath();
      ctx.moveTo(p.x, p.y);
      ctx.lineTo(p.x + p.vx / spd * s, p.y + p.vy / spd * s);
      ctx.stroke();
      ctx.restore();
    }
  }

  /* ── empty hint ── */
  /* ── conductor (проводник в поле — Сила Ампера) ── */
  _drawConductor(ctx) {
    const c = this._cond;
    const Lx = c.x2 - c.x1, Ly = c.y2 - c.y1;
    const L  = Math.hypot(Lx, Ly);
    if (L < 2) return;

    const { Fz, B, bx, by, mx, my } = this._ampereForce();
    const Fabs = Math.abs(Fz);
    const fOut = Fz > 0; // force out of screen (⊙) vs into screen (⊗)

    ctx.save();

    /* glow under conductor */
    ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 14;
    ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 5;
    ctx.globalAlpha = 0.35;
    ctx.beginPath(); ctx.moveTo(c.x1, c.y1); ctx.lineTo(c.x2, c.y2); ctx.stroke();

    /* main conductor line */
    ctx.globalAlpha = 1; ctx.shadowBlur = 6;
    ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 3.5; ctx.lineCap = 'round';
    ctx.beginPath(); ctx.moveTo(c.x1, c.y1); ctx.lineTo(c.x2, c.y2); ctx.stroke();

    /* current direction arrows along conductor */
    const steps = Math.floor(L / 55);
    ctx.fillStyle = '#F15BB5'; ctx.shadowBlur = 5;
    for (let s = 0; s <= steps; s++) {
      const t  = (s + 0.5) / (steps + 1);
      const ax = c.x1 + Lx * t, ay = c.y1 + Ly * t;
      const ang = c.I > 0 ? Math.atan2(Ly, Lx) : Math.atan2(-Ly, -Lx);
      ctx.save(); ctx.translate(ax, ay); ctx.rotate(ang);
      ctx.beginPath(); ctx.moveTo(7,0); ctx.lineTo(-5,-4); ctx.lineTo(-5,4); ctx.closePath();
      ctx.fill(); ctx.restore();
    }

    /* endpoints handle dots */
    [[c.x1, c.y1], [c.x2, c.y2]].forEach(([ex, ey]) => {
      ctx.beginPath(); ctx.arc(ex, ey, 8, 0, Math.PI*2);
      ctx.fillStyle = '#F15BB5'; ctx.shadowBlur = 10; ctx.fill();
      ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
    });

    /* B vector at midpoint */
    if (B > 0.5 && this.sources.length) {
      const bScale = Math.min(40, Math.log10(1 + B * 0.02) * 50);
      const bNorm  = Math.hypot(bx, by);
      const bnx = bx/bNorm, bny = by/bNorm;
      ctx.strokeStyle = '#22d55e'; ctx.lineWidth = 1.5; ctx.shadowColor = '#22d55e';
      ctx.beginPath(); ctx.moveTo(mx, my);
      ctx.lineTo(mx + bnx*bScale, my + bny*bScale); ctx.stroke();
      ctx.fillStyle = '#22d55e';
      ctx.font = '10px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
      ctx.fillText('B⃗', mx + bnx*(bScale+10), my + bny*(bScale+10));
    }

    /* Ampere force symbols along conductor */
    if (Fabs > 1e-6) {
      const sym     = fOut ? '⊙' : '⊗';
      const symCol  = fOut ? '#06D6E0' : '#ff6060';
      const symSize = Math.min(22, 8 + Fabs * 200);
      const perpX   = -Ly / L, perpY = Lx / L; // perpendicular to conductor
      const offset  = fOut ? -35 : 35;           // visual direction hint

      ctx.font = `${symSize}px Manrope`;
      ctx.fillStyle = symCol; ctx.shadowColor = symCol; ctx.shadowBlur = 10;
      ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      const symCount = Math.max(1, Math.min(5, Math.floor(L / 80)));
      for (let s = 0; s < symCount; s++) {
        const t = (s + 0.5) / symCount;
        ctx.fillText(sym,
          c.x1 + Lx*t + perpX * 22,
          c.y1 + Ly*t + perpY * 22);
      }

      /* force magnitude label */
      ctx.font = 'bold 11px Manrope'; ctx.shadowBlur = 5;
      ctx.fillStyle = symCol;
      ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
      ctx.fillText('F = ' + Fabs.toFixed(3) + ' (ед)', c.x2 + 12, c.y2);
    }

    /* current label */
    ctx.shadowBlur = 0;
    ctx.font = '10px Manrope'; ctx.fillStyle = 'rgba(241,91,181,0.8)';
    ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
    const ang2 = Math.atan2(Ly, Lx);
    ctx.fillText('I = ' + c.I + ' А', mx - Math.sin(ang2)*20, my + Math.cos(ang2)*(-20));

    ctx.restore();
  }

  /* ── flux circle (магнитный поток) ── */
  _drawFlux(ctx) {
    const f = this._flux;
    const Phi = this._fluxValue();
    const { mag } = this._field(f.x, f.y);
    const brightness = Math.min(1, Math.log10(1 + mag * 0.003) * 0.7);

    ctx.save();

    /* filled circle — colour by field strength */
    const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.r);
    grad.addColorStop(0,   `rgba(255,220,50,${brightness * 0.4})`);
    grad.addColorStop(0.6, `rgba(155,93,229,${brightness * 0.15})`);
    grad.addColorStop(1,   'transparent');
    ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, Math.PI*2); ctx.fill();

    /* dashed border */
    ctx.strokeStyle = 'rgba(255,220,50,0.7)'; ctx.lineWidth = 1.8;
    ctx.setLineDash([6, 4]); ctx.shadowColor = '#ffdc32'; ctx.shadowBlur = 8;
    ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, Math.PI*2); ctx.stroke();
    ctx.setLineDash([]);

    /* centre dot */
    ctx.beginPath(); ctx.arc(f.x, f.y, 4, 0, Math.PI*2);
    ctx.fillStyle = '#ffdc32'; ctx.fill();

    /* flux label */
    ctx.font = 'bold 11px Manrope'; ctx.fillStyle = '#ffdc32';
    ctx.shadowColor = '#ffdc32'; ctx.shadowBlur = 6;
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    ctx.fillText('Φ = ' + Phi.toFixed(4) + ' Вб', f.x, f.y + f.r + 6);
    ctx.fillText('|B| = ' + mag.toFixed(1) + ' (ед)', f.x, f.y + f.r + 20);

    ctx.restore();
  }

  /* ── B value at cursor ── */
  _drawCursorB(ctx) {
    const b = this._cursorB;
    if (!b || !this._mousePos) return;
    const { x, y, mag, bx, by } = b;
    if (mag < 0.5) return;

    ctx.save();
    /* small circle */
    ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1;
    ctx.setLineDash([3,3]); ctx.beginPath(); ctx.arc(x, y, 14, 0, Math.PI*2); ctx.stroke();
    ctx.setLineDash([]);

    /* B direction arrow */
    const bNorm = Math.hypot(bx, by);
    const len   = Math.min(28, Math.log10(1 + mag * 0.01) * 35);
    const bnx = bx/bNorm, bny = by/bNorm;
    const col   = 'rgba(255,255,255,0.6)';
    ctx.strokeStyle = col; ctx.lineWidth = 1.2;
    ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + bnx*len, y + bny*len); ctx.stroke();
    ctx.fillStyle = col;
    const a = Math.atan2(bny, bnx);
    const tx = x + bnx*len, ty = y + bny*len;
    ctx.beginPath(); ctx.moveTo(tx,ty);
    ctx.lineTo(tx - 6*Math.cos(a-0.4), ty - 6*Math.sin(a-0.4));
    ctx.lineTo(tx - 6*Math.cos(a+0.4), ty - 6*Math.sin(a+0.4));
    ctx.closePath(); ctx.fill();

    /* label */
    ctx.font = '9px Manrope'; ctx.fillStyle = 'rgba(255,255,255,0.6)';
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillText('|B|=' + mag.toFixed(0), x + 18, y - 8);

    ctx.restore();
  }

  _drawHint(ctx, W, H) {
    ctx.save();
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.font = '16px Manrope, sans-serif';
    ctx.fillStyle = 'rgba(155,93,229,0.45)';
    ctx.fillText('Нажми на канвас — добавь провод с током', W/2, H/2 - 18);
    ctx.font = '13px Manrope, sans-serif';
    ctx.fillStyle = 'rgba(255,255,255,0.22)';
    ctx.fillText('• Ток на нас   ×  Ток от нас   ПКМ / двойной клик — удалить', W/2, H/2 + 14);
    ctx.restore();
  }
}

/* ─── lab UI init ─────────────────────────────────── */
  function _openMagnetic() {
    document.getElementById('sim-topbar-title').textContent = 'Магнитное поле токов';
    _simShow('sim-mag');
    _simShow('ctrl-mag');

    requestAnimationFrame(() => requestAnimationFrame(() => {
      if (!mSim) {
        mSim = new MagneticSim(document.getElementById('mag-canvas'));
        mSim.onUpdate = _magUpdateUI;
      }
      mSim.fit();
      // default preset on first open
      if (mSim.sources.length === 0) mSim.preset('anti');
      _magUpdateUI(mSim.info());
    }));
  }

  function magMode(dir) {
    if (!mSim) return;
    mSim.addMode = dir;
    document.getElementById('mag-add-out').classList.toggle('active', dir === 'out');
    document.getElementById('mag-add-in').classList.toggle('active', dir === 'in');
    document.getElementById('mag-mode-out').classList.toggle('active', dir === 'out');
    document.getElementById('mag-mode-in').classList.toggle('active', dir === 'in');
  }

  function magCurrentChange() {
    const I = +document.getElementById('sl-curI').value;
    document.getElementById('m-curI').textContent  = I + ' А';
    document.getElementById('mbar-I').textContent  = I + ' А';
    if (mSim) mSim.setCurrentAll(I);
  }

  function magLayer(name, rowEl) {
    if (!mSim) return;
    mSim.layers[name] = !mSim.layers[name];
    rowEl.classList.toggle('active', mSim.layers[name]);
    mSim._invalidateCache();
    mSim.draw();
  }

  function magParticle(rowEl) {
    if (!mSim) return;
    mSim.toggleParticle();
    rowEl.classList.toggle('active', mSim.particleOn);
    _magUpdateUI(mSim.info());
  }

  function magCondToggle(rowEl) {
    if (!mSim) return;
    mSim.toggleConductor();
    const on = mSim._cond.on;
    rowEl.classList.toggle('active', on);
    document.getElementById('cond-I-block').style.display = on ? '' : 'none';
    _magUpdateUI(mSim.info());
  }

  function magCondCurrentChange() {
    if (!mSim) return;
    const I = parseFloat(document.getElementById('sl-condI').value);
    document.getElementById('m-condI').textContent = I + ' А';
    mSim.setConductorI(I);
  }

  function magFluxToggle(rowEl) {
    if (!mSim) return;
    mSim.toggleFlux();
    rowEl.classList.toggle('active', mSim._flux.on);
    _magUpdateUI(mSim.info());
  }

  function _magUpdateUI(info) {
    document.getElementById('ms-out').textContent    = info.out;
    document.getElementById('ms-in').textContent     = info.inn;
    document.getElementById('mbar-total').textContent = info.total;
    document.getElementById('mbar-out').textContent  = info.out;
    document.getElementById('mbar-in').textContent   = info.inn;
    document.getElementById('mbar-particle').textContent = info.particleOn ? 'вкл' : 'выкл';
    document.getElementById('mbar-particle').style.color = info.particleOn ? '#ffff50' : '';
    // Ampere force
    const fEl = document.getElementById('mbar-ampere');
    if (info.condOn && info.Fz !== 0) {
      const dir = info.Fz > 0 ? '⊙' : '⊗';
      fEl.textContent = dir + ' ' + Math.abs(info.Fz).toFixed(3);
      fEl.style.color = '#fbbf24';
    } else {
      fEl.textContent = '—';
      fEl.style.color = '#fbbf24';
    }
    // Flux
    const phEl = document.getElementById('mbar-flux');
    if (info.fluxOn) {
      phEl.textContent = info.flux.toExponential(2) + ' Вб';
      phEl.style.color = '#34d399';
    } else {
      phEl.textContent = '—';
      phEl.style.color = '#34d399';
    }
  }

  /* ── triangle ── */

