Files
Learn_System/frontend/js/labs/magnetic.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

1056 lines
35 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';
/* ══════════════════════════════════════════════════════════
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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> hue, magnitude <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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();
}
}