feat(labs): wave 2 — depth features across 6 sims
Электрические цепи (circuit): - Индуктивность L как новый компонент (1–1000 мГн, шорт в DC, jωL в AC) - RLC preset для демонстрации резонанса - Осциллограф: U(t)/I(t) для выбранного компонента, 100 sample, dual-axis - Heatmap мощности: радиальный градиент halo от blue→red пропорционально P=UI Стереометрия 3D (stereo): - Сечение через 3 произвольные точки: pick на гранях/рёбрах/вершинах - Плоскость + полигон пересечения с авто-определением типа (3–6-угольник) и площадью - Step-by-step режим: визуализация P1→линия→P2→линия→P3→плоскость→сечение - Поддержка всех solids (включая cylinder/cone через sampling fallback) Планиметрия (geometry): - Задачник framework: CHALLENGES[] с setup/check функциями - 5 стартовых задач: серединный перпендикуляр, биссектриса, описанная окружность, ГМТ, касательная - Авто-checker: толерантности ±0.5° для углов, ±1–5% для расстояний - UI: collapsible панель с статус-иконками, конфетти + «Молодец!» на success Электромагнитные поля (emfield): - Preset «Тороид»: 16+16 проводов в концентрических кольцах - Поверхность Гаусса: draggable круг, считает Φ = q_enc/ε₀, подсвечивает охваченные заряды - Motional EMF: draggable rod, arrow-keys управление, считает ε = ∫(v×B)·dl Химическая песочница (chemsandbox): - Live-overlay с уравнением реакции: молекулярное / полное ионное / сокращённое ионное - Coverage: 49/49 молекулярных, 34/49 ионных, 36/49 сокращённых - Auto-hide через 5 сек, fade-in animation, цветовая кодировка типов Волны и звук (waves): - Doppler: source+observer drag, expanding wavefronts, f_obs формула, Mach cone при v>c - Beats: f1+f2, sum waveform с envelope, индикация f_beat=|f1-f2| - Spectrum (DFT): N=256 samples pure JS, bar-chart с пиками и labels, «Добавить гармонику» Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+429
-9
@@ -64,6 +64,26 @@ class EMFieldSim {
|
||||
_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;
|
||||
@@ -124,6 +144,9 @@ class EMFieldSim {
|
||||
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;
|
||||
@@ -185,6 +208,9 @@ class EMFieldSim {
|
||||
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());
|
||||
@@ -225,6 +251,113 @@ class EMFieldSim {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/* Gauss surface (E mode, electric flux) */
|
||||
toggleGauss() {
|
||||
this._gauss.on = !this._gauss.on;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
setGaussR(r) {
|
||||
this._gauss.r = r;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/* Motional EMF rod (B mode) */
|
||||
toggleRod() {
|
||||
const rod = this._rod;
|
||||
rod.on = !rod.on;
|
||||
if (rod.on) {
|
||||
rod.vx = 0; rod.vy = 0;
|
||||
rod._last = performance.now();
|
||||
this._tickRod();
|
||||
} else {
|
||||
if (rod._raf) { cancelAnimationFrame(rod._raf); rod._raf = null; }
|
||||
this.draw();
|
||||
}
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
}
|
||||
|
||||
_tickRod() {
|
||||
const rod = this._rod;
|
||||
if (!rod.on) return;
|
||||
const now = performance.now();
|
||||
const dt = Math.min((now - rod._last) * 0.001, 0.05); // seconds
|
||||
rod._last = now;
|
||||
|
||||
/* keyboard-driven acceleration: arrow keys → velocity */
|
||||
const speed = 90; // px/s max
|
||||
let ax = 0, ay = 0;
|
||||
if (rod._keys['ArrowLeft']) ax -= 1;
|
||||
if (rod._keys['ArrowRight']) ax += 1;
|
||||
if (rod._keys['ArrowUp']) ay -= 1;
|
||||
if (rod._keys['ArrowDown']) ay += 1;
|
||||
|
||||
if (ax !== 0 || ay !== 0) {
|
||||
const len = Math.hypot(ax, ay);
|
||||
rod.vx = (ax / len) * speed;
|
||||
rod.vy = (ay / len) * speed;
|
||||
} else {
|
||||
/* friction */
|
||||
rod.vx *= 0.88;
|
||||
rod.vy *= 0.88;
|
||||
if (Math.hypot(rod.vx, rod.vy) < 0.5) { rod.vx = 0; rod.vy = 0; }
|
||||
}
|
||||
|
||||
/* move rod */
|
||||
rod.x1 += rod.vx * dt;
|
||||
rod.y1 += rod.vy * dt;
|
||||
rod.x2 += rod.vx * dt;
|
||||
rod.y2 += rod.vy * dt;
|
||||
|
||||
/* clamp to canvas */
|
||||
const margin = 10;
|
||||
const minX = Math.min(rod.x1, rod.x2), maxX = Math.max(rod.x1, rod.x2);
|
||||
const minY = Math.min(rod.y1, rod.y2), maxY = Math.max(rod.y1, rod.y2);
|
||||
if (minX < margin) { const d = margin - minX; rod.x1 += d; rod.x2 += d; }
|
||||
if (maxX > this.W - margin) { const d = maxX - (this.W - margin); rod.x1 -= d; rod.x2 -= d; }
|
||||
if (minY < margin) { const d = margin - minY; rod.y1 += d; rod.y2 += d; }
|
||||
if (maxY > this.H - margin) { const d = maxY - (this.H - margin); rod.y1 -= d; rod.y2 -= d; }
|
||||
|
||||
this.draw();
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
rod._raf = requestAnimationFrame(() => this._tickRod());
|
||||
}
|
||||
|
||||
/* Compute motional EMF = integral of (v × B) · dl along rod */
|
||||
_rodEMF() {
|
||||
const rod = this._rod;
|
||||
const Lx = rod.x2 - rod.x1, Ly = rod.y2 - rod.y1;
|
||||
const L = Math.hypot(Lx, Ly);
|
||||
if (L < 1) return { emf: 0, avgB: 0, v: 0 };
|
||||
/* dl unit vector */
|
||||
const dlx = Lx / L, dly = Ly / L;
|
||||
const v = Math.hypot(rod.vx, rod.vy);
|
||||
const N = 20; // integration samples
|
||||
let sum = 0, avgB = 0;
|
||||
for (let k = 0; k <= N; k++) {
|
||||
const t = k / N;
|
||||
const px = rod.x1 + Lx * t, py = rod.y1 + Ly * t;
|
||||
const { bx, by, mag } = this._bField(px, py);
|
||||
avgB += mag;
|
||||
/* (v × B) in 2D: vx*By - vy*Bx gives z-component of (v×B)
|
||||
(v×B)·dl = (vx·By - vy·Bx)·dlx - ... → in 2D project back:
|
||||
(v×B) is a vector: if v=(vx,vy,0), B=(Bx,By,0) →
|
||||
v×B = (vy·0-0·By, 0·Bx-vx·0, vx·By-vy·Bx) = (0,0,vx·By-vy·Bx)
|
||||
But B here is in-plane; we treat |B| as out-of-plane Bz for the 2D sim.
|
||||
So B = (0,0,Bz) where Bz = mag (or -mag depending on orientation sign).
|
||||
We use bx,by as in-plane → but physically they represent the field in the plane.
|
||||
For motional EMF in 2D: use Bz=mag (perpendicular to plane convention).
|
||||
(v×Bz_hat)·dl = (vy·Bz)·dlx + (-vx·Bz)·dly */
|
||||
const Beff = mag * 0.00012; // same scale used in particle simulation
|
||||
const vCrossB_x = rod.vy * Beff;
|
||||
const vCrossB_y = -rod.vx * Beff;
|
||||
sum += (vCrossB_x * dlx + vCrossB_y * dly);
|
||||
}
|
||||
avgB /= (N + 1);
|
||||
const emf = sum * L / (N + 1); // Riemann sum → integral
|
||||
return { emf, avgB, v };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────
|
||||
Particle
|
||||
────────────────────────────── */
|
||||
@@ -367,6 +500,18 @@ class EMFieldSim {
|
||||
}
|
||||
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();
|
||||
@@ -466,18 +611,50 @@ class EMFieldSim {
|
||||
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 ampere = condOn ? this._ampereForce() : null;
|
||||
const Fz = ampere ? ampere.Fz : 0;
|
||||
const flux = fluxOn ? this._fluxValue() : 0;
|
||||
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, Fz, flux,
|
||||
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) : '—',
|
||||
@@ -519,6 +696,18 @@ class EMFieldSim {
|
||||
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 => {
|
||||
@@ -543,6 +732,19 @@ class EMFieldSim {
|
||||
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'; }
|
||||
});
|
||||
@@ -588,6 +790,20 @@ class EMFieldSim {
|
||||
this.draw(); return;
|
||||
}
|
||||
|
||||
if (this._gauss._dragging) {
|
||||
this._gauss.x = p.x; this._gauss.y = p.y;
|
||||
this.draw(); return;
|
||||
}
|
||||
|
||||
if (this._rod._dragging) {
|
||||
const rod = this._rod;
|
||||
const cx = p.x - rod._dragOffX, cy = p.y - rod._dragOffY;
|
||||
const hLx = (rod.x2 - rod.x1) / 2, hLy = (rod.y2 - rod.y1) / 2;
|
||||
rod.x1 = cx - hLx; rod.y1 = cy - hLy;
|
||||
rod.x2 = cx + hLx; rod.y2 = cy + hLy;
|
||||
this.draw(); return;
|
||||
}
|
||||
|
||||
if (this._drag !== null) {
|
||||
this.sources[this._drag].x = p.x;
|
||||
this.sources[this._drag].y = p.y;
|
||||
@@ -597,7 +813,7 @@ class EMFieldSim {
|
||||
|
||||
const i = hitSource(p);
|
||||
const ch = hitCond(p);
|
||||
const fh = hitFlux(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();
|
||||
@@ -614,6 +830,12 @@ class EMFieldSim {
|
||||
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';
|
||||
@@ -622,7 +844,7 @@ class EMFieldSim {
|
||||
|
||||
/* click on empty canvas — add source based on mode */
|
||||
if (!moved && e.button === 0 &&
|
||||
hitSource(p) < 0 && hitCond(p) === null && !hitFlux(p)) {
|
||||
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') {
|
||||
@@ -686,6 +908,18 @@ class EMFieldSim {
|
||||
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];
|
||||
});
|
||||
}
|
||||
|
||||
/* ──────────────────────────────
|
||||
@@ -729,6 +963,8 @@ class EMFieldSim {
|
||||
/* 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 */
|
||||
@@ -1238,6 +1474,129 @@ class EMFieldSim {
|
||||
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;
|
||||
@@ -1510,6 +1869,44 @@ function emFluxToggle(rowEl) {
|
||||
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; };
|
||||
@@ -1527,7 +1924,7 @@ function _emUpdateUI(info) {
|
||||
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.textContent = (info.Fz > 0 ? '(+) ' : '(-) ') + Math.abs(info.Fz).toFixed(3);
|
||||
fEl.style.color = '#fbbf24';
|
||||
} else {
|
||||
fEl.textContent = '—'; fEl.style.color = '#fbbf24';
|
||||
@@ -1538,4 +1935,27 @@ function _emUpdateUI(info) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user