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