Files
Learn_System/frontend/js/labs/emfield.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

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

2036 lines
72 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 (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';
}
}
}