'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(); } }