'use strict';
/* ══════════════════════════════════════════════════════════
   CoulombSim — Coulomb's Law interactive simulation
   • Click canvas to place charge (+ or −)
   • Drag to reposition, double-click / right-click to remove
   • Layers: colormap, field lines, vector arrows,
             equipotentials, force arrows
   Electric field of point charge q at (cx,cy):
     Ex = K·q·(x-cx)/r³,  Ey = K·q·(y-cy)/r³
   Potential: V = K·q/r
══════════════════════════════════════════════════════════ */

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

    this.charges  = [];   // [{x, y, q, id}]
    this._nextId  = 0;
    this.addSign  = +1;

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

    /* interaction */
    this._drag     = null;   // charge index being dragged
    this._hovered  = null;   // charge index under mouse
    this._downPos  = null;   // mousedown position for click vs drag detection
    this._mousePos = null;   // {x, y}

    /* colormap cache */
    this._cmDirty = true;
    this._cmCache = null;    // ImageData

    /* cursor reading */
    this._cursorE = null;    // {ex, ey, mag, v}

    /* visual Coulomb constant */
    this.K = 60000;

    /* dimensions */
    this.W = 0;
    this.H = 0;

    /* callback */
    this.onUpdate = null;

    this._bindEvents();
  }

  /* ── Resize ─────────────────────────────────────────────── */
  fit() {
    this.W = this.canvas.offsetWidth;
    this.H = this.canvas.offsetHeight;
    this.canvas.width  = this.W * devicePixelRatio;
    this.canvas.height = this.H * devicePixelRatio;
    this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
    this._cmDirty = true;
    this._cmCache = null;
    this.draw();
  }

  /* ── Reset ──────────────────────────────────────────────── */
  reset() {
    this.charges  = [];
    this._nextId  = 0;
    this._cmDirty = true;
    this._cmCache = null;
    this._drag    = null;
    this._hovered = null;
  }

  /* ── Charge management ──────────────────────────────────── */
  addCharge(x, y, q) {
    this.charges.push({ x, y, q, id: this._nextId++ });
    this._cmDirty = true;
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

  removeCharge(i) {
    if (i < 0 || i >= this.charges.length) return;
    this.charges.splice(i, 1);
    this._cmDirty = true;
    this._drag    = null;
    this._hovered = null;
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

  toggleLayer(name) {
    if (name in this.layers) {
      this.layers[name] = !this.layers[name];
      this.draw();
    }
  }

  setSign(s) {
    this.addSign = s >= 0 ? +1 : -1;
  }

  /* ── Presets ────────────────────────────────────────────── */
  preset(name) {
    this.reset();
    const cx = this.W / 2, cy = this.H / 2, d = this.W * 0.2;
    if (name === 'dipole') {
      this.addCharge(cx - d, cy,  1);
      this.addCharge(cx + d, cy, -1);
    } else if (name === 'equal') {
      this.addCharge(cx - d, cy, 1);
      this.addCharge(cx + d, cy, 1);
    } else if (name === 'quadrupole') {
      this.addCharge(cx - d, cy - d,  1);
      this.addCharge(cx + d, cy - d, -1);
      this.addCharge(cx + d, cy + d,  1);
      this.addCharge(cx - d, cy + d, -1);
    } else if (name === 'ring') {
      for (let i = 0; i < 6; i++) {
        const a = i * Math.PI / 3;
        this.addCharge(cx + d * Math.cos(a), cy + d * Math.sin(a), i % 2 === 0 ? 1 : -1);
      }
    }
    this._cmDirty = true;
    this.draw();
    if (this.onUpdate) this.onUpdate(this.info());
  }

  /* ── Info ───────────────────────────────────────────────── */
  info() {
    const pos = this.charges.filter(c => c.q > 0).length;
    const neg = this.charges.filter(c => c.q < 0).length;
    let maxE = 0;
    for (let x = 20; x < this.W; x += 40)
      for (let y = 20; y < this.H; y += 40) {
        const f = this._fieldAt(x, y);
        if (f.mag > maxE) maxE = f.mag;
      }
    const ce = this._cursorE;
    return {
      total:    this.charges.length,
      positive: pos,
      negative: neg,
      maxE:     maxE.toFixed(0),
      cursorE:  ce ? ce.mag.toFixed(0) : '—',
      cursorV:  ce ? ce.v.toFixed(0)   : '—',
    };
  }

  /* ── Physics ────────────────────────────────────────────── */
  _fieldAt(x, y) {
    let ex = 0, ey = 0, v = 0;
    for (const c of this.charges) {
      const dx = x - c.x, dy = y - c.y;
      const r2 = dx * dx + dy * dy;
      if (r2 < 1) continue;
      const r  = Math.sqrt(r2);
      const r3 = r2 * r;
      ex += this.K * c.q * dx / r3;
      ey += this.K * c.q * dy / r3;
      v  += this.K * c.q / r;
    }
    return { ex, ey, mag: Math.hypot(ex, ey), v };
  }

  /* ── HSL <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> RGB helper ───────────────────────────────────── */
  _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),
    ];
  }

  /* ── Colormap ───────────────────────────────────────────── */
  _drawColormap(ctx) {
    const W = this.W, H = this.H;
    const STEP = 3;

    if (this._cmDirty || !this._cmCache) {
      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._fieldAt(x, y);

          /* hue based on potential sign */
          let hue;
          if (v > 0)      hue = 0  + (v  / (v  + 30000)) * 30;   // 0–30 red-orange
          else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20; // 220–240 blue
          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._cmCache = { img, imgW, imgH, STEP };
      this._cmDirty = false;
    }

    const { img, imgW, imgH } = this._cmCache;

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

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

    /* build V grid */
    const vGrid = new Float64Array(cols * rows);
    for (let r = 0; r < rows; r++)
      for (let c = 0; c < cols; c++)
        vGrid[r * cols + c] = this._fieldAt(c * GRID, r * GRID).v;

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

    for (const level of LEVELS) {
      for (let r = 0; r < rows - 1; r++) {
        for (let c = 0; c < cols - 1; c++) {
          const v00 = vGrid[ r      * cols + c    ];
          const v10 = vGrid[ r      * cols + c + 1];
          const v01 = vGrid[(r + 1) * cols + c    ];
          const v11 = vGrid[(r + 1) * cols + c + 1];

          /* crossed edges: top, right, bottom, left */
          const pts = [];
          const interp = (va, vb, xa, ya, xb, yb) => {
            const t = (level - va) / (vb - va);
            return [xa + t * (xb - xa), ya + t * (yb - ya)];
          };

          if ((v00 - level) * (v10 - level) < 0)
            pts.push(interp(v00, v10, c * GRID, r * GRID, (c + 1) * GRID, r * GRID));
          if ((v10 - level) * (v11 - level) < 0)
            pts.push(interp(v10, v11, (c + 1) * GRID, r * GRID, (c + 1) * GRID, (r + 1) * GRID));
          if ((v01 - level) * (v11 - level) < 0)
            pts.push(interp(v01, v11, c * GRID, (r + 1) * GRID, (c + 1) * GRID, (r + 1) * GRID));
          if ((v00 - level) * (v01 - level) < 0)
            pts.push(interp(v00, v01, c * GRID, r * GRID, c * GRID, (r + 1) * GRID));

          if (pts.length >= 2) {
            ctx.moveTo(pts[0][0], pts[0][1]);
            ctx.lineTo(pts[1][0], pts[1][1]);
          }
        }
      }
    }

    ctx.stroke();
    ctx.restore();
  }

  /* ── Vector arrows ──────────────────────────────────────── */
  _drawVectors(ctx) {
    const GRID = 45;
    ctx.save();
    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._fieldAt(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();

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

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

    function rkStep(x, y, h) {
      const f = (px, py) => {
        const e = sim._fieldAt(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 = (startX, startY, dir) => {
      const pts = [[startX, startY]];
      let px = startX, py = startY;
      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;

        /* stop near negative charges */
        let hitNeg = false;
        for (const c of sim.charges) {
          if (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.charges) {
      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)');
        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();
      }
    }
    ctx.restore();
  }

  /* ── Force arrows ───────────────────────────────────────── */
  _drawForceArrows(ctx) {
    ctx.save();
    for (let i = 0; i < this.charges.length; i++) {
      const ci = this.charges[i];
      let fx = 0, fy = 0;
      for (let j = 0; j < this.charges.length; j++) {
        if (i === j) continue;
        const cj  = this.charges[j];
        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 * 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();

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

  /* ── Draw charges ───────────────────────────────────────── */
  _drawCharges(ctx) {
    for (let i = 0; i < this.charges.length; i++) {
      const c   = this.charges[i];
      const r   = 14 + Math.tanh(Math.abs(c.q) / 5) * 4;
      const pos = c.q > 0;

      ctx.save();
      ctx.shadowBlur  = 18;
      ctx.shadowColor = pos ? '#EF476F' : '#4CC9F0';

      /* hovered outer ring */
      if (this._hovered === i) {
        ctx.beginPath();
        ctx.arc(c.x, c.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();
      }

      /* body gradient */
      const grd = ctx.createRadialGradient(c.x - r * 0.3, c.y - r * 0.3, r * 0.1, c.x, c.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(c.x, c.y, r, 0, Math.PI * 2);
      ctx.fillStyle = grd;
      ctx.fill();

      /* label */
      ctx.shadowBlur  = 0;
      ctx.fillStyle   = '#fff';
      ctx.font        = 'bold 14px sans-serif';
      ctx.textAlign   = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(pos ? '+' : '−', c.x, c.y + 1);

      ctx.restore();
    }
  }

  /* ── Cursor E display ───────────────────────────────────── */
  _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;
    const x2  = x + nx * len, y2 = y + ny * len;

    ctx.save();
    ctx.strokeStyle = 'rgba(255,255,255,0.8)';
    ctx.fillStyle   = 'rgba(255,255,255,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();

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

  /* ── Hint ───────────────────────────────────────────────── */
  _drawHint(ctx) {
    const W = this.W, H = this.H;
    ctx.save();
    ctx.textAlign    = 'center';
    ctx.textBaseline = 'middle';
    ctx.font         = '16px sans-serif';
    ctx.fillStyle    = 'rgba(255,255,255,0.35)';
    ctx.fillText('Нажмите чтобы добавить заряд', W / 2, H / 2 + 30);

    /* simple circle icon */
    ctx.strokeStyle = 'rgba(255,255,255,0.25)';
    ctx.lineWidth   = 1.5;
    ctx.beginPath();
    ctx.arc(W / 2, H / 2 - 14, 18, 0, Math.PI * 2);
    ctx.stroke();
    ctx.font      = 'bold 22px sans-serif';
    ctx.fillStyle = 'rgba(255,255,255,0.3)';
    ctx.fillText('+', W / 2, H / 2 - 13);
    ctx.restore();
  }

  /* ── Main draw ──────────────────────────────────────────── */
  draw() {
    const ctx = this.ctx, W = this.W, H = this.H;
    if (!W || !H) return;
    ctx.clearRect(0, 0, W, H);

    /* 1. background radial gradient */
    const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) / 2);
    bg.addColorStop(0, '#0D0D1A');
    bg.addColorStop(1, '#050508');
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, W, H);

    /* 2. subtle grid */
    ctx.save();
    ctx.strokeStyle = 'rgba(255,255,255,0.025)';
    ctx.lineWidth   = 1;
    ctx.beginPath();
    for (let x = 0; x < W; x += 30) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
    for (let y = 0; y < H; y += 30) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
    ctx.stroke();
    ctx.restore();

    if (this.charges.length > 0) {
      /* 3. colormap */
      if (this.layers.colormap)       this._drawColormap(ctx);
      /* 4. equipotentials */
      if (this.layers.equipotentials) this._drawEquipotentials(ctx);
      /* 5. vectors */
      if (this.layers.vectors)        this._drawVectors(ctx);
      /* 6. field lines */
      if (this.layers.fieldlines)     this._drawFieldLines(ctx);
      /* 7. force arrows */
      if (this.layers.forces)         this._drawForceArrows(ctx);
    }

    /* 8. charges */
    this._drawCharges(ctx);

    /* 9. cursor E */
    if (this._cursorE && this._mousePos && this.charges.length > 0)
      this._drawCursorE(ctx);

    /* 10. hint if empty */
    if (this.charges.length === 0) this._drawHint(ctx);
  }

  /* ── Events ─────────────────────────────────────────────── */
  _bindEvents() {
    const canvas = this.canvas;

    const pos = e => {
      const r = canvas.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.charges.length - 1; i >= 0; i--)
        if (Math.hypot(p.x - this.charges[i].x, p.y - this.charges[i].y) < 20) return i;
      return -1;
    };

    /* ── mousedown ── */
    canvas.addEventListener('mousedown', e => {
      if (e.button !== 0) return;
      const p  = pos(e);
      const hi = hitIdx(p);
      this._downPos = p;
      if (hi >= 0) this._drag = hi;
    });

    /* ── mousemove ── */
    canvas.addEventListener('mousemove', e => {
      const p = pos(e);
      this._mousePos = p;
      if (this._drag !== null) {
        this.charges[this._drag].x = p.x;
        this.charges[this._drag].y = p.y;
        this._cmDirty = true;
        this._cursorE = this._fieldAt(p.x, p.y);
        this.draw();
      } else {
        this._hovered = hitIdx(p);
        this._cursorE = this.charges.length > 0 ? this._fieldAt(p.x, p.y) : null;
        this.draw();
      }
    });

    /* ── mouseup ── */
    canvas.addEventListener('mouseup', e => {
      if (e.button !== 0) return;
      const p  = pos(e);
      const wasDragging = this._drag !== null;
      if (wasDragging) {
        this._drag    = null;
        this._cmDirty = true;
        this.draw();
        if (this.onUpdate) this.onUpdate(this.info());
        return;
      }
      /* click (no drag) */
      const dp   = this._downPos || p;
      const dist = Math.hypot(p.x - dp.x, p.y - dp.y);
      if (dist < 5) {
        const hi = hitIdx(p);
        if (hi < 0) this.addCharge(p.x, p.y, this.addSign);
      }
      if (this.onUpdate) this.onUpdate(this.info());
    });

    /* ── contextmenu (remove) ── */
    canvas.addEventListener('contextmenu', e => {
      e.preventDefault();
      const p  = pos(e);
      const hi = hitIdx(p);
      if (hi >= 0) this.removeCharge(hi);
    });

    /* ── dblclick (remove) ── */
    canvas.addEventListener('dblclick', e => {
      const p  = pos(e);
      const hi = hitIdx(p);
      if (hi >= 0) this.removeCharge(hi);
    });

    /* ── mouseleave ── */
    canvas.addEventListener('mouseleave', () => {
      this._cursorE  = null;
      this._mousePos = null;
      this._hovered  = null;
      this.draw();
    });

    /* ── touch support ── */
    canvas.addEventListener('touchstart', e => {
      e.preventDefault();
      const p  = pos(e);
      const hi = hitIdx(p);
      this._downPos = p;
      if (hi >= 0) this._drag = hi;
    }, { passive: false });

    canvas.addEventListener('touchmove', e => {
      e.preventDefault();
      const p = pos(e);
      this._mousePos = p;
      if (this._drag !== null) {
        this.charges[this._drag].x = p.x;
        this.charges[this._drag].y = p.y;
        this._cmDirty = true;
        this._cursorE = this._fieldAt(p.x, p.y);
        this.draw();
      }
    }, { passive: false });

    canvas.addEventListener('touchend', e => {
      e.preventDefault();
      const wasDragging = this._drag !== null;
      if (wasDragging) {
        this._drag    = null;
        this._cmDirty = true;
        this.draw();
        if (this.onUpdate) this.onUpdate(this.info());
        return;
      }
      const p    = pos({ touches: e.changedTouches });
      const dp   = this._downPos || p;
      const dist = Math.hypot(p.x - dp.x, p.y - dp.y);
      if (dist < 10) {
        const hi = hitIdx(p);
        if (hi < 0) this.addCharge(p.x, p.y, this.addSign);
      }
      if (this.onUpdate) this.onUpdate(this.info());
    }, { passive: false });
  }
}

/* ─── lab UI init ─────────────────────────────────── */
  var csSim = null;

  function _openCoulomb() {
    document.getElementById('sim-topbar-title').textContent = 'Закон Кулона';
    _simShow('sim-coulomb');
    _simShow('ctrl-coulomb');
    requestAnimationFrame(() => requestAnimationFrame(() => {
      const canvas = document.getElementById('coulomb-canvas');
      if (!csSim) {
        csSim = new CoulombSim(canvas);
        csSim.onUpdate = _coulombUpdateUI;
      }
      csSim.fit();
      if (csSim.charges.length === 0) csSim.preset('dipole');
      _coulombUpdateUI(csSim.info());
    }));
  }

  function coulombSign(s) {
    if (!csSim) return;
    csSim.setSign(s);
    document.getElementById('cbtn-pos').classList.toggle('active', s > 0);
    document.getElementById('cbtn-neg').classList.toggle('active', s < 0);
    document.getElementById('csign-pos').style.opacity = s > 0 ? '1' : '0.45';
    document.getElementById('csign-neg').style.opacity = s < 0 ? '1' : '0.45';
  }

  function coulombLayer(name, rowEl) {
    if (!csSim) return;
    csSim.toggleLayer(name);
    const on = csSim.layers[name];
    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';
    }
    csSim.draw();
  }

  function coulombPreset(name) {
    if (!csSim) return;
    csSim.preset(name);
  }

  function _coulombUpdateUI(info) {
    if (!info) return;
    document.getElementById('cs-total').textContent   = info.total;
    document.getElementById('cs-curE').textContent    = info.cursorE;
    document.getElementById('cs-curV').textContent    = info.cursorV;
    document.getElementById('csbar-total').textContent = info.total;
    document.getElementById('csbar-pos').textContent   = info.positive;
    document.getElementById('csbar-neg').textContent   = info.negative;
    document.getElementById('csbar-maxE').textContent  = info.maxE;
    document.getElementById('csbar-curE').textContent  = info.cursorE;
  }

  /* ════════════════════════════════
     ЭЛЕКТРИЧЕСКИЕ ЦЕПИ
  ════════════════════════════════ */

