'use strict';
/* ══════════════════════════════════════════════════════════
   EMFieldSim — unified electromagnetic field simulation
   Modes:
     'E'        — electric only (charge sources)
     'B'        — magnetic only (wire sources)
     'combined' — both fields, full Lorentz force

   Source kinds:
     charge   → {kind:'charge',  x, y, q}       — point charge
     wireOut  → {kind:'wireOut', x, y, I}        — wire current toward viewer (•)
     wireIn   → {kind:'wireIn',  x, y, I}        — wire current away   (×)
     conductor→ special overlay, not in sources[]

   Physics (visual units, no SI conversion):
     E: Ex = K_E·q·dx/r³,  Ey = K_E·q·dy/r³
     B: Bx = -K_B·I·dy/r², By = K_B·I·dx/r²
     Lorentz (2-D projection):
       F_E = q_test · E
       F_B: treat |B_xy| as Bz → Fx=q·vy·Bz, Fy=-q·vx·Bz
══════════════════════════════════════════════════════════ */

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

    this.mode = 'E';          // 'E' | 'B' | 'combined'

    /* source list — mixed kinds */
    this.sources  = [];
    this._nextId  = 1;

    /* add-mode per kind */
    this.addSign = +1;        // charge sign (+1 / -1)
    this.addDir  = 'out';     // wire direction 'out' | 'in'
    this.curI    = 6;         // wire current magnitude

    /* layers — per-field toggles */
    this.layers = {
      E_colormap:       true,
      E_fieldlines:     true,
      E_vectors:        false,
      E_equipotentials: true,
      E_forces:         false,
      B_colormap:       true,
      B_fieldlines:     true,
      B_vectors:        false,
    };

    /* conductor overlay (magnetic feature) */
    this._cond = {
      on: false,
      x1: 0, y1: 0, x2: 0, y2: 0,
      I: 8,
      _dragEndpoint: null,
    };

    /* flux indicator (magnetic feature) */
    this._flux = {
      on: false,
      x: 0, y: 0,
      r: 55,
      _dragging: false,
    };

    /* Gauss surface (electric flux, E / combined modes) */
    this._gauss = {
      on: false,
      x: 0, y: 0,
      r: 70,
      _dragging: false,
    };

    /* motional EMF rod (B / combined modes) */
    this._rod = {
      on: false,
      x1: 0, y1: 0, x2: 0, y2: 0,
      vx: 0, vy: 0,        // current velocity px/s
      _dragging: false,
      _dragOffX: 0, _dragOffY: 0,
      _keys: {},           // keys held
      _raf: null,
      _last: 0,
    };

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

    /* cursor readout */
    this._cursorE  = null;   // {ex, ey, mag, v}
    this._cursorB  = null;   // {bx, by, mag}
    this._mousePos = null;

    /* interaction */
    this._drag    = null;
    this._hovered = null;
    this._downPos = null;

    /* offscreen canvas for B colormap */
    this._ocB  = null;
    this._ocBW = 0;
    this._ocBH = 0;

    /* colormap dirty flags */
    this._cmBDirty = true;
    this._cmECache = null;
    this._cmEDirty = true;

    /* visual constants */
    this.K_E = 60000;   // Coulomb visual constant
    this.K_B = 8000;    // Biot-Savart visual constant

    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;
    this._ocBW = Math.ceil(this.W / DS);
    this._ocBH = Math.ceil(this.H / DS);
    this._ocB  = document.createElement('canvas');
    this._ocB.width  = this._ocBW;
    this._ocB.height = this._ocBH;

    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._gauss.x = this.W * 0.5;  this._gauss.y = this.H * 0.5;
      this._rod.x1  = this.W * 0.5;  this._rod.y1  = this.H * 0.3;
      this._rod.x2  = this.W * 0.5;  this._rod.y2  = this.H * 0.7;
    }

    this._cmBDirty = true;
    this._cmEDirty = true;
    this._cmECache = null;
    this.draw();
  }

  /* ──────────────────────────────
     Mode switching
  ────────────────────────────── */

  setMode(mode) {
    this.mode = mode;
    /* remove incompatible sources */
    if (mode === 'E') {
      this.sources = this.sources.filter(s => s.kind === 'charge');
    } else if (mode === 'B') {
      this.sources = this.sources.filter(s => s.kind !== 'charge');
    }
    this._invalidateAll();
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

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

  addCharge(x, y, q) {
    if (this.mode === 'B') return;
    this.sources.push({ kind: 'charge', id: this._nextId++, x, y, q });
    this._invalidateAll();
    this.draw();
    if (window.LabFX) {
      LabFX.sound.play('spark', { pitch: 1.1 });
      const col = q > 0 ? '#EF476F' : '#4CC9F0';
      LabFX.particles.emit({ ctx: this.ctx, x, y, count: 8, color: col, speed: 60, spread: Math.PI * 2, life: 400, shape: 'spark', size: 3, glow: true });
    }
    if (this.onUpdate) this.onUpdate(this.info());
  }

  addWire(x, y, dir) {
    if (this.mode === 'E') return;
    const kind = dir === 'out' ? 'wireOut' : 'wireIn';
    const I    = dir === 'out' ? +this.curI : -this.curI;
    this.sources.push({ kind, id: this._nextId++, x, y, I });
    this._invalidateAll();
    this.draw();
    if (window.LabFX) {
      LabFX.sound.play('spark', { pitch: 1.1 });
      const col = I > 0 ? '#06D6E0' : '#F15BB5';
      LabFX.particles.emit({ ctx: this.ctx, x, y, count: 8, color: col, speed: 60, spread: Math.PI * 2, life: 400, shape: 'spark', size: 3, glow: true });
    }
    if (this.onUpdate) this.onUpdate(this.info());
  }

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

  clearAll() {
    this.sources = [];
    this._particle = null;
    this.particleOn = false;
    if (this._pRaf) { cancelAnimationFrame(this._pRaf); this._pRaf = null; }
    this._cond.on = false;
    this._flux.on = false;
    this._gauss.on = false;
    if (this._rod._raf) { cancelAnimationFrame(this._rod._raf); this._rod._raf = null; }
    this._rod.on = false;
    this._invalidateAll();
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

  setCurrentAll(I) {
    this.curI = I;
    this.sources.forEach(s => {
      if (s.kind === 'wireOut') s.I =  I;
      if (s.kind === 'wireIn')  s.I = -I;
    });
    this._invalidateAll();
    this.draw();
  }

  _invalidateAll() {
    this._cmBDirty = true;
    this._cmEDirty = true;
    this._cmECache = null;
  }

  /* ──────────────────────────────
     Conductor & flux (B-mode)
  ────────────────────────────── */

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

  /* Gauss surface (E mode, electric flux) */
  toggleGauss() {
    this._gauss.on = !this._gauss.on;
    this.draw();
  }

  setGaussR(r) {
    this._gauss.r = r;
    this.draw();
  }

  /* Motional EMF rod (B mode) */
  toggleRod() {
    const rod = this._rod;
    rod.on = !rod.on;
    if (rod.on) {
      rod.vx = 0; rod.vy = 0;
      rod._last = performance.now();
      this._tickRod();
    } else {
      if (rod._raf) { cancelAnimationFrame(rod._raf); rod._raf = null; }
      this.draw();
    }
    if (this.onUpdate) this.onUpdate(this.info());
  }

  _tickRod() {
    const rod = this._rod;
    if (!rod.on) return;
    const now = performance.now();
    const dt  = Math.min((now - rod._last) * 0.001, 0.05); // seconds
    rod._last = now;

    /* keyboard-driven acceleration: arrow keys → velocity */
    const speed = 90; // px/s max
    let ax = 0, ay = 0;
    if (rod._keys['ArrowLeft'])  ax -= 1;
    if (rod._keys['ArrowRight']) ax += 1;
    if (rod._keys['ArrowUp'])    ay -= 1;
    if (rod._keys['ArrowDown'])  ay += 1;

    if (ax !== 0 || ay !== 0) {
      const len = Math.hypot(ax, ay);
      rod.vx = (ax / len) * speed;
      rod.vy = (ay / len) * speed;
    } else {
      /* friction */
      rod.vx *= 0.88;
      rod.vy *= 0.88;
      if (Math.hypot(rod.vx, rod.vy) < 0.5) { rod.vx = 0; rod.vy = 0; }
    }

    /* move rod */
    rod.x1 += rod.vx * dt;
    rod.y1 += rod.vy * dt;
    rod.x2 += rod.vx * dt;
    rod.y2 += rod.vy * dt;

    /* clamp to canvas */
    const margin = 10;
    const minX = Math.min(rod.x1, rod.x2), maxX = Math.max(rod.x1, rod.x2);
    const minY = Math.min(rod.y1, rod.y2), maxY = Math.max(rod.y1, rod.y2);
    if (minX < margin) { const d = margin - minX; rod.x1 += d; rod.x2 += d; }
    if (maxX > this.W - margin) { const d = maxX - (this.W - margin); rod.x1 -= d; rod.x2 -= d; }
    if (minY < margin) { const d = margin - minY; rod.y1 += d; rod.y2 += d; }
    if (maxY > this.H - margin) { const d = maxY - (this.H - margin); rod.y1 -= d; rod.y2 -= d; }

    if (window.LabFX) {
      LabFX.particles.update(dt);
      const v2 = Math.hypot(rod.vx, rod.vy);
      if (v2 > 30) {
        LabFX.particles.emit({ ctx: this.ctx, x: rod.x1, y: rod.y1, count: 1, color: '#f59e0b', speed: 20, spread: Math.PI * 2, life: 200, shape: 'spark', size: 2, glow: true });
        LabFX.particles.emit({ ctx: this.ctx, x: rod.x2, y: rod.y2, count: 1, color: '#f59e0b', speed: 20, spread: Math.PI * 2, life: 200, shape: 'spark', size: 2, glow: true });
      }
    }
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
    rod._raf = requestAnimationFrame(() => this._tickRod());
  }

  /* Compute motional EMF = integral of (v × B) · dl along rod */
  _rodEMF() {
    const rod = this._rod;
    const Lx = rod.x2 - rod.x1, Ly = rod.y2 - rod.y1;
    const L  = Math.hypot(Lx, Ly);
    if (L < 1) return { emf: 0, avgB: 0, v: 0 };
    /* dl unit vector */
    const dlx = Lx / L, dly = Ly / L;
    const v   = Math.hypot(rod.vx, rod.vy);
    const N   = 20; // integration samples
    let sum   = 0, avgB = 0;
    for (let k = 0; k <= N; k++) {
      const t  = k / N;
      const px = rod.x1 + Lx * t, py = rod.y1 + Ly * t;
      const { bx, by, mag } = this._bField(px, py);
      avgB += mag;
      /* (v × B) in 2D: vx*By - vy*Bx gives z-component of (v×B)
         (v×B)·dl = (vx·By - vy·Bx)·dlx - ... → in 2D project back:
         (v×B) is a vector: if v=(vx,vy,0), B=(Bx,By,0) →
         v×B = (vy·0-0·By, 0·Bx-vx·0, vx·By-vy·Bx) = (0,0,vx·By-vy·Bx)
         But B here is in-plane; we treat |B| as out-of-plane Bz for the 2D sim.
         So B = (0,0,Bz) where Bz = mag (or -mag depending on orientation sign).
         We use bx,by as in-plane → but physically they represent the field in the plane.
         For motional EMF in 2D: use Bz=mag (perpendicular to plane convention).
         (v×Bz_hat)·dl = (vy·Bz)·dlx + (-vx·Bz)·dly */
      const Beff = mag * 0.00012; // same scale used in particle simulation
      const vCrossB_x = rod.vy * Beff;
      const vCrossB_y = -rod.vx * Beff;
      sum += (vCrossB_x * dlx + vCrossB_y * dly);
    }
    avgB /= (N + 1);
    const emf = sum * L / (N + 1); // Riemann sum → integral
    return { emf, avgB, v };
  }

  /* ──────────────────────────────
     Particle
  ────────────────────────────── */

  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 rawDt = Math.min((now - this._pLast) * 0.001, 0.05); // seconds
    const dt  = Math.min((now - this._pLast) * 0.06, 2.5);
    this._pLast = now;
    if (!this._pFrame) this._pFrame = 0;
    this._pFrame++;

    const p  = this._particle;

    /* electric force: F = q·E (push particle) */
    if (this.mode !== 'B') {
      const { ex, ey } = this._eField(p.x, p.y);
      const EScale = 0.000008;
      p.vx += p.q * ex * EScale * dt;
      p.vy += p.q * ey * EScale * dt;
    }

    /* magnetic force: Lorentz (2D) using B magnitude as Bz */
    if (this.mode !== 'E') {
      const spd  = Math.hypot(p.vx, p.vy);
      const { mag } = this._bField(p.x, p.y);
      const Bz   = mag * 0.00012 * p.q;
      p.vx += p.q * p.vy * Bz * dt;
      p.vy -= p.q * p.vx * Bz * dt;
      /* conserve speed when only B acts */
      if (this.mode === 'B') {
        const newSpd = Math.hypot(p.vx, p.vy);
        if (newSpd > 1e-6) { p.vx = p.vx / newSpd * spd; p.vy = p.vy / newSpd * spd; }
      }
    }

    /* clamp speed to avoid runaway */
    const maxSpd = 6;
    const spd2 = Math.hypot(p.vx, p.vy);
    if (spd2 > maxSpd) { p.vx = p.vx / spd2 * maxSpd; p.vy = p.vy / spd2 * maxSpd; }

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

    if (window.LabFX && this._pFrame % 2 === 0) {
      const trailCol = p.q > 0 ? '#FFD166' : '#4CC9F0';
      LabFX.particles.emit({ ctx: this.ctx, x: p.x, y: p.y, count: 1, color: trailCol, life: 400, shape: 'dot', size: 2, glow: true });
    }

    if (window.LabFX) LabFX.particles.update(rawDt);
    this.draw();
    this._pRaf = requestAnimationFrame(() => this._tickParticle());
  }

  /* ──────────────────────────────
     Presets
  ────────────────────────────── */

  presetE(name) {
    this.sources = this.sources.filter(s => s.kind !== 'charge');
    const cx = this.W / 2, cy = this.H / 2, d = this.W * 0.2;
    if (name === 'dipole') {
      this._pushCharge(cx - d, cy,  1);
      this._pushCharge(cx + d, cy, -1);
    } else if (name === 'equal') {
      this._pushCharge(cx - d, cy, 1);
      this._pushCharge(cx + d, cy, 1);
    } else if (name === 'quadrupole') {
      this._pushCharge(cx - d, cy - d,  1);
      this._pushCharge(cx + d, cy - d, -1);
      this._pushCharge(cx + d, cy + d,  1);
      this._pushCharge(cx - d, cy + d, -1);
    } else if (name === 'ring') {
      for (let i = 0; i < 6; i++) {
        const a = i * Math.PI / 3;
        this._pushCharge(cx + d * Math.cos(a), cy + d * Math.sin(a), i % 2 === 0 ? 1 : -1);
      }
    }
    this._invalidateAll();
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

  presetB(name) {
    this.sources = this.sources.filter(s => s.kind === 'charge');
    const cx = this.W / 2, cy = this.H / 2, d = 90;
    switch (name) {
      case 'single':
        this._pushWire(cx, cy, 'out'); break;
      case 'parallel':
        this._pushWire(cx - d, cy, 'out');
        this._pushWire(cx + d, cy, 'out'); break;
      case 'anti':
        this._pushWire(cx - d, cy, 'out');
        this._pushWire(cx + d, cy, 'in'); break;
      case 'solenoid': {
        const cols = 5, gx = 60, gy = 70;
        for (let c = 0; c < cols; c++) {
          const x = cx + (c - (cols - 1) / 2) * gx;
          this._pushWire(x, cy - gy / 2, 'out');
          this._pushWire(x, cy + gy / 2, 'in');
        }
        break;
      }
      case 'quadrupole':
        this._pushWire(cx - d, cy,     'out');
        this._pushWire(cx + d, cy,     'out');
        this._pushWire(cx,     cy - d, 'in');
        this._pushWire(cx,     cy + d, 'in'); break;
      case 'ring': {
        const n = 8, r = 110;
        for (let i = 0; i < n; i++) {
          const a = (i / n) * Math.PI * 2;
          this._pushWire(cx + Math.cos(a) * r, cy + Math.sin(a) * r, i % 2 === 0 ? 'out' : 'in');
        }
        break;
      }
      case 'toroid': {
        /* toroid cross-section: inner ring (wire-out) + outer ring (wire-in)
           This approximates a toroid where B is confined inside the winding.
           16 wire-out at radius r1, 16 wire-in at radius r2 (concentric). */
        const n = 16, r1 = 75, r2 = 130;
        for (let i = 0; i < n; i++) {
          const a = (i / n) * Math.PI * 2;
          this._pushWire(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1, 'out');
          this._pushWire(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2, 'in');
        }
        break;
      }
    }
    this._invalidateAll();
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

  _pushCharge(x, y, q) {
    this.sources.push({ kind: 'charge', id: this._nextId++, x, y, q });
  }

  _pushWire(x, y, dir) {
    const kind = dir === 'out' ? 'wireOut' : 'wireIn';
    const I    = dir === 'out' ? +this.curI : -this.curI;
    this.sources.push({ kind, id: this._nextId++, x, y, I });
  }

  /* ──────────────────────────────
     Physics
  ────────────────────────────── */

  _eField(px, py) {
    let ex = 0, ey = 0, v = 0;
    for (const s of this.sources) {
      if (s.kind !== 'charge') continue;
      const dx = px - s.x, dy = py - s.y;
      const r2 = dx * dx + dy * dy;
      if (r2 < 1) continue;
      const r  = Math.sqrt(r2);
      const r3 = r2 * r;
      ex += this.K_E * s.q * dx / r3;
      ey += this.K_E * s.q * dy / r3;
      v  += this.K_E * s.q / r;
    }
    return { ex, ey, mag: Math.hypot(ex, ey), v };
  }

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

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

  _bRk4(x, y, step) {
    const f  = (xx, yy) => this._bFieldNorm(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,
    };
  }

  /* Ampere force on conductor */
  _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._bField(mx, my);
    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 */
  _fluxValue() {
    const f = this._flux;
    const { mag } = this._bField(f.x, f.y);
    return mag * Math.PI * f.r * f.r * 0.000001;
  }

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

  info() {
    const charges = this.sources.filter(s => s.kind === 'charge');
    const wires   = this.sources.filter(s => s.kind !== 'charge');
    const pos     = charges.filter(c => c.q > 0).length;
    const neg     = charges.filter(c => c.q < 0).length;
    const out     = wires.filter(w => w.I > 0).length;
    const inn     = wires.filter(w => w.I < 0).length;

    const condOn  = this._cond.on;
    const fluxOn  = this._flux.on;
    const gaussOn = this._gauss.on;
    const rodOn   = this._rod.on;
    const ampere  = condOn ? this._ampereForce() : null;
    const Fz      = ampere ? ampere.Fz : 0;
    const flux    = fluxOn ? this._fluxValue() : 0;

    /* Gauss surface: exact (sum q_enc) + numerical */
    let gaussExact = 0, gaussNumerical = 0;
    if (gaussOn && this.mode !== 'B') {
      const g = this._gauss;
      const eps0inv = 1 / (4 * Math.PI * this.K_E); // 1/ε₀ in visual units
      for (const s of this.sources) {
        if (s.kind !== 'charge') continue;
        if (Math.hypot(s.x - g.x, s.y - g.y) < g.r) gaussExact += s.q;
      }
      gaussExact *= eps0inv; // Gauss: Φ = q_enc / ε₀
      /* numerical line integral ∮ E·n ds */
      const N = 64;
      for (let k = 0; k < N; k++) {
        const a = (k / N) * Math.PI * 2;
        const px = g.x + g.r * Math.cos(a), py = g.y + g.r * Math.sin(a);
        const { ex, ey } = this._eField(px, py);
        const nx = Math.cos(a), ny = Math.sin(a); // outward normal
        gaussNumerical += (ex * nx + ey * ny) * g.r * (2 * Math.PI / N);
      }
    }

    /* Rod EMF */
    let rodEMF = 0, rodV = 0, rodAvgB = 0;
    if (rodOn) {
      const r = this._rodEMF();
      rodEMF = r.emf; rodV = r.v; rodAvgB = r.avgB;
    }

    return {
      total: this.sources.length,
      charges: charges.length, pos, neg,
      wires: wires.length, out, inn,
      particleOn: this.particleOn,
      condOn, fluxOn, gaussOn, rodOn, Fz, flux,
      gaussExact, gaussNumerical,
      rodEMF, rodV, rodAvgB,
      cursorE: this._cursorE ? this._cursorE.mag.toFixed(0) : '—',
      cursorV: this._cursorE ? this._cursorE.v.toFixed(0)   : '—',
      cursorB: this._cursorB ? this._cursorB.mag.toFixed(0) : '—',
    };
  }

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

    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;
      const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
      if (Math.hypot(p.x - mx, p.y - my) < 14) return 'body';
      return null;
    };

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

    const hitGauss = p => {
      if (!this._gauss.on) return false;
      return Math.hypot(p.x - this._gauss.x, p.y - this._gauss.y) < this._gauss.r + 12;
    };

    const hitRod = p => {
      if (!this._rod.on) return false;
      const { x1, y1, x2, y2 } = this._rod;
      const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
      return Math.hypot(p.x - mx, p.y - my) < Math.hypot(x2 - x1, y2 - y1) / 2 + 14;
    };

    let _condDragOffset = null;

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

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

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

      if (hitGauss(p)) {
        this._gauss._dragging = true;
        c.style.cursor = 'grabbing'; return;
      }

      if (hitRod(p)) {
        const rod = this._rod;
        rod._dragging = true;
        rod._dragOffX = p.x - (rod.x1 + rod.x2) / 2;
        rod._dragOffY = p.y - (rod.y1 + rod.y2) / 2;
        c.style.cursor = 'grabbing'; return;
      }

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

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

      /* cursor field readout */
      if (!e.buttons) {
        if (this.mode !== 'B' && this.sources.some(s => s.kind === 'charge')) {
          this._cursorE = this._eField(p.x, p.y);
        } else {
          this._cursorE = null;
        }
        if (this.mode !== 'E' && this.sources.some(s => s.kind !== 'charge')) {
          this._cursorB = this._bField(p.x, p.y);
        } else {
          this._cursorB = null;
        }
        if (this.onUpdate) this.onUpdate(this.info());
      }

      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 nh  = Math.hypot(dx, dy);
          const nx  = dx / nh, ny = dy / nh;
          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;
      }

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

      if (this._gauss._dragging) {
        this._gauss.x = p.x; this._gauss.y = p.y;
        const now2 = performance.now();
        if (!this._gaussHapticT || now2 - this._gaussHapticT > 100) {
          this._gaussHapticT = now2;
          if (window.LabFX) LabFX.haptic(5);
        }
        this.draw(); return;
      }

      if (this._rod._dragging) {
        const rod = this._rod;
        const cx  = p.x - rod._dragOffX, cy = p.y - rod._dragOffY;
        const hLx = (rod.x2 - rod.x1) / 2, hLy = (rod.y2 - rod.y1) / 2;
        rod.x1 = cx - hLx; rod.y1 = cy - hLy;
        rod.x2 = cx + hLx; rod.y2 = cy + hLy;
        this.draw(); return;
      }

      if (this._drag !== null) {
        this.sources[this._drag].x = p.x;
        this.sources[this._drag].y = p.y;
        this._invalidateAll();
        this.draw(); return;
      }

      const i  = hitSource(p);
      const ch = hitCond(p);
      const fh = hitFlux(p) || hitGauss(p) || hitRod(p);
      this._hovered = i >= 0 ? i : null;
      c.style.cursor = (i >= 0 || ch !== null || fh) ? 'grab' : 'crosshair';
      this.draw();
    });

    c.addEventListener('mouseup', e => {
      const p     = pos(e);
      const moved = this._downPos &&
        Math.hypot(p.x - this._downPos.x, p.y - this._downPos.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._gauss._dragging) {
        this._gauss._dragging = false; c.style.cursor = 'crosshair'; return;
      }
      if (this._rod._dragging) {
        this._rod._dragging = false; c.style.cursor = 'crosshair'; return;
      }
      if (this._drag !== null) {
        this._invalidateAll();
        this._drag = null; c.style.cursor = 'crosshair';
        this.draw(); if (this.onUpdate) this.onUpdate(this.info()); return;
      }

      /* click on empty canvas — add source based on mode */
      if (!moved && e.button === 0 &&
          hitSource(p) < 0 && hitCond(p) === null && !hitFlux(p) && !hitGauss(p) && !hitRod(p)) {
        if (this.mode === 'E') {
          this.addCharge(p.x, p.y, this.addSign);
        } else if (this.mode === 'B') {
          this.addWire(p.x, p.y, this.addDir);
        } else {
          /* combined: user picks via active add-type button */
          if (this._addType === 'charge') this.addCharge(p.x, p.y, this.addSign);
          else                            this.addWire(p.x, p.y, this.addDir);
        }
      }
    });

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

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

    c.addEventListener('mouseleave', () => {
      this._cursorE  = null;
      this._cursorB  = null;
      this._mousePos = null;
      this._hovered  = null;
      this.draw();
    });

    c.addEventListener('touchstart', e => {
      e.preventDefault();
      this._downPos = pos(e);
      const i = hitSource(this._downPos);
      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._invalidateAll();
      this.draw();
    }, { passive: false });

    c.addEventListener('touchend', e => {
      const p    = e.changedTouches ? pos({ ...e, touches: e.changedTouches }) : null;
      const moved = this._downPos && p &&
        Math.hypot(p.x - this._downPos.x, p.y - this._downPos.y) > 8;
      if (this._drag === null && !moved && p) {
        if (this.mode === 'E') this.addCharge(p.x, p.y, this.addSign);
        else if (this.mode === 'B') this.addWire(p.x, p.y, this.addDir);
        else if (this._addType === 'charge') this.addCharge(p.x, p.y, this.addSign);
        else                                 this.addWire(p.x, p.y, this.addDir);
      }
      this._drag = null;
      if (this.onUpdate) this.onUpdate(this.info());
    }, { passive: false });

    /* arrow-key control for rod */
    document.addEventListener('keydown', e => {
      if (!this._rod.on) return;
      if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)) {
        e.preventDefault();
        this._rod._keys[e.key] = true;
      }
    });
    document.addEventListener('keyup', e => {
      delete this._rod._keys[e.key];
    });
  }

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

    const hasE = this.sources.some(s => s.kind === 'charge');
    const hasB = this.sources.some(s => s.kind !== 'charge');

    /* E layers */
    if (hasE && this.mode !== 'B') {
      if (this.layers.E_colormap)       this._drawColormapE(ctx);
      if (this.layers.E_equipotentials) this._drawEquipotentials(ctx);
      if (this.layers.E_vectors)        this._drawVectorsE(ctx);
      if (this.layers.E_fieldlines)     this._drawFieldLinesE(ctx);
      if (this.layers.E_forces)         this._drawForceArrows(ctx);
    }

    /* B layers */
    if (hasB && this.mode !== 'E') {
      if (this.layers.B_colormap)       this._drawColormapB(ctx);
      if (this.layers.B_fieldlines)     this._drawFieldLinesB(ctx);
      if (this.layers.B_vectors)        this._drawVectorsB(ctx);
    }

    /* overlays */
    if (this._flux.on && this.mode !== 'E') this._drawFlux(ctx);
    if (this._cond.on && this.mode !== 'E') this._drawConductor(ctx);
    if (this._gauss.on && this.mode !== 'B') this._drawGauss(ctx);
    if (this._rod.on  && this.mode !== 'E') this._drawRod(ctx);
    if (this._particle) this._drawParticle(ctx);

    /* sources */
    this._drawSources(ctx);

    /* high-field lightning FX */
    if (window.LabFX && this.sources.length >= 2) {
      const now3 = performance.now();
      if (!this._lightningT) this._lightningT = 0;
      if (now3 - this._lightningT > 500) {
        // sample max field at center
        const cx = this.W / 2, cy = this.H / 2;
        const em = this._eField(cx, cy), bm = this._bField(cx, cy);
        const maxField = Math.max(em.mag, bm.mag);
        if (maxField > 30000) {
          this._lightningT = now3;
          const i1 = Math.floor(Math.random() * this.sources.length);
          let i2 = Math.floor(Math.random() * this.sources.length);
          if (i2 === i1) i2 = (i1 + 1) % this.sources.length;
          const s1 = this.sources[i1], s2 = this.sources[i2];
          const lx = (s1.x + s2.x) / 2, ly = (s1.y + s2.y) / 2;
          LabFX.particles.emit({ ctx: this.ctx, x: lx, y: ly, count: 5, color: '#FFFFFF', speed: 30, spread: Math.PI * 2, life: 80, shape: 'spark', glow: true });
          LabFX.sound.play('spark', { volume: 0.2 });
        }
      }
    }

    /* cursor readout */
    if (this._mousePos) {
      if (this._cursorE && this.mode !== 'B' && hasE) this._drawCursorE(ctx);
      if (this._cursorB && this.mode !== 'E' && hasB) this._drawCursorB(ctx);
    }

    if (this.sources.length === 0) this._drawHint(ctx);

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

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

  /* ── E colormap (hue = potential sign, brightness = |E|) ── */
  _drawColormapE(ctx) {
    const W = this.W, H = this.H;
    const STEP = 3;

    if (this._cmEDirty || !this._cmECache) {
      const imgW = Math.ceil(W / STEP);
      const imgH = Math.ceil(H / STEP);
      const img  = ctx.createImageData(imgW, imgH);
      const d    = img.data;

      for (let py = 0; py < imgH; py++) {
        for (let px = 0; px < imgW; px++) {
          const x = px * STEP + STEP / 2;
          const y = py * STEP + STEP / 2;
          const { mag, v } = this._eField(x, y);

          let hue;
          if (v > 0)      hue = 0   + (v  / (v  + 30000)) * 30;
          else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20;
          else            hue = 0;

          const sat = 80;
          const lit = Math.tanh(mag / 3000) * 40
                    + Math.tanh(Math.abs(v) / 50000) * 25;

          const [r, g, b] = this._hslToRgb(hue, sat, lit);
          const idx = (py * imgW + px) * 4;
          d[idx]     = r;
          d[idx + 1] = g;
          d[idx + 2] = b;
          d[idx + 3] = 200;
        }
      }

      this._cmECache = { img, imgW, imgH, STEP };
      this._cmEDirty = false;
    }

    const { img, imgW, imgH } = this._cmECache;
    const oc  = document.createElement('canvas');
    oc.width  = imgW;
    oc.height = imgH;
    oc.getContext('2d').putImageData(img, 0, 0);
    ctx.save();
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'medium';
    ctx.drawImage(oc, 0, 0, W, H);
    ctx.restore();
  }

  /* ── B colormap (hue = angle of B, brightness = log|B|) ── */
  _drawColormapB(ctx) {
    if (!this._ocB) return;
    const DS = 4;
    const oc  = this._ocB;
    const oct = oc.getContext('2d');
    const w   = this._ocBW, h = this._ocBH;

    if (this._cmBDirty) {
      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 { bx, by, mag } = this._bField(px * DS, py * DS);
          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._cmBDirty = false;
    }

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

  /* ── E equipotentials ── */
  _drawEquipotentials(ctx) {
    const W = this.W, H = this.H;
    const GRID   = 8;
    const LEVELS = [500, 2000, 8000, 30000, 100000, -500, -2000, -8000, -30000, -100000];
    const cols   = Math.ceil(W / GRID) + 1;
    const rows   = Math.ceil(H / GRID) + 1;
    const vGrid  = new Float64Array(cols * rows);
    for (let r = 0; r < rows; r++)
      for (let col = 0; col < cols; col++)
        vGrid[r * cols + col] = this._eField(col * GRID, r * GRID).v;

    ctx.save();
    ctx.strokeStyle = 'rgba(255,255,255,0.22)';
    ctx.lineWidth   = 0.8;
    ctx.setLineDash([4, 4]);
    ctx.beginPath();

    const interp = (va, vb, xa, ya, xb, yb, level) => {
      const t = (level - va) / (vb - va);
      return [xa + t * (xb - xa), ya + t * (yb - ya)];
    };

    for (const level of LEVELS) {
      for (let r = 0; r < rows - 1; r++) {
        for (let col = 0; col < cols - 1; col++) {
          const v00 = vGrid[ r      * cols + col    ];
          const v10 = vGrid[ r      * cols + col + 1];
          const v01 = vGrid[(r + 1) * cols + col    ];
          const v11 = vGrid[(r + 1) * cols + col + 1];
          const pts = [];
          if ((v00-level)*(v10-level) < 0) pts.push(interp(v00,v10, col*GRID,r*GRID, (col+1)*GRID,r*GRID, level));
          if ((v10-level)*(v11-level) < 0) pts.push(interp(v10,v11, (col+1)*GRID,r*GRID, (col+1)*GRID,(r+1)*GRID, level));
          if ((v01-level)*(v11-level) < 0) pts.push(interp(v01,v11, col*GRID,(r+1)*GRID, (col+1)*GRID,(r+1)*GRID, level));
          if ((v00-level)*(v01-level) < 0) pts.push(interp(v00,v01, col*GRID,r*GRID, col*GRID,(r+1)*GRID, level));
          if (pts.length >= 2) { ctx.moveTo(pts[0][0], pts[0][1]); ctx.lineTo(pts[1][0], pts[1][1]); }
        }
      }
    }
    ctx.stroke();
    ctx.restore();
  }

  /* ── E vectors ── */
  _drawVectorsE(ctx) {
    const GRID = 45;
    const _pulse = (window.LabFX) ? (0.7 + 0.3 * LabFX.glow.pulse(performance.now(), 2000)) : 1;
    ctx.save();
    ctx.globalAlpha = _pulse;
    ctx.strokeStyle = 'rgba(255,255,255,0.5)';
    ctx.fillStyle   = 'rgba(255,255,255,0.5)';
    ctx.lineWidth   = 1;
    for (let x = GRID / 2; x < this.W; x += GRID) {
      for (let y = GRID / 2; y < this.H; y += GRID) {
        const { ex, ey, mag } = this._eField(x, y);
        if (mag < 1e-6) continue;
        const len = Math.tanh(mag / 8000) * 18;
        const nx  = ex / mag, ny = ey / mag;
        const x2  = x + nx * len, y2 = y + ny * len;
        ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x2, y2); ctx.stroke();
        const ax = -ny * 3, ay = nx * 3;
        ctx.beginPath(); ctx.moveTo(x2, y2);
        ctx.lineTo(x2 - nx*6+ax, y2 - ny*6+ay);
        ctx.lineTo(x2 - nx*6-ax, y2 - ny*6-ay);
        ctx.closePath(); ctx.fill();
      }
    }
    ctx.restore();
  }

  /* ── E field lines ── */
  _drawFieldLinesE(ctx) {
    const W = this.W, H = this.H, sim = this;
    const RAYS = 12, STEP = 2.5, MAX = 2500, MARGIN = 5, HIT_R = 12, START_R = 18;

    function rkStep(x, y, h) {
      const f = (px, py) => {
        const e = sim._eField(px, py);
        const m = Math.hypot(e.ex, e.ey) || 1e-10;
        return [e.ex / m, e.ey / m];
      };
      const [k1x, k1y] = f(x, y);
      const [k2x, k2y] = f(x + h*k1x/2, y + h*k1y/2);
      const [k3x, k3y] = f(x + h*k2x/2, y + h*k2y/2);
      const [k4x, k4y] = f(x + h*k3x,   y + h*k3y);
      return [x + h*(k1x+2*k2x+2*k3x+k4x)/6, y + h*(k1y+2*k2y+2*k3y+k4y)/6];
    }

    const traceLine = (sx, sy, dir) => {
      const pts = [[sx, sy]]; let px = sx, py = sy;
      for (let s = 0; s < MAX; s++) {
        const [nx, ny] = rkStep(px, py, dir * STEP);
        if (nx < -MARGIN || nx > W+MARGIN || ny < -MARGIN || ny > H+MARGIN) break;
        let hitNeg = false;
        for (const c of sim.sources) {
          if (c.kind === 'charge' && c.q < 0 && Math.hypot(nx-c.x, ny-c.y) < HIT_R) { hitNeg = true; break; }
        }
        if (hitNeg) break;
        pts.push([nx, ny]); px = nx; py = ny;
      }
      return pts;
    };

    ctx.save();
    ctx.lineWidth = 1.2;
    for (const charge of this.sources) {
      if (charge.kind !== 'charge') continue;
      const dir = charge.q > 0 ? 1 : -1;
      for (let i = 0; i < RAYS; i++) {
        const angle = (i / RAYS) * Math.PI * 2;
        const sx = charge.x + START_R * Math.cos(angle);
        const sy = charge.y + START_R * Math.sin(angle);
        const pts = traceLine(sx, sy, dir);
        if (pts.length < 2) continue;
        const grad = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[pts.length-1][0], pts[pts.length-1][1]);
        grad.addColorStop(0,   'rgba(255,255,255,0.75)');
        grad.addColorStop(0.5, 'rgba(255,255,255,0.35)');
        grad.addColorStop(1,   'rgba(255,255,255,0.0)');
        const drawStroke = () => {
          ctx.strokeStyle = grad;
          ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
          for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
          ctx.stroke();
        };
        if (window.LabFX) {
          LabFX.glow.drawGlow(ctx, drawStroke, { color: '#06D6E0', intensity: 6 });
        } else {
          drawStroke();
        }
      }
    }
    ctx.restore();
  }

  /* ── Coulomb force arrows ── */
  _drawForceArrows(ctx) {
    ctx.save();
    for (let i = 0; i < this.sources.length; i++) {
      const ci = this.sources[i];
      if (ci.kind !== 'charge') continue;
      let fx = 0, fy = 0;
      for (let j = 0; j < this.sources.length; j++) {
        if (i === j) continue;
        const cj = this.sources[j];
        if (cj.kind !== 'charge') continue;
        const dx = ci.x - cj.x, dy = ci.y - cj.y;
        const r2 = dx*dx + dy*dy;
        if (r2 < 1) continue;
        const r3 = r2 * Math.sqrt(r2);
        const F  = this.K_E * ci.q * cj.q;
        fx += F * dx / r3; fy += F * dy / r3;
      }
      const mag = Math.hypot(fx, fy);
      if (mag < 1e-6) continue;
      const len = Math.tanh(mag / 50000) * 55;
      const nx  = fx / mag, ny = fy / mag;
      const x2  = ci.x + nx * len, y2 = ci.y + ny * len;
      ctx.strokeStyle = '#FFD166'; ctx.fillStyle = '#FFD166';
      ctx.lineWidth = 2; ctx.shadowBlur = 10; ctx.shadowColor = '#FFD166';
      ctx.beginPath(); ctx.moveTo(ci.x, ci.y); ctx.lineTo(x2, y2); ctx.stroke();
      const ax = -ny*5, ay = nx*5;
      ctx.beginPath(); ctx.moveTo(x2, y2);
      ctx.lineTo(x2-nx*10+ax, y2-ny*10+ay); ctx.lineTo(x2-nx*10-ax, y2-ny*10-ay);
      ctx.closePath(); ctx.fill(); ctx.shadowBlur = 0;
    }
    ctx.restore();
  }

  /* ── B field lines ── */
  _drawFieldLinesB(ctx) {
    const maxSteps = 700, step = 5, killR = 24;
    ctx.save();
    for (const src of this.sources) {
      if (src.kind === 'charge') continue;
      const isOut = src.I > 0;
      const col   = isOut ? '6,214,224' : '241,91,181';
      const nLines = 14, 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 }];
        for (let st = 0; st < maxSteps; st++) {
          const { nx, ny } = this._bRk4(x, y, step);
          x += step * nx; y += step * ny;
          if (x < -60 || x > this.W+60 || y < -60 || y > this.H+60) break;
          let nearOther = false;
          for (const s2 of this.sources) {
            if (s2 === src || s2.kind === 'charge') continue;
            if (Math.hypot(x-s2.x, y-s2.y) < killR) { nearOther = true; break; }
          }
          if (nearOther) { pts.push({ x, y }); break; }
          if (st > 20 && Math.hypot(x-src.x, y-src.y) < killR) break;
          pts.push({ x, y });
        }
        if (pts.length < 3) continue;
        const drawBLine = () => {
          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();
        };
        if (window.LabFX) {
          const bGlowCol = src.I > 0 ? '#06D6E0' : '#9B5DE5';
          LabFX.glow.drawGlow(ctx, drawBLine, { color: bGlowCol, intensity: 6 });
        } else {
          drawBLine();
        }
        this._drawBArrows(ctx, pts, col);
      }
    }
    ctx.restore();
  }

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

  /* ── B vector field ── */
  _drawVectorsB(ctx) {
    const step = 42;
    const _pulse = (window.LabFX) ? (0.7 + 0.3 * LabFX.glow.pulse(performance.now(), 2000)) : 1;
    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._bField(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) * _pulse;
        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();
  }

  /* ── draw all sources ── */
  _drawSources(ctx) {
    this.sources.forEach((s, i) => {
      if (s.kind === 'charge') this._drawCharge(ctx, s, i);
      else                     this._drawWire(ctx, s, i);
    });
  }

  _drawCharge(ctx, s, i) {
    const r   = 14 + Math.tanh(Math.abs(s.q) / 5) * 4;
    const pos = s.q > 0;
    ctx.save();
    ctx.shadowBlur  = 18;
    ctx.shadowColor = pos ? '#EF476F' : '#4CC9F0';
    if (this._hovered === i) {
      ctx.beginPath(); ctx.arc(s.x, s.y, r+6, 0, Math.PI*2);
      ctx.strokeStyle = pos ? 'rgba(239,71,111,0.45)' : 'rgba(76,201,240,0.45)';
      ctx.lineWidth = 2; ctx.stroke();
    }
    const grd = ctx.createRadialGradient(s.x-r*0.3, s.y-r*0.3, r*0.1, s.x, s.y, r);
    if (pos) { grd.addColorStop(0,'#FF7FA3'); grd.addColorStop(1,'#EF476F'); }
    else     { grd.addColorStop(0,'#90E0FF'); grd.addColorStop(1,'#4CC9F0'); }
    ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI*2);
    ctx.fillStyle = grd; ctx.fill();
    ctx.shadowBlur = 0;
    ctx.fillStyle = '#fff'; ctx.font = 'bold 14px sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText(pos ? '+' : '−', s.x, s.y + 1);
    ctx.restore();
  }

  _drawWire(ctx, 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;
    ctx.beginPath(); ctx.arc(s.x, s.y, R+6, 0, Math.PI*2);
    ctx.fillStyle = `rgba(${rgb},0.08)`; ctx.fill();
    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();
    if (isOut) {
      ctx.beginPath(); ctx.arc(s.x, s.y, 5, 0, Math.PI*2);
      ctx.fillStyle = col; ctx.shadowBlur = 8; ctx.fill();
    } else {
      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();
    }
    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();
  }

  /* ── 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;
    ctx.save();
    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();
    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();
    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();
    }
    [[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();
    });
    if (B > 0.5) {
      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));
    }
    if (Fabs > 1e-6) {
      const sym    = fOut ? '⊙' : '⊗';
      const symCol = fOut ? '#06D6E0' : '#ff6060';
      const perpX  = -Ly/L, perpY = Lx/L;
      ctx.font = `${Math.min(22,8+Fabs*200)}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);
      }
      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);
    }
    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._bField(f.x, f.y);
    const brightness = Math.min(1, Math.log10(1+mag*0.003)*0.7);
    ctx.save();
    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();
    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([]);
    ctx.beginPath(); ctx.arc(f.x,f.y,4,0,Math.PI*2); ctx.fillStyle='#ffdc32'; ctx.fill();
    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();
  }

  /* ── Gauss surface (electric flux) ── */
  _drawGauss(ctx) {
    const g = this._gauss;
    /* compute enclosed charge and numerical flux */
    const eps0inv = 1 / (4 * Math.PI * this.K_E);
    let qEnc = 0;
    for (const s of this.sources) {
      if (s.kind !== 'charge') continue;
      if (Math.hypot(s.x - g.x, s.y - g.y) < g.r) qEnc += s.q;
    }
    const phiExact = qEnc * eps0inv;

    /* draw enclosed charge halo */
    ctx.save();
    for (const s of this.sources) {
      if (s.kind !== 'charge') continue;
      if (Math.hypot(s.x - g.x, s.y - g.y) < g.r) {
        ctx.beginPath(); ctx.arc(s.x, s.y, 26, 0, Math.PI * 2);
        ctx.strokeStyle = 'rgba(52,211,153,0.55)'; ctx.lineWidth = 3;
        ctx.shadowColor = '#34d399'; ctx.shadowBlur = 12; ctx.stroke();
      }
    }
    ctx.restore();

    /* background fill */
    ctx.save();
    const grad = ctx.createRadialGradient(g.x, g.y, 0, g.x, g.y, g.r);
    const a = Math.min(0.35, Math.abs(phiExact) * 0.008 + 0.05);
    grad.addColorStop(0, `rgba(52,211,153,${a})`);
    grad.addColorStop(1, 'transparent');
    ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(g.x, g.y, g.r, 0, Math.PI * 2); ctx.fill();

    /* dashed circle with flowing dash-offset to suggest surface motion */
    ctx.setLineDash([10, 6]);
    ctx.strokeStyle = 'rgba(52,211,153,0.85)'; ctx.lineWidth = 2;
    ctx.shadowColor = '#34d399'; ctx.shadowBlur = 10;
    ctx.beginPath(); ctx.arc(g.x, g.y, g.r, 0, Math.PI * 2); ctx.stroke();
    ctx.setLineDash([]);
    ctx.shadowBlur = 0;

    /* normal arrows on circle */
    const nArr = 12;
    ctx.strokeStyle = 'rgba(52,211,153,0.5)'; ctx.fillStyle = 'rgba(52,211,153,0.7)'; ctx.lineWidth = 1.2;
    for (let k = 0; k < nArr; k++) {
      const a2 = (k / nArr) * Math.PI * 2;
      const ex = Math.cos(a2), ey = Math.sin(a2);
      const rx = g.x + g.r * ex, ry = g.y + g.r * ey;
      const len = phiExact !== 0 ? (phiExact > 0 ? 14 : -14) : 10;
      const x2 = rx + ex * len, y2 = ry + ey * len;
      ctx.beginPath(); ctx.moveTo(rx, ry); ctx.lineTo(x2, y2); ctx.stroke();
      const ang = Math.atan2(ey, ex);
      ctx.save(); ctx.translate(x2, y2); ctx.rotate(ang);
      ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-5, -3); ctx.lineTo(-5, 3);
      ctx.closePath(); ctx.fill(); ctx.restore();
    }

    /* label */
    ctx.font = 'bold 11px Manrope, sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    ctx.fillStyle = '#34d399'; ctx.shadowColor = '#34d399'; ctx.shadowBlur = 6;
    const signStr = phiExact >= 0 ? '+' : '';
    ctx.fillText('Φₑ = ' + signStr + phiExact.toFixed(3) + ' (точн.)', g.x, g.y + g.r + 6);
    ctx.font = '10px Manrope, sans-serif'; ctx.fillStyle = 'rgba(52,211,153,0.7)'; ctx.shadowBlur = 3;
    ctx.fillText('qₑₙₙ = ' + qEnc.toFixed(1) + '  |  перетащи', g.x, g.y + g.r + 20);
    ctx.restore();
  }

  /* ── motional EMF rod ── */
  _drawRod(ctx) {
    const rod = this._rod;
    const Lx = rod.x2 - rod.x1, Ly = rod.y2 - rod.y1;
    const L  = Math.hypot(Lx, Ly);
    if (L < 2) return;
    const { emf, avgB, v } = this._rodEMF();
    const mx = (rod.x1 + rod.x2) / 2, my = (rod.y1 + rod.y2) / 2;

    ctx.save();

    /* velocity arrow */
    if (v > 0.5) {
      const spd = Math.min(50, v * 0.5);
      const vx  = rod.vx / v, vy = rod.vy / v;
      const ax2 = mx + vx * spd, ay2 = my + vy * spd;
      ctx.strokeStyle = '#a78bfa'; ctx.lineWidth = 2; ctx.shadowColor = '#a78bfa'; ctx.shadowBlur = 8;
      ctx.beginPath(); ctx.moveTo(mx, my); ctx.lineTo(ax2, ay2); ctx.stroke();
      const ang = Math.atan2(vy, vx);
      ctx.save(); ctx.translate(ax2, ay2); ctx.rotate(ang);
      ctx.fillStyle = '#a78bfa';
      ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(-8,-4); ctx.lineTo(-8,4); ctx.closePath(); ctx.fill();
      ctx.restore();
      ctx.font = '10px Manrope'; ctx.fillStyle = '#a78bfa';
      ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
      ctx.fillText('v', ax2, ay2 - 6);
    }

    /* rod itself */
    ctx.shadowColor = '#f59e0b'; ctx.shadowBlur = 16;
    ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 5; ctx.globalAlpha = 0.35; ctx.lineCap = 'round';
    ctx.beginPath(); ctx.moveTo(rod.x1, rod.y1); ctx.lineTo(rod.x2, rod.y2); ctx.stroke();
    ctx.globalAlpha = 1; ctx.shadowBlur = 8; ctx.lineWidth = 3.5;
    ctx.strokeStyle = '#f59e0b';
    ctx.beginPath(); ctx.moveTo(rod.x1, rod.y1); ctx.lineTo(rod.x2, rod.y2); ctx.stroke();

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

    /* EMF label */
    const perpX = -Ly / L, perpY = Lx / L;
    ctx.shadowBlur = 6; ctx.shadowColor = '#f59e0b';
    ctx.font = 'bold 11px Manrope'; ctx.fillStyle = '#f59e0b';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('ε = ' + emf.toFixed(4) + ' (ед)', mx + perpX * 26, my + perpY * 26);
    ctx.font = '10px Manrope'; ctx.fillStyle = 'rgba(245,158,11,0.75)'; ctx.shadowBlur = 3;
    ctx.fillText('|B|̲ = ' + avgB.toFixed(1) + '  v = ' + v.toFixed(1), mx + perpX * 26, my + perpY * 40);
    ctx.fillText('← ↑ → ↓ — перемещение', mx, my - L / 2 - 14);

    ctx.restore();
  }

  /* ── particle ── */
  _drawParticle(ctx) {
    const p = this._particle;
    if (!p) return;
    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();
    }
    const grd = ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,16);
    grd.addColorStop(0,'rgba(255,255,80,0.35)'); grd.addColorStop(1,'rgba(255,255,80,0)');
    ctx.save(); ctx.fillStyle=grd; ctx.beginPath(); ctx.arc(p.x,p.y,16,0,Math.PI*2); ctx.fill(); ctx.restore();
    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();
    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();
    }
  }

  /* ── cursor E ── */
  _drawCursorE(ctx) {
    const { ex, ey, mag, v } = this._cursorE;
    const { x, y } = this._mousePos;
    if (mag < 1e-6) return;
    const nx = ex/mag, ny = ey/mag;
    const len = 20, x2 = x+nx*len, y2 = y+ny*len;
    ctx.save();
    ctx.strokeStyle = 'rgba(239,71,111,0.8)'; ctx.fillStyle = 'rgba(239,71,111,0.8)'; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x2,y2); ctx.stroke();
    const ax=-ny*4, ay=nx*4;
    ctx.beginPath(); ctx.moveTo(x2,y2);
    ctx.lineTo(x2-nx*8+ax, y2-ny*8+ay); ctx.lineTo(x2-nx*8-ax, y2-ny*8-ay);
    ctx.closePath(); ctx.fill();
    const eStr = mag>=1000 ? (mag/1000).toFixed(1)+'k' : mag.toFixed(0);
    const vStr = Math.abs(v)>=1000 ? (v/1000).toFixed(1)+'k' : v.toFixed(0);
    ctx.font='11px monospace'; ctx.textAlign='left'; ctx.textBaseline='bottom';
    ctx.fillStyle='rgba(255,255,255,0.85)'; ctx.shadowBlur=4; ctx.shadowColor='#000';
    ctx.fillText(`|E| = ${eStr}`, x+6, y-14);
    ctx.fillText(`V  = ${vStr}`, x+6, y-2);
    ctx.restore();
  }

  /* ── cursor B ── */
  _drawCursorB(ctx) {
    const b = this._cursorB;
    if (!b || !this._mousePos) return;
    const { bx, by, mag } = b;
    const { x, y } = this._mousePos;
    if (mag < 0.5) return;
    ctx.save();
    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([]);
    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;
    ctx.strokeStyle='rgba(6,214,224,0.7)'; ctx.lineWidth=1.2;
    ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+bnx*len,y+bny*len); ctx.stroke();
    ctx.fillStyle='rgba(6,214,224,0.7)';
    const a=Math.atan2(bny,bnx), 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();
    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();
  }

  /* ── empty hint ── */
  _drawHint(ctx) {
    const W = this.W, H = this.H;
    ctx.save();
    ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.font='16px Manrope, sans-serif'; ctx.fillStyle='rgba(155,93,229,0.45)';
    if (this.mode === 'E' || this.mode === 'combined') {
      ctx.fillText('Нажмите — добавьте заряд (+/−)', W/2, H/2-18);
    } else {
      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();
  }

  /* ──────────────────────────────
     Colour helpers
  ────────────────────────────── */

  /* HSL (0-360, 0-100, 0-100) → [r, g, b] — used by E colormap */
  _hslToRgb(h, s, l) {
    h = ((h % 360) + 360) % 360;
    s /= 100; l /= 100;
    const c = (1 - Math.abs(2*l-1)) * s;
    const x = c * (1 - Math.abs((h/60) % 2 - 1));
    const m = l - c/2;
    let r=0, g=0, b=0;
    if      (h < 60)  { r=c; g=x; b=0; }
    else if (h < 120) { r=x; g=c; b=0; }
    else if (h < 180) { r=0; g=c; b=x; }
    else if (h < 240) { r=0; g=x; b=c; }
    else if (h < 300) { r=x; g=0; b=c; }
    else              { r=c; g=0; b=x; }
    return [Math.round((r+m)*255), Math.round((g+m)*255), Math.round((b+m)*255)];
  }

  /* HSL (0-1, 0-1, 0-1) → [r, g, b] — used by B colormap */
  _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)];
  }
}

