6afe928c0d
ФУНДАМЕНТ (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>
2036 lines
72 KiB
JavaScript
2036 lines
72 KiB
JavaScript
'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';
|
||
}
|
||
}
|
||
}
|