be4d43105e
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>
1056 lines
35 KiB
JavaScript
1056 lines
35 KiB
JavaScript
'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();
|
||
}
|
||
}
|