'use strict';
/* ══════════════════════════════════════════════════════════
MagneticSim — magnetic field of current-carrying wires
• Click canvas to place wire (• out / × in)
• Drag to reposition, double-click / right-click to remove
• Layers: colour map, field lines, vector arrows
• Particle: charged particle with Lorentz force (circular)
B field of wire at (x0,y0) with current I:
Bx = -k·I·(y-y0)/r², By = k·I·(x-x0)/r²
Colour map maps angle of B hue, magnitude brightness
══════════════════════════════════════════════════════════ */
class MagneticSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.sources = []; // {id, x, y, I}
this._nextId = 1;
this.curI = 6; // current magnitude (user-adjustable)
this.addMode = 'out'; // 'out' | 'in'
/* particle */
this._particle = null;
this.particleOn = false;
this._pRaf = null;
this._pLast = 0;
/* layers */
this.layers = { colormap: true, fieldlines: true, vectors: false };
/* conductor (проводник в поле) */
this._cond = {
on: false,
x1: 0, y1: 0, x2: 0, y2: 0, // set in fit()
I: 8, // conductor current
_dragEndpoint: null, // 0 | 1 | 'body' | null
};
/* magnetic flux indicator (круг потока) */
this._flux = {
on: false,
x: 0, y: 0, // set in fit()
r: 55,
_dragging: false,
};
/* cursor B reading */
this._cursorB = null; // {x, y, bx, by, mag}
this._mousePos = null;
/* interaction */
this._drag = null; // index into sources[]
this._hovered = null;
/* offscreen canvas for pixel-level colour map */
this._oc = null; // created in fit()
this._ocW = 0;
this._ocH = 0;
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; // downsample factor for colour map
this._ocW = Math.ceil(this.W / DS);
this._ocH = Math.ceil(this.H / DS);
this._oc = document.createElement('canvas');
this._oc.width = this._ocW;
this._oc.height = this._ocH;
this._initOverlays();
/* position conductor + flux relative to canvas */
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.draw();
}
/* init conductor / flux positions on first fit */
_initOverlays() {
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;
}
/* ────────────────────────────────
Field calculation
──────────────────────────────── */
_K = 8000; // visual scaling constant
_field(px, py) {
let bx = 0, by = 0;
for (const s of this.sources) {
const dx = px - s.x, dy = py - s.y;
const r2 = dx * dx + dy * dy;
if (r2 < 4) continue;
const k = this._K * s.I / r2;
bx -= k * dy;
by += k * dx;
}
const mag = Math.hypot(bx, by);
return { bx, by, mag };
}
_fieldNorm(px, py) {
const { bx, by, mag } = this._field(px, py);
if (mag < 1e-12) return { nx: 0, ny: 0, mag: 0 };
return { nx: bx / mag, ny: by / mag, mag };
}
/* RK4 step for field-line tracing */
_rk4(x, y, step) {
const f = (xx, yy) => this._fieldNorm(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,
};
}
/* ────────────────────────────────
Source management
──────────────────────────────── */
addSource(x, y, dir) {
this.sources.push({
id: this._nextId++,
x, y,
I: dir === 'out' ? +this.curI : -this.curI,
});
this._invalidateCache();
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
removeSource(id) {
this.sources = this.sources.filter(s => s.id !== id);
this._invalidateCache();
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
clearAll() {
this.sources = [];
this._particle = null;
this._invalidateCache();
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
setCurrentAll(I) {
this.curI = I;
this.sources.forEach(s => { s.I = s.I > 0 ? I : -I; });
this._invalidateCache();
this.draw();
}
/* Invalidate precomputed colour map cache */
_invalidateCache() { this._cmapDirty = true; }
/* ── conductor & flux toggles ── */
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();
}
/* Ampere force on conductor: F = I·(L×B)
L = conductor vector, B from wire sources at midpoint
In 3D with B in xy-plane: F = (0, 0, I*(Lx*By - Ly*Bx)) [force in z]
We display Fz magnitude + direction (⊙ out / ⊗ in) */
_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._field(mx, my);
// F_z = I*(Lx*By - Ly*Bx) — in "visual units"
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: Φ ≈ |B_avg|·πr² */
_fluxValue() {
const f = this._flux;
const { mag } = this._field(f.x, f.y);
return mag * Math.PI * f.r * f.r * 0.000001; // visual units
}
/* Preset arrangements */
preset(name) {
this.sources = [];
const cx = this.W / 2, cy = this.H / 2, d = 90;
switch (name) {
case 'single':
this.sources.push({ id: this._nextId++, x: cx, y: cy, I: +this.curI });
break;
case 'parallel':
this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI });
this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: +this.curI });
break;
case 'anti':
this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI });
this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: -this.curI });
break;
case 'solenoid': {
const cols = 5, rows = 2, gx = 60, gy = 70;
for (let c = 0; c < cols; c++) {
const x = cx + (c - (cols-1)/2) * gx;
this.sources.push({ id: this._nextId++, x, y: cy - gy/2, I: +this.curI });
this.sources.push({ id: this._nextId++, x, y: cy + gy/2, I: -this.curI });
}
break;
}
case 'quadrupole':
this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI });
this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: +this.curI });
this.sources.push({ id: this._nextId++, x: cx, y: cy - d, I: -this.curI });
this.sources.push({ id: this._nextId++, x: cx, y: cy + d, I: -this.curI });
break;
case 'ring': {
const n = 8, r = 110;
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
const dir = i % 2 === 0 ? +this.curI : -this.curI;
this.sources.push({ id: this._nextId++,
x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r, I: dir });
}
break;
}
case 'dipole':
this.sources.push({ id: this._nextId++, x: cx - 60, y: cy, I: +this.curI * 1.5 });
this.sources.push({ id: this._nextId++, x: cx + 60, y: cy, I: -this.curI * 1.5 });
this.sources.push({ id: this._nextId++, x: cx - 60, y: cy - 50, I: +this.curI * 0.5 });
this.sources.push({ id: this._nextId++, x: cx + 60, y: cy - 50, I: -this.curI * 0.5 });
this.sources.push({ id: this._nextId++, x: cx - 60, y: cy + 50, I: +this.curI * 0.5 });
this.sources.push({ id: this._nextId++, x: cx + 60, y: cy + 50, I: -this.curI * 0.5 });
break;
}
this._invalidateCache();
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
/* ────────────────────────────────
Particle (Lorentz force)
F = q(v × B)
Treat |B_xy| as Bz (educational approximation for 2D):
Fx = q·vy·Bz, Fy = -q·vx·Bz
──────────────────────────────── */
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;
const { mag } = this._field(p.x, p.y);
const Bz = mag * 0.00012 * p.q;
const spd = Math.hypot(p.vx, p.vy);
// Lorentz (2D): Fx = q·vy·Bz, Fy = -q·vx·Bz
p.vx += p.q * p.vy * Bz * dt;
p.vy -= p.q * p.vx * Bz * dt;
// Conserve speed (magnetic force does no work)
const newSpd = Math.hypot(p.vx, p.vy);
if (newSpd > 1e-6) { p.vx = p.vx / newSpd * spd; p.vy = p.vy / newSpd * spd; }
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());
}
/* ────────────────────────────────
Info
──────────────────────────────── */
info() {
const out = this.sources.filter(s => s.I > 0).length;
const inn = this.sources.filter(s => s.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 cursorB = this._cursorB ? this._cursorB.mag : null;
return { total: this.sources.length, out, inn, particleOn: this.particleOn,
condOn, fluxOn, Fz, flux, cursorB };
}
/* ────────────────────────────────
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 hitIdx = 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;
};
/* hit test conductor endpoints / body */
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;
// check midpoint drag
const mx = (x1+x2)/2, my = (y1+y2)/2;
if (Math.hypot(p.x - mx, p.y - my) < 14) return 'body';
return null;
};
/* hit test flux circle */
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 _mousedownPos = null;
let _condDragOffset = null;
c.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const p = pos(e);
_mousedownPos = p;
/* conductor endpoint drag */
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;
}
/* flux drag */
if (hitFlux(p)) {
this._flux._dragging = true;
c.style.cursor = 'grabbing';
return;
}
const i = hitIdx(p);
if (i >= 0) {
this._drag = i;
c.style.cursor = 'grabbing';
}
});
c.addEventListener('mousemove', e => {
const p = pos(e);
/* update cursor B reading */
if (!e.buttons) {
if (this.sources.length > 0) {
const f = this._field(p.x, p.y);
this._cursorB = { x: p.x, y: p.y, ...f };
if (this.onUpdate) this.onUpdate(this.info());
}
this._mousePos = p;
}
/* conductor drag */
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 nx = dx / Math.hypot(dx, dy), ny = dy / Math.hypot(dx, dy);
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;
}
/* flux drag */
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._invalidateCache();
this.draw();
return;
}
const i = hitIdx(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';
});
c.addEventListener('mouseup', e => {
const p = pos(e);
const moved = _mousedownPos &&
Math.hypot(p.x - _mousedownPos.x, p.y - _mousedownPos.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._invalidateCache();
this._drag = null;
c.style.cursor = 'crosshair';
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
return;
}
// Click (not drag) on empty space add source
if (!moved && e.button === 0 && hitIdx(p) < 0 &&
hitCond(p) === null && !hitFlux(p)) {
this.addSource(p.x, p.y, this.addMode);
}
});
c.addEventListener('dblclick', e => {
const p = pos(e);
const i = hitIdx(p);
if (i >= 0) this.removeSource(this.sources[i].id);
});
c.addEventListener('contextmenu', e => {
e.preventDefault();
const p = pos(e);
const i = hitIdx(p);
if (i >= 0) this.removeSource(this.sources[i].id);
});
c.addEventListener('touchstart', e => {
e.preventDefault();
_mousedownPos = pos(e);
const i = hitIdx(_mousedownPos);
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._invalidateCache();
this.draw();
}, { passive: false });
c.addEventListener('touchend', e => {
const p = e.changedTouches ? pos({ ...e, touches: e.changedTouches }) : null;
const moved = _mousedownPos && p &&
Math.hypot(p.x - _mousedownPos.x, p.y - _mousedownPos.y) > 8;
if (this._drag === null && !moved && p) {
this.addSource(p.x, p.y, this.addMode);
}
this._drag = null;
if (this.onUpdate) this.onUpdate(this.info());
});
}
/* ────────────────────────────────
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, W, H);
if (this.sources.length > 0) {
if (this.layers.colormap) this._drawColormap(ctx);
if (this.layers.fieldlines) this._drawFieldLines(ctx);
if (this.layers.vectors) this._drawVectors(ctx);
}
if (this._flux.on) this._drawFlux(ctx);
if (this._cond.on) this._drawConductor(ctx);
if (this._particle) this._drawParticle(ctx);
this._drawSources(ctx);
if (this._cursorB && this.sources.length > 0) this._drawCursorB(ctx);
if (this.sources.length === 0) this._drawHint(ctx, W, H);
}
/* ── grid ── */
_drawGrid(ctx, W, H) {
ctx.save();
ctx.strokeStyle = 'rgba(155,93,229,0.055)'; ctx.lineWidth = 1;
for (let x = 0; x <= W; x += 50) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
for (let y = 0; y <= H; y += 50) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
ctx.restore();
}
/* ── colour map (hue = angle of B, brightness = log|B|) ── */
_cmapDirty = true;
_drawColormap(ctx) {
if (!this._oc) return;
const DS = 4;
const oc = this._oc;
const oct = oc.getContext('2d');
const w = this._ocW, h = this._ocH;
if (this._cmapDirty) {
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 wx = px * DS, wy = py * DS;
const { bx, by, mag } = this._field(wx, wy);
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._cmapDirty = false;
}
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(oc, 0, 0, w * DS, h * DS);
ctx.restore();
}
_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)];
}
/* ── field lines ── */
_drawFieldLines(ctx) {
if (!this.sources.length) return;
const maxSteps = 700;
const step = 5;
const killR = 24;
ctx.save();
for (const src of this.sources) {
const isOut = src.I > 0;
const col = isOut ? '6,214,224' : '241,91,181';
const nLines = 14;
const 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 }];
let travelledSq = 0;
for (let st = 0; st < maxSteps; st++) {
const { nx, ny } = this._rk4(x, y, step);
x += step * nx;
y += step * ny;
travelledSq += step * step;
if (x < -60 || x > this.W + 60 || y < -60 || y > this.H + 60) break;
/* stop near another source */
let nearOther = false;
for (const s2 of this.sources) {
if (s2 === src) continue;
if (Math.hypot(x - s2.x, y - s2.y) < killR) { nearOther = true; break; }
}
if (nearOther) { pts.push({ x, y }); break; }
/* stop looping back to origin */
if (st > 20 && Math.hypot(x - src.x, y - src.y) < killR) break;
pts.push({ x, y });
}
if (pts.length < 3) continue;
/* draw with glow */
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();
/* arrowheads every ~85 px */
this._drawArrows(ctx, pts, col);
}
}
ctx.restore();
}
_drawArrows(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();
}
}
/* ── vector field ── */
_drawVectors(ctx) {
if (!this.sources.length) return;
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._field(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();
}
/* ── sources ── */
_drawSources(ctx) {
this.sources.forEach((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;
/* halo ring */
ctx.beginPath(); ctx.arc(s.x, s.y, R + 6, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${rgb},0.08)`; ctx.fill();
/* body disc */
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();
/* symbol */
if (isOut) {
/* dot = current toward viewer */
ctx.beginPath(); ctx.arc(s.x, s.y, 5, 0, Math.PI * 2);
ctx.fillStyle = col; ctx.shadowBlur = 8; ctx.fill();
} else {
/* × = current away from viewer */
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();
}
/* current label below */
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();
});
}
/* ── particle ── */
_drawParticle(ctx) {
const p = this._particle;
if (!p) return;
/* trail */
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();
}
/* glow aura */
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 16);
grad.addColorStop(0, 'rgba(255,255,80,0.35)');
grad.addColorStop(1, 'rgba(255,255,80,0)');
ctx.save(); ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(p.x, p.y, 16, 0, Math.PI*2); ctx.fill(); ctx.restore();
/* body */
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();
/* velocity arrow */
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();
}
}
/* ── empty hint ── */
/* ── 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; // force out of screen (⊙) vs into screen (⊗)
ctx.save();
/* glow under conductor */
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();
/* main conductor line */
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();
/* current direction arrows along conductor */
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();
}
/* endpoints handle dots */
[[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();
});
/* B vector at midpoint */
if (B > 0.5 && this.sources.length) {
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));
}
/* Ampere force symbols along conductor */
if (Fabs > 1e-6) {
const sym = fOut ? '⊙' : '⊗';
const symCol = fOut ? '#06D6E0' : '#ff6060';
const symSize = Math.min(22, 8 + Fabs * 200);
const perpX = -Ly / L, perpY = Lx / L; // perpendicular to conductor
const offset = fOut ? -35 : 35; // visual direction hint
ctx.font = `${symSize}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);
}
/* force magnitude label */
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);
}
/* current label */
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._field(f.x, f.y);
const brightness = Math.min(1, Math.log10(1 + mag * 0.003) * 0.7);
ctx.save();
/* filled circle — colour by field strength */
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();
/* dashed border */
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([]);
/* centre dot */
ctx.beginPath(); ctx.arc(f.x, f.y, 4, 0, Math.PI*2);
ctx.fillStyle = '#ffdc32'; ctx.fill();
/* flux label */
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();
}
/* ── B value at cursor ── */
_drawCursorB(ctx) {
const b = this._cursorB;
if (!b || !this._mousePos) return;
const { x, y, mag, bx, by } = b;
if (mag < 0.5) return;
ctx.save();
/* small circle */
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([]);
/* B direction arrow */
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;
const col = 'rgba(255,255,255,0.6)';
ctx.strokeStyle = col; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + bnx*len, y + bny*len); ctx.stroke();
ctx.fillStyle = col;
const a = Math.atan2(bny, bnx);
const 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();
/* label */
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();
}
_drawHint(ctx, W, H) {
ctx.save();
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.font = '16px Manrope, sans-serif';
ctx.fillStyle = 'rgba(155,93,229,0.45)';
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();
}
}