/* ═══════════════════════════════════════
   Lab UI glue — EMField
═══════════════════════════════════════ */

var emSim = null;

function _openEMField(defaultMode) {
  const mode = defaultMode || 'E';
  document.getElementById('sim-topbar-title').textContent = 'Электромагнитные поля';
  _simShow('sim-emfield');
  _simShow('ctrl-emfield');

  requestAnimationFrame(() => requestAnimationFrame(() => {
    const canvas = document.getElementById('emfield-canvas');
    if (!emSim) {
      emSim = new EMFieldSim(canvas);
      emSim.onUpdate = _emUpdateUI;
    }
    emSim.fit();
    emSwitchMode(mode, true);
    if (emSim.sources.length === 0) _emDefaultPreset(mode);
    _emUpdateUI(emSim.info());
  }));
}

function _emDefaultPreset(mode) {
  if (mode === 'E' || mode === 'combined') emSim.presetE('dipole');
  else                                     emSim.presetB('anti');
}

function emSwitchMode(mode, silent) {
  if (!emSim) return;
  emSim.setMode(mode);

  /* tab styling */
  ['E','B','combined'].forEach(m => {
    const btn = document.getElementById('em-tab-' + m);
    if (btn) btn.classList.toggle('active', m === mode);
  });

  /* show/hide control sections */
  const eCtrl  = document.getElementById('em-ctrl-E');
  const bCtrl  = document.getElementById('em-ctrl-B');
  const abCtrl = document.getElementById('em-ctrl-combined');
  if (eCtrl)  eCtrl.style.display  = (mode === 'E' || mode === 'combined') ? '' : 'none';
  if (bCtrl)  bCtrl.style.display  = (mode === 'B' || mode === 'combined') ? '' : 'none';
  if (abCtrl) abCtrl.style.display = mode === 'combined' ? '' : 'none';

  if (!silent) {
    if (emSim.sources.length === 0) _emDefaultPreset(mode);
    _emUpdateUI(emSim.info());
  }
}

function emAddTypeSwitch(type) {
  if (!emSim) return;
  emSim._addType = type;
  document.getElementById('em-add-charge').classList.toggle('active', type === 'charge');
  document.getElementById('em-add-wire').classList.toggle('active', type === 'wire');
}

function emSign(s) {
  if (!emSim) return;
  emSim.addSign = s >= 0 ? +1 : -1;
  document.getElementById('em-sign-pos').classList.toggle('active', s > 0);
  document.getElementById('em-sign-neg').classList.toggle('active', s < 0);
}

function emWireDir(dir) {
  if (!emSim) return;
  emSim.addDir = dir;
  document.getElementById('em-dir-out').classList.toggle('active', dir === 'out');
  document.getElementById('em-dir-in').classList.toggle('active', dir === 'in');
}

function emCurrentChange() {
  const I = +document.getElementById('sl-emI').value;
  const lbl = document.getElementById('em-curI-val');
  if (lbl) lbl.textContent = I + ' А';
  if (emSim) emSim.setCurrentAll(I);
}

function emLayer(field, name, rowEl) {
  if (!emSim) return;
  const key = field + '_' + name;
  emSim.layers[key] = !emSim.layers[key];
  const on  = emSim.layers[key];
  rowEl.classList.toggle('active', on);
  const tog = rowEl.querySelector('.tri-toggle');
  if (tog) {
    tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
    const dot = tog.querySelector('span');
    if (dot) dot.style.marginLeft = on ? '14px' : '2px';
  }
  if (field === 'B') emSim._cmBDirty = true;
  if (field === 'E') emSim._cmEDirty = true;
  emSim.draw();
}

function emParticle(rowEl) {
  if (!emSim) return;
  emSim.toggleParticle();
  rowEl.classList.toggle('active', emSim.particleOn);
  _emUpdateUI(emSim.info());
}

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

function emCondCurrentChange() {
  if (!emSim) return;
  const I = parseFloat(document.getElementById('sl-emCondI').value);
  const lbl = document.getElementById('em-condI-val');
  if (lbl) lbl.textContent = I + ' А';
  emSim.setConductorI(I);
}

function emFluxToggle(rowEl) {
  if (!emSim) return;
  emSim.toggleFlux();
  rowEl.classList.toggle('active', emSim._flux.on);
  _emUpdateUI(emSim.info());
}

function emPresetE(name) { if (emSim) emSim.presetE(name); }
function emPresetB(name) { if (emSim) emSim.presetB(name); }

function emGaussToggle(rowEl) {
  if (!emSim) return;
  emSim.toggleGauss();
  const on = emSim._gauss.on;
  rowEl.classList.toggle('active', on);
  const tog = rowEl.querySelector('.tri-toggle');
  if (tog) {
    tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
    const dot = tog.querySelector('span');
    if (dot) dot.style.marginLeft = on ? '14px' : '2px';
  }
  const block = document.getElementById('em-gauss-r-block');
  if (block) block.style.display = on ? '' : 'none';
  _emUpdateUI(emSim.info());
}

function emGaussRChange() {
  if (!emSim) return;
  const r   = parseFloat(document.getElementById('sl-emGaussR').value);
  const lbl = document.getElementById('em-gaussR-val');
  if (lbl) lbl.textContent = Math.round(r) + ' пкс';
  emSim.setGaussR(r);
}

function emRodToggle(rowEl) {
  if (!emSim) return;
  emSim.toggleRod();
  const on = emSim._rod.on;
  rowEl.classList.toggle('active', on);
  const tog = rowEl.querySelector('.tri-toggle');
  if (tog) {
    tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
    const dot = tog.querySelector('span');
    if (dot) dot.style.marginLeft = on ? '14px' : '2px';
  }
  _emUpdateUI(emSim.info());
}

function _emUpdateUI(info) {
  if (!info) return;
  const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };

  set('embar-charges',  info.charges);
  set('embar-wires',    info.wires);
  set('embar-curE',     info.cursorE);
  set('embar-curV',     info.cursorV);
  set('embar-curB',     info.cursorB);
  set('embar-particle', info.particleOn ? 'вкл' : 'выкл');

  const pEl = document.getElementById('embar-particle');
  if (pEl) pEl.style.color = info.particleOn ? '#ffff50' : '';

  const fEl = document.getElementById('embar-ampere');
  if (fEl) {
    if (info.condOn && info.Fz !== 0) {
      fEl.textContent = (info.Fz > 0 ? '(+) ' : '(-) ') + Math.abs(info.Fz).toFixed(3);
      fEl.style.color = '#fbbf24';
    } else {
      fEl.textContent = '—'; fEl.style.color = '#fbbf24';
    }
  }
  const phEl = document.getElementById('embar-flux');
  if (phEl) {
    if (info.fluxOn) { phEl.textContent = info.flux.toExponential(2) + ' Вб'; phEl.style.color = '#34d399'; }
    else             { phEl.textContent = '—'; phEl.style.color = '#34d399'; }
  }

  /* Gauss surface stats */
  const gEl = document.getElementById('embar-gauss');
  if (gEl) {
    if (info.gaussOn) {
      const sign = info.gaussExact >= 0 ? '+' : '';
      gEl.textContent = sign + info.gaussExact.toFixed(3);
      gEl.style.color = '#34d399';
    } else {
      gEl.textContent = '—'; gEl.style.color = '#34d399';
    }
  }

  /* Rod EMF stats */
  const rEl = document.getElementById('embar-rod');
  if (rEl) {
    if (info.rodOn) {
      rEl.textContent = info.rodEMF.toFixed(4) + ' ед';
      rEl.style.color = '#f59e0b';
    } else {
      rEl.textContent = '—'; rEl.style.color = '#f59e0b';
    }
  }
}
