Files
Learn_System/frontend/js/labs/emfield.js
T
Maxim Dolgolyov 8f30a8cef6 feat(labs): wave 2 — depth features across 6 sims
Электрические цепи (circuit):
- Индуктивность L как новый компонент (1–1000 мГн, шорт в DC, jωL в AC)
- RLC preset для демонстрации резонанса
- Осциллограф: U(t)/I(t) для выбранного компонента, 100 sample, dual-axis
- Heatmap мощности: радиальный градиент halo от blue→red пропорционально P=UI

Стереометрия 3D (stereo):
- Сечение через 3 произвольные точки: pick на гранях/рёбрах/вершинах
- Плоскость + полигон пересечения с авто-определением типа (3–6-угольник) и площадью
- Step-by-step режим: визуализация P1→линия→P2→линия→P3→плоскость→сечение
- Поддержка всех solids (включая cylinder/cone через sampling fallback)

Планиметрия (geometry):
- Задачник framework: CHALLENGES[] с setup/check функциями
- 5 стартовых задач: серединный перпендикуляр, биссектриса, описанная окружность, ГМТ, касательная
- Авто-checker: толерантности ±0.5° для углов, ±1–5% для расстояний
- UI: collapsible панель с статус-иконками, конфетти + «Молодец!» на success

Электромагнитные поля (emfield):
- Preset «Тороид»: 16+16 проводов в концентрических кольцах
- Поверхность Гаусса: draggable круг, считает Φ = q_enc/ε₀, подсвечивает охваченные заряды
- Motional EMF: draggable rod, arrow-keys управление, считает ε = ∫(v×B)·dl

Химическая песочница (chemsandbox):
- Live-overlay с уравнением реакции: молекулярное / полное ионное / сокращённое ионное
- Coverage: 49/49 молекулярных, 34/49 ионных, 36/49 сокращённых
- Auto-hide через 5 сек, fade-in animation, цветовая кодировка типов

Волны и звук (waves):
- Doppler: source+observer drag, expanding wavefronts, f_obs формула, Mach cone при v>c
- Beats: f1+f2, sum waveform с envelope, индикация f_beat=|f1-f2|
- Spectrum (DFT): N=256 samples pure JS, bar-chart с пиками и labels, «Добавить гармонику»

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:48:14 +03:00

1962 lines
69 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 (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 (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; }
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 dt = Math.min((now - this._pLast) * 0.06, 2.5);
this._pLast = now;
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();
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;
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);
/* 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);
}
/* ── 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;
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._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)');
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();
}
/* ── 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;
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();
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;
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;
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';
}
}
}