Files
Learn_System/frontend/js/labs/emfield.js
T
Maxim Dolgolyov 7f75c96acd feat(labs): planimetry locus + emfield merger + projectile graphs + UI cleanup
Геометрия (планиметрия):
- Живые измерения как объекты: длина / угол / площадь — auto-recompute, draggable chips
- Инструмент ГМТ: sweep мовера через параметр, рисует кривую места точек
- Новые типы точек: on_segment (скользит по отрезку, _t), on_circle (по окружности, _theta)
- Toolbar: «Длина», «Угол», «Площадь», «ГМТ», «На отрезке», «На окружности»

Электромагнитные поля (emfield):
- Merge magnetic.js + coulomb.js в один EMFieldSim с 3 режимами (E / B / комбинированное)
- Унифицированный pipeline: colormap, field lines, vectors, equipotentials, flux loop, test particle
- Combined-режим: полная сила Лоренца F=q(E+v×B)
- Backward compat: #coulomb и #magnetic хеши и ?sim= параметры редиректят в emfield
- Удалены: magnetic.js, coulomb.js. Добавлен: emfield.js

Бросок тела (projectile):
- Режим целей: 3 окна, hit-детекция, HUD «Цели: N/M / Попыток: K»
- Графики x(t), y(t), vx(t), vy(t) — 2×2 Canvas 2D, real-time
- Двойной бросок: одновременно 2 траектории для сравнения (cyan vs gold)

UI fixes (по результатам аудита):
- Заменены emoji/unicode на inline SVG .ic: switch ⌇, spring 〜 (5 мест), download ⬇ (2), camera 📷
- Убраны декоративные символы ☉ ○ из geometry tool labels
- Добавлены THEORY entries: geometry, hydrostatics (раньше показывали fallback)
- Стандартизирована ширина panel для sim-proj и sim-coll (240px)
- waves перенесён в физический блок SIMS catalog (был после биологии)
- Очищен дефолтный sim-topbar-title (был «График функции»)

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

1542 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ══════════════════════════════════════════════════════════
EMFieldSim — unified electromagnetic field simulation
Modes:
'E' — electric only (charge sources)
'B' — magnetic only (wire sources)
'combined' — both fields, full Lorentz force
Source kinds:
charge → {kind:'charge', x, y, q} — point charge
wireOut → {kind:'wireOut', x, y, I} — wire current toward viewer (•)
wireIn → {kind:'wireIn', x, y, I} — wire current away (×)
conductor→ special overlay, not in sources[]
Physics (visual units, no SI conversion):
E: Ex = K_E·q·dx/r³, Ey = K_E·q·dy/r³
B: Bx = -K_B·I·dy/r², By = K_B·I·dx/r²
Lorentz (2-D projection):
F_E = q_test · E
F_B: treat |B_xy| as Bz → Fx=q·vy·Bz, Fy=-q·vx·Bz
══════════════════════════════════════════════════════════ */
class EMFieldSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.mode = 'E'; // 'E' | 'B' | 'combined'
/* source list — mixed kinds */
this.sources = [];
this._nextId = 1;
/* add-mode per kind */
this.addSign = +1; // charge sign (+1 / -1)
this.addDir = 'out'; // wire direction 'out' | 'in'
this.curI = 6; // wire current magnitude
/* layers — per-field toggles */
this.layers = {
E_colormap: true,
E_fieldlines: true,
E_vectors: false,
E_equipotentials: true,
E_forces: false,
B_colormap: true,
B_fieldlines: true,
B_vectors: false,
};
/* conductor overlay (magnetic feature) */
this._cond = {
on: false,
x1: 0, y1: 0, x2: 0, y2: 0,
I: 8,
_dragEndpoint: null,
};
/* flux indicator (magnetic feature) */
this._flux = {
on: false,
x: 0, y: 0,
r: 55,
_dragging: false,
};
/* 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._cmBDirty = true;
this._cmEDirty = true;
this._cmECache = null;
this.draw();
}
/* ──────────────────────────────
Mode switching
────────────────────────────── */
setMode(mode) {
this.mode = mode;
/* remove incompatible sources */
if (mode === 'E') {
this.sources = this.sources.filter(s => s.kind === 'charge');
} else if (mode === 'B') {
this.sources = this.sources.filter(s => s.kind !== 'charge');
}
this._invalidateAll();
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
/* ──────────────────────────────
Source management
────────────────────────────── */
addCharge(x, y, q) {
if (this.mode === 'B') return;
this.sources.push({ kind: 'charge', id: this._nextId++, x, y, q });
this._invalidateAll();
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
addWire(x, y, dir) {
if (this.mode === 'E') return;
const kind = dir === 'out' ? 'wireOut' : 'wireIn';
const I = dir === 'out' ? +this.curI : -this.curI;
this.sources.push({ kind, id: this._nextId++, x, y, I });
this._invalidateAll();
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
removeSource(id) {
this.sources = this.sources.filter(s => s.id !== id);
this._invalidateAll();
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
clearAll() {
this.sources = [];
this._particle = null;
this.particleOn = false;
if (this._pRaf) { cancelAnimationFrame(this._pRaf); this._pRaf = null; }
this._cond.on = false;
this._flux.on = false;
this._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();
}
/* ──────────────────────────────
Particle
────────────────────────────── */
toggleParticle() {
this.particleOn = !this.particleOn;
if (this.particleOn) {
this._initParticle();
this._pLast = performance.now();
this._tickParticle();
} else {
if (this._pRaf) cancelAnimationFrame(this._pRaf);
this._pRaf = null;
this._particle = null;
this.draw();
}
if (this.onUpdate) this.onUpdate(this.info());
}
_initParticle() {
this._particle = {
x: this.W * 0.18, y: this.H * 0.5,
vx: 2.2, vy: 0,
q: 1,
trail: [],
};
}
_tickParticle() {
if (!this.particleOn || !this._particle) return;
const now = performance.now();
const dt = Math.min((now - this._pLast) * 0.06, 2.5);
this._pLast = now;
const p = this._particle;
/* electric force: F = q·E (push particle) */
if (this.mode !== 'B') {
const { ex, ey } = this._eField(p.x, p.y);
const EScale = 0.000008;
p.vx += p.q * ex * EScale * dt;
p.vy += p.q * ey * EScale * dt;
}
/* magnetic force: Lorentz (2D) using B magnitude as Bz */
if (this.mode !== 'E') {
const spd = Math.hypot(p.vx, p.vy);
const { mag } = this._bField(p.x, p.y);
const Bz = mag * 0.00012 * p.q;
p.vx += p.q * p.vy * Bz * dt;
p.vy -= p.q * p.vx * Bz * dt;
/* conserve speed when only B acts */
if (this.mode === 'B') {
const newSpd = Math.hypot(p.vx, p.vy);
if (newSpd > 1e-6) { p.vx = p.vx / newSpd * spd; p.vy = p.vy / newSpd * spd; }
}
}
/* clamp speed to avoid runaway */
const maxSpd = 6;
const spd2 = Math.hypot(p.vx, p.vy);
if (spd2 > maxSpd) { p.vx = p.vx / spd2 * maxSpd; p.vy = p.vy / spd2 * maxSpd; }
p.x += p.vx * dt;
p.y += p.vy * dt;
/* bounce walls */
if (p.x < 4) { p.vx = Math.abs(p.vx); p.x = 4; }
if (p.x > this.W - 4) { p.vx = -Math.abs(p.vx); p.x = this.W - 4; }
if (p.y < 4) { p.vy = Math.abs(p.vy); p.y = 4; }
if (p.y > this.H - 4) { p.vy = -Math.abs(p.vy); p.y = this.H - 4; }
p.trail.push({ x: p.x, y: p.y });
if (p.trail.length > 350) p.trail.shift();
this.draw();
this._pRaf = requestAnimationFrame(() => this._tickParticle());
}
/* ──────────────────────────────
Presets
────────────────────────────── */
presetE(name) {
this.sources = this.sources.filter(s => s.kind !== 'charge');
const cx = this.W / 2, cy = this.H / 2, d = this.W * 0.2;
if (name === 'dipole') {
this._pushCharge(cx - d, cy, 1);
this._pushCharge(cx + d, cy, -1);
} else if (name === 'equal') {
this._pushCharge(cx - d, cy, 1);
this._pushCharge(cx + d, cy, 1);
} else if (name === 'quadrupole') {
this._pushCharge(cx - d, cy - d, 1);
this._pushCharge(cx + d, cy - d, -1);
this._pushCharge(cx + d, cy + d, 1);
this._pushCharge(cx - d, cy + d, -1);
} else if (name === 'ring') {
for (let i = 0; i < 6; i++) {
const a = i * Math.PI / 3;
this._pushCharge(cx + d * Math.cos(a), cy + d * Math.sin(a), i % 2 === 0 ? 1 : -1);
}
}
this._invalidateAll();
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
presetB(name) {
this.sources = this.sources.filter(s => s.kind === 'charge');
const cx = this.W / 2, cy = this.H / 2, d = 90;
switch (name) {
case 'single':
this._pushWire(cx, cy, 'out'); break;
case 'parallel':
this._pushWire(cx - d, cy, 'out');
this._pushWire(cx + d, cy, 'out'); break;
case 'anti':
this._pushWire(cx - d, cy, 'out');
this._pushWire(cx + d, cy, 'in'); break;
case 'solenoid': {
const cols = 5, gx = 60, gy = 70;
for (let c = 0; c < cols; c++) {
const x = cx + (c - (cols - 1) / 2) * gx;
this._pushWire(x, cy - gy / 2, 'out');
this._pushWire(x, cy + gy / 2, 'in');
}
break;
}
case 'quadrupole':
this._pushWire(cx - d, cy, 'out');
this._pushWire(cx + d, cy, 'out');
this._pushWire(cx, cy - d, 'in');
this._pushWire(cx, cy + d, 'in'); break;
case 'ring': {
const n = 8, r = 110;
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
this._pushWire(cx + Math.cos(a) * r, cy + Math.sin(a) * r, i % 2 === 0 ? 'out' : 'in');
}
break;
}
}
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 ampere = condOn ? this._ampereForce() : null;
const Fz = ampere ? ampere.Fz : 0;
const flux = fluxOn ? this._fluxValue() : 0;
return {
total: this.sources.length,
charges: charges.length, pos, neg,
wires: wires.length, out, inn,
particleOn: this.particleOn,
condOn, fluxOn, Fz, flux,
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;
};
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;
}
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._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);
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._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)) {
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 });
}
/* ──────────────────────────────
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._particle) this._drawParticle(ctx);
/* sources */
this._drawSources(ctx);
/* cursor readout */
if (this._mousePos) {
if (this._cursorE && this.mode !== 'B' && hasE) this._drawCursorE(ctx);
if (this._cursorB && this.mode !== 'E' && hasB) this._drawCursorB(ctx);
}
if (this.sources.length === 0) this._drawHint(ctx);
}
/* ── grid ── */
_drawGrid(ctx) {
ctx.save();
ctx.strokeStyle = 'rgba(155,93,229,0.055)'; ctx.lineWidth = 1;
for (let x = 0; x <= this.W; x += 50) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, this.H); ctx.stroke();
}
for (let y = 0; y <= this.H; y += 50) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(this.W, y); ctx.stroke();
}
ctx.restore();
}
/* ── E colormap (hue = potential sign, brightness = |E|) ── */
_drawColormapE(ctx) {
const W = this.W, H = this.H;
const STEP = 3;
if (this._cmEDirty || !this._cmECache) {
const imgW = Math.ceil(W / STEP);
const imgH = Math.ceil(H / STEP);
const img = ctx.createImageData(imgW, imgH);
const d = img.data;
for (let py = 0; py < imgH; py++) {
for (let px = 0; px < imgW; px++) {
const x = px * STEP + STEP / 2;
const y = py * STEP + STEP / 2;
const { mag, v } = this._eField(x, y);
let hue;
if (v > 0) hue = 0 + (v / (v + 30000)) * 30;
else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20;
else hue = 0;
const sat = 80;
const lit = Math.tanh(mag / 3000) * 40
+ Math.tanh(Math.abs(v) / 50000) * 25;
const [r, g, b] = this._hslToRgb(hue, sat, lit);
const idx = (py * imgW + px) * 4;
d[idx] = r;
d[idx + 1] = g;
d[idx + 2] = b;
d[idx + 3] = 200;
}
}
this._cmECache = { img, imgW, imgH, STEP };
this._cmEDirty = false;
}
const { img, imgW, imgH } = this._cmECache;
const oc = document.createElement('canvas');
oc.width = imgW;
oc.height = imgH;
oc.getContext('2d').putImageData(img, 0, 0);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'medium';
ctx.drawImage(oc, 0, 0, W, H);
ctx.restore();
}
/* ── B colormap (hue = angle of B, brightness = log|B|) ── */
_drawColormapB(ctx) {
if (!this._ocB) return;
const DS = 4;
const oc = this._ocB;
const oct = oc.getContext('2d');
const w = this._ocBW, h = this._ocBH;
if (this._cmBDirty) {
const imgData = oct.createImageData(w, h);
const data = imgData.data;
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const { bx, by, mag } = this._bField(px * DS, py * DS);
if (mag < 0.5) continue;
const angle = Math.atan2(by, bx);
const hue = ((angle / (2 * Math.PI) + 1) % 1) * 360;
const bright = Math.min(1, Math.log10(1 + mag * 0.005) * 0.55);
const alpha = Math.round(bright * 210);
const [r, g, b] = this._hsl(hue / 360, 0.90, 0.38 + bright * 0.28);
const idx = (py * w + px) * 4;
data[idx] = r; data[idx+1] = g; data[idx+2] = b; data[idx+3] = alpha;
}
}
oct.putImageData(imgData, 0, 0);
this._cmBDirty = false;
}
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(oc, 0, 0, w * DS, h * DS);
ctx.restore();
}
/* ── E equipotentials ── */
_drawEquipotentials(ctx) {
const W = this.W, H = this.H;
const GRID = 8;
const LEVELS = [500, 2000, 8000, 30000, 100000, -500, -2000, -8000, -30000, -100000];
const cols = Math.ceil(W / GRID) + 1;
const rows = Math.ceil(H / GRID) + 1;
const vGrid = new Float64Array(cols * rows);
for (let r = 0; r < rows; r++)
for (let col = 0; col < cols; col++)
vGrid[r * cols + col] = this._eField(col * GRID, r * GRID).v;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
ctx.lineWidth = 0.8;
ctx.setLineDash([4, 4]);
ctx.beginPath();
const interp = (va, vb, xa, ya, xb, yb, level) => {
const t = (level - va) / (vb - va);
return [xa + t * (xb - xa), ya + t * (yb - ya)];
};
for (const level of LEVELS) {
for (let r = 0; r < rows - 1; r++) {
for (let col = 0; col < cols - 1; col++) {
const v00 = vGrid[ r * cols + col ];
const v10 = vGrid[ r * cols + col + 1];
const v01 = vGrid[(r + 1) * cols + col ];
const v11 = vGrid[(r + 1) * cols + col + 1];
const pts = [];
if ((v00-level)*(v10-level) < 0) pts.push(interp(v00,v10, col*GRID,r*GRID, (col+1)*GRID,r*GRID, level));
if ((v10-level)*(v11-level) < 0) pts.push(interp(v10,v11, (col+1)*GRID,r*GRID, (col+1)*GRID,(r+1)*GRID, level));
if ((v01-level)*(v11-level) < 0) pts.push(interp(v01,v11, col*GRID,(r+1)*GRID, (col+1)*GRID,(r+1)*GRID, level));
if ((v00-level)*(v01-level) < 0) pts.push(interp(v00,v01, col*GRID,r*GRID, col*GRID,(r+1)*GRID, level));
if (pts.length >= 2) { ctx.moveTo(pts[0][0], pts[0][1]); ctx.lineTo(pts[1][0], pts[1][1]); }
}
}
}
ctx.stroke();
ctx.restore();
}
/* ── E vectors ── */
_drawVectorsE(ctx) {
const GRID = 45;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
for (let x = GRID / 2; x < this.W; x += GRID) {
for (let y = GRID / 2; y < this.H; y += GRID) {
const { ex, ey, mag } = this._eField(x, y);
if (mag < 1e-6) continue;
const len = Math.tanh(mag / 8000) * 18;
const nx = ex / mag, ny = ey / mag;
const x2 = x + nx * len, y2 = y + ny * len;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x2, y2); ctx.stroke();
const ax = -ny * 3, ay = nx * 3;
ctx.beginPath(); ctx.moveTo(x2, y2);
ctx.lineTo(x2 - nx*6+ax, y2 - ny*6+ay);
ctx.lineTo(x2 - nx*6-ax, y2 - ny*6-ay);
ctx.closePath(); ctx.fill();
}
}
ctx.restore();
}
/* ── E field lines ── */
_drawFieldLinesE(ctx) {
const W = this.W, H = this.H, sim = this;
const RAYS = 12, STEP = 2.5, MAX = 2500, MARGIN = 5, HIT_R = 12, START_R = 18;
function rkStep(x, y, h) {
const f = (px, py) => {
const e = sim._eField(px, py);
const m = Math.hypot(e.ex, e.ey) || 1e-10;
return [e.ex / m, e.ey / m];
};
const [k1x, k1y] = f(x, y);
const [k2x, k2y] = f(x + h*k1x/2, y + h*k1y/2);
const [k3x, k3y] = f(x + h*k2x/2, y + h*k2y/2);
const [k4x, k4y] = f(x + h*k3x, y + h*k3y);
return [x + h*(k1x+2*k2x+2*k3x+k4x)/6, y + h*(k1y+2*k2y+2*k3y+k4y)/6];
}
const traceLine = (sx, sy, dir) => {
const pts = [[sx, sy]]; let px = sx, py = sy;
for (let s = 0; s < MAX; s++) {
const [nx, ny] = rkStep(px, py, dir * STEP);
if (nx < -MARGIN || nx > W+MARGIN || ny < -MARGIN || ny > H+MARGIN) break;
let hitNeg = false;
for (const c of sim.sources) {
if (c.kind === 'charge' && c.q < 0 && Math.hypot(nx-c.x, ny-c.y) < HIT_R) { hitNeg = true; break; }
}
if (hitNeg) break;
pts.push([nx, ny]); px = nx; py = ny;
}
return pts;
};
ctx.save();
ctx.lineWidth = 1.2;
for (const charge of this.sources) {
if (charge.kind !== 'charge') continue;
const dir = charge.q > 0 ? 1 : -1;
for (let i = 0; i < RAYS; i++) {
const angle = (i / RAYS) * Math.PI * 2;
const sx = charge.x + START_R * Math.cos(angle);
const sy = charge.y + START_R * Math.sin(angle);
const pts = traceLine(sx, sy, dir);
if (pts.length < 2) continue;
const grad = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[pts.length-1][0], pts[pts.length-1][1]);
grad.addColorStop(0, 'rgba(255,255,255,0.75)');
grad.addColorStop(0.5, 'rgba(255,255,255,0.35)');
grad.addColorStop(1, 'rgba(255,255,255,0.0)');
ctx.strokeStyle = grad;
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
}
}
ctx.restore();
}
/* ── Coulomb force arrows ── */
_drawForceArrows(ctx) {
ctx.save();
for (let i = 0; i < this.sources.length; i++) {
const ci = this.sources[i];
if (ci.kind !== 'charge') continue;
let fx = 0, fy = 0;
for (let j = 0; j < this.sources.length; j++) {
if (i === j) continue;
const cj = this.sources[j];
if (cj.kind !== 'charge') continue;
const dx = ci.x - cj.x, dy = ci.y - cj.y;
const r2 = dx*dx + dy*dy;
if (r2 < 1) continue;
const r3 = r2 * Math.sqrt(r2);
const F = this.K_E * ci.q * cj.q;
fx += F * dx / r3; fy += F * dy / r3;
}
const mag = Math.hypot(fx, fy);
if (mag < 1e-6) continue;
const len = Math.tanh(mag / 50000) * 55;
const nx = fx / mag, ny = fy / mag;
const x2 = ci.x + nx * len, y2 = ci.y + ny * len;
ctx.strokeStyle = '#FFD166'; ctx.fillStyle = '#FFD166';
ctx.lineWidth = 2; ctx.shadowBlur = 10; ctx.shadowColor = '#FFD166';
ctx.beginPath(); ctx.moveTo(ci.x, ci.y); ctx.lineTo(x2, y2); ctx.stroke();
const ax = -ny*5, ay = nx*5;
ctx.beginPath(); ctx.moveTo(x2, y2);
ctx.lineTo(x2-nx*10+ax, y2-ny*10+ay); ctx.lineTo(x2-nx*10-ax, y2-ny*10-ay);
ctx.closePath(); ctx.fill(); ctx.shadowBlur = 0;
}
ctx.restore();
}
/* ── B field lines ── */
_drawFieldLinesB(ctx) {
const maxSteps = 700, step = 5, killR = 24;
ctx.save();
for (const src of this.sources) {
if (src.kind === 'charge') continue;
const isOut = src.I > 0;
const col = isOut ? '6,214,224' : '241,91,181';
const nLines = 14, seedR = 26;
for (let li = 0; li < nLines; li++) {
const ang = (li / nLines) * Math.PI * 2;
let x = src.x + Math.cos(ang) * seedR;
let y = src.y + Math.sin(ang) * seedR;
const pts = [{ x, y }];
for (let st = 0; st < maxSteps; st++) {
const { nx, ny } = this._bRk4(x, y, step);
x += step * nx; y += step * ny;
if (x < -60 || x > this.W+60 || y < -60 || y > this.H+60) break;
let nearOther = false;
for (const s2 of this.sources) {
if (s2 === src || s2.kind === 'charge') continue;
if (Math.hypot(x-s2.x, y-s2.y) < killR) { nearOther = true; break; }
}
if (nearOther) { pts.push({ x, y }); break; }
if (st > 20 && Math.hypot(x-src.x, y-src.y) < killR) break;
pts.push({ x, y });
}
if (pts.length < 3) continue;
ctx.shadowColor = `rgba(${col},0.5)`; ctx.shadowBlur = 7;
ctx.strokeStyle = `rgba(${col},0.65)`; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y);
ctx.stroke();
this._drawBArrows(ctx, pts, col);
}
}
ctx.restore();
}
_drawBArrows(ctx, pts, col) {
let acc = 0, next = 80;
for (let i = 1; i < pts.length; i++) {
const dx = pts[i].x - pts[i-1].x, dy = pts[i].y - pts[i-1].y;
acc += Math.hypot(dx, dy);
if (acc < next) continue;
next += 85;
const ang = Math.atan2(dy, dx);
ctx.save();
ctx.translate(pts[i].x, pts[i].y); ctx.rotate(ang);
ctx.shadowColor = `rgba(${col},0.9)`; ctx.shadowBlur = 6;
ctx.fillStyle = `rgba(${col},0.90)`;
ctx.beginPath(); ctx.moveTo(7,0); ctx.lineTo(-5,-4); ctx.lineTo(-3,0); ctx.lineTo(-5,4);
ctx.closePath(); ctx.fill();
ctx.restore();
}
}
/* ── B vector field ── */
_drawVectorsB(ctx) {
const step = 42;
ctx.save();
for (let px = step*0.5; px < this.W; px += step) {
for (let py = step*0.5; py < this.H; py += step) {
const { bx, by, mag } = this._bField(px, py);
if (mag < 1) continue;
const t = Math.min(1, Math.log10(1 + mag * 0.006) / 1.4);
const len = 8 + t * 14;
const nx = bx / mag, ny = by / mag;
const alp = 0.28 + t * 0.6;
ctx.save();
ctx.translate(px, py); ctx.rotate(Math.atan2(ny, nx));
ctx.globalAlpha = alp;
ctx.strokeStyle = `rgba(${Math.round(155+t*100)},${Math.round(93+t*121)},229,1)`;
ctx.lineWidth = 1.1 + t * 0.6;
ctx.beginPath(); ctx.moveTo(-len/2, 0); ctx.lineTo(len/2, 0); ctx.stroke();
ctx.fillStyle = ctx.strokeStyle;
ctx.beginPath(); ctx.moveTo(len/2,0); ctx.lineTo(len/2-5,-2.5); ctx.lineTo(len/2-5,2.5);
ctx.closePath(); ctx.fill();
ctx.restore();
}
}
ctx.restore();
}
/* ── draw all sources ── */
_drawSources(ctx) {
this.sources.forEach((s, i) => {
if (s.kind === 'charge') this._drawCharge(ctx, s, i);
else this._drawWire(ctx, s, i);
});
}
_drawCharge(ctx, s, i) {
const r = 14 + Math.tanh(Math.abs(s.q) / 5) * 4;
const pos = s.q > 0;
ctx.save();
ctx.shadowBlur = 18;
ctx.shadowColor = pos ? '#EF476F' : '#4CC9F0';
if (this._hovered === i) {
ctx.beginPath(); ctx.arc(s.x, s.y, r+6, 0, Math.PI*2);
ctx.strokeStyle = pos ? 'rgba(239,71,111,0.45)' : 'rgba(76,201,240,0.45)';
ctx.lineWidth = 2; ctx.stroke();
}
const grd = ctx.createRadialGradient(s.x-r*0.3, s.y-r*0.3, r*0.1, s.x, s.y, r);
if (pos) { grd.addColorStop(0,'#FF7FA3'); grd.addColorStop(1,'#EF476F'); }
else { grd.addColorStop(0,'#90E0FF'); grd.addColorStop(1,'#4CC9F0'); }
ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI*2);
ctx.fillStyle = grd; ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = '#fff'; ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(pos ? '+' : '', s.x, s.y + 1);
ctx.restore();
}
_drawWire(ctx, s, i) {
const isOut = s.I > 0;
const col = isOut ? '#06D6E0' : '#F15BB5';
const rgb = isOut ? '6,214,224' : '241,91,181';
const isHov = this._hovered === i || this._drag === i;
const R = isHov ? 19 : 16;
ctx.save();
ctx.shadowColor = col; ctx.shadowBlur = isHov ? 32 : 18;
ctx.beginPath(); ctx.arc(s.x, s.y, R+6, 0, Math.PI*2);
ctx.fillStyle = `rgba(${rgb},0.08)`; ctx.fill();
ctx.beginPath(); ctx.arc(s.x, s.y, R, 0, Math.PI*2);
ctx.fillStyle = isHov ? `rgba(${rgb},0.25)` : 'rgba(5,5,20,0.9)'; ctx.fill();
ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.stroke();
if (isOut) {
ctx.beginPath(); ctx.arc(s.x, s.y, 5, 0, Math.PI*2);
ctx.fillStyle = col; ctx.shadowBlur = 8; ctx.fill();
} else {
const d = 5.5;
ctx.strokeStyle = col; ctx.lineWidth = 2.2; ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo(s.x-d, s.y-d); ctx.lineTo(s.x+d, s.y+d);
ctx.moveTo(s.x+d, s.y-d); ctx.lineTo(s.x-d, s.y+d);
ctx.stroke();
}
ctx.shadowBlur = 0;
ctx.font = '10px Manrope, sans-serif';
ctx.fillStyle = `rgba(${rgb},0.75)`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText((isOut ? '↑' : '↓') + ' ' + Math.abs(s.I).toFixed(0) + ' А', s.x, s.y+R+5);
ctx.restore();
}
/* ── conductor ── */
_drawConductor(ctx) {
const c = this._cond;
const Lx = c.x2-c.x1, Ly = c.y2-c.y1;
const L = Math.hypot(Lx, Ly);
if (L < 2) return;
const { Fz, B, bx, by, mx, my } = this._ampereForce();
const Fabs = Math.abs(Fz);
const fOut = Fz > 0;
ctx.save();
ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 14;
ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 5; ctx.globalAlpha = 0.35;
ctx.beginPath(); ctx.moveTo(c.x1,c.y1); ctx.lineTo(c.x2,c.y2); ctx.stroke();
ctx.globalAlpha = 1; ctx.shadowBlur = 6;
ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 3.5; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(c.x1,c.y1); ctx.lineTo(c.x2,c.y2); ctx.stroke();
const steps = Math.floor(L / 55);
ctx.fillStyle = '#F15BB5'; ctx.shadowBlur = 5;
for (let s = 0; s <= steps; s++) {
const t = (s + 0.5) / (steps + 1);
const ax = c.x1+Lx*t, ay = c.y1+Ly*t;
const ang = c.I > 0 ? Math.atan2(Ly,Lx) : Math.atan2(-Ly,-Lx);
ctx.save(); ctx.translate(ax,ay); ctx.rotate(ang);
ctx.beginPath(); ctx.moveTo(7,0); ctx.lineTo(-5,-4); ctx.lineTo(-5,4); ctx.closePath();
ctx.fill(); ctx.restore();
}
[[c.x1,c.y1],[c.x2,c.y2]].forEach(([ex,ey]) => {
ctx.beginPath(); ctx.arc(ex,ey,8,0,Math.PI*2);
ctx.fillStyle='#F15BB5'; ctx.shadowBlur=10; ctx.fill();
ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.stroke();
});
if (B > 0.5) {
const bScale = Math.min(40, Math.log10(1+B*0.02)*50);
const bNorm = Math.hypot(bx,by);
const bnx = bx/bNorm, bny = by/bNorm;
ctx.strokeStyle='#22d55e'; ctx.lineWidth=1.5; ctx.shadowColor='#22d55e';
ctx.beginPath(); ctx.moveTo(mx,my); ctx.lineTo(mx+bnx*bScale,my+bny*bScale); ctx.stroke();
ctx.fillStyle='#22d55e'; ctx.font='10px Manrope';
ctx.textAlign='center'; ctx.textBaseline='bottom';
ctx.fillText('B', mx+bnx*(bScale+10), my+bny*(bScale+10));
}
if (Fabs > 1e-6) {
const sym = fOut ? '⊙' : '⊗';
const symCol = fOut ? '#06D6E0' : '#ff6060';
const perpX = -Ly/L, perpY = Lx/L;
ctx.font = `${Math.min(22,8+Fabs*200)}px Manrope`;
ctx.fillStyle=symCol; ctx.shadowColor=symCol; ctx.shadowBlur=10;
ctx.textAlign='center'; ctx.textBaseline='middle';
const symCount = Math.max(1, Math.min(5, Math.floor(L/80)));
for (let s = 0; s < symCount; s++) {
const t = (s+0.5)/symCount;
ctx.fillText(sym, c.x1+Lx*t+perpX*22, c.y1+Ly*t+perpY*22);
}
ctx.font='bold 11px Manrope'; ctx.shadowBlur=5; ctx.fillStyle=symCol;
ctx.textAlign='left'; ctx.textBaseline='middle';
ctx.fillText('F = '+Fabs.toFixed(3)+' (ед)', c.x2+12, c.y2);
}
ctx.shadowBlur = 0;
ctx.font = '10px Manrope'; ctx.fillStyle = 'rgba(241,91,181,0.8)';
ctx.textAlign='center'; ctx.textBaseline='bottom';
const ang2 = Math.atan2(Ly,Lx);
ctx.fillText('I = '+c.I+' А', mx-Math.sin(ang2)*20, my+Math.cos(ang2)*(-20));
ctx.restore();
}
/* ── flux circle ── */
_drawFlux(ctx) {
const f = this._flux;
const Phi = this._fluxValue();
const { mag } = this._bField(f.x, f.y);
const brightness = Math.min(1, Math.log10(1+mag*0.003)*0.7);
ctx.save();
const grad = ctx.createRadialGradient(f.x,f.y,0,f.x,f.y,f.r);
grad.addColorStop(0, `rgba(255,220,50,${brightness*0.4})`);
grad.addColorStop(0.6, `rgba(155,93,229,${brightness*0.15})`);
grad.addColorStop(1, 'transparent');
ctx.fillStyle=grad; ctx.beginPath(); ctx.arc(f.x,f.y,f.r,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='rgba(255,220,50,0.7)'; ctx.lineWidth=1.8;
ctx.setLineDash([6,4]); ctx.shadowColor='#ffdc32'; ctx.shadowBlur=8;
ctx.beginPath(); ctx.arc(f.x,f.y,f.r,0,Math.PI*2); ctx.stroke(); ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(f.x,f.y,4,0,Math.PI*2); ctx.fillStyle='#ffdc32'; ctx.fill();
ctx.font='bold 11px Manrope'; ctx.fillStyle='#ffdc32';
ctx.shadowColor='#ffdc32'; ctx.shadowBlur=6;
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText('Φ = '+Phi.toFixed(4)+' Вб', f.x, f.y+f.r+6);
ctx.fillText('|B| = '+mag.toFixed(1)+' (ед)', f.x, f.y+f.r+20);
ctx.restore();
}
/* ── 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 _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'; }
}
}