Files
Learn_System/frontend/js/labs/coulomb.js
T
Maxim Dolgolyov ae31e4c4e8 refactor: distribute lab-init.js into 34 engine files
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only)

Each sim's _open*() + UI helpers moved to its engine file:
graph.js, projectile.js, collision.js, magnetic.js, triangle.js,
geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js,
reactions.js (chemistry), newton.js (dynamics), chemsandbox.js,
celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js,
normaldist.js, graphtransform.js, pendulum.js, equilibrium.js,
thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js,
probability.js, bohratom.js, electrolysis.js, waves.js,
crystal.js, orbitals.js, stereo.js, hydrostatics.js

All 34 engine files syntax-checked OK.
2026-05-08 14:54:54 +03:00

813 lines
27 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';
/* ══════════════════════════════════════════════════════════
CoulombSim — Coulomb's Law interactive simulation
• Click canvas to place charge (+ or )
• Drag to reposition, double-click / right-click to remove
• Layers: colormap, field lines, vector arrows,
equipotentials, force arrows
Electric field of point charge q at (cx,cy):
Ex = K·q·(x-cx)/r³, Ey = K·q·(y-cy)/r³
Potential: V = K·q/r
══════════════════════════════════════════════════════════ */
class CoulombSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.charges = []; // [{x, y, q, id}]
this._nextId = 0;
this.addSign = +1;
/* layers */
this.layers = {
colormap: true,
fieldlines: true,
vectors: false,
equipotentials: true,
forces: false,
};
/* interaction */
this._drag = null; // charge index being dragged
this._hovered = null; // charge index under mouse
this._downPos = null; // mousedown position for click vs drag detection
this._mousePos = null; // {x, y}
/* colormap cache */
this._cmDirty = true;
this._cmCache = null; // ImageData
/* cursor reading */
this._cursorE = null; // {ex, ey, mag, v}
/* visual Coulomb constant */
this.K = 60000;
/* dimensions */
this.W = 0;
this.H = 0;
/* callback */
this.onUpdate = null;
this._bindEvents();
}
/* ── Resize ─────────────────────────────────────────────── */
fit() {
this.W = this.canvas.offsetWidth;
this.H = this.canvas.offsetHeight;
this.canvas.width = this.W * devicePixelRatio;
this.canvas.height = this.H * devicePixelRatio;
this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
this._cmDirty = true;
this._cmCache = null;
this.draw();
}
/* ── Reset ──────────────────────────────────────────────── */
reset() {
this.charges = [];
this._nextId = 0;
this._cmDirty = true;
this._cmCache = null;
this._drag = null;
this._hovered = null;
}
/* ── Charge management ──────────────────────────────────── */
addCharge(x, y, q) {
this.charges.push({ x, y, q, id: this._nextId++ });
this._cmDirty = true;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
removeCharge(i) {
if (i < 0 || i >= this.charges.length) return;
this.charges.splice(i, 1);
this._cmDirty = true;
this._drag = null;
this._hovered = null;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
toggleLayer(name) {
if (name in this.layers) {
this.layers[name] = !this.layers[name];
this.draw();
}
}
setSign(s) {
this.addSign = s >= 0 ? +1 : -1;
}
/* ── Presets ────────────────────────────────────────────── */
preset(name) {
this.reset();
const cx = this.W / 2, cy = this.H / 2, d = this.W * 0.2;
if (name === 'dipole') {
this.addCharge(cx - d, cy, 1);
this.addCharge(cx + d, cy, -1);
} else if (name === 'equal') {
this.addCharge(cx - d, cy, 1);
this.addCharge(cx + d, cy, 1);
} else if (name === 'quadrupole') {
this.addCharge(cx - d, cy - d, 1);
this.addCharge(cx + d, cy - d, -1);
this.addCharge(cx + d, cy + d, 1);
this.addCharge(cx - d, cy + d, -1);
} else if (name === 'ring') {
for (let i = 0; i < 6; i++) {
const a = i * Math.PI / 3;
this.addCharge(cx + d * Math.cos(a), cy + d * Math.sin(a), i % 2 === 0 ? 1 : -1);
}
}
this._cmDirty = true;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
/* ── Info ───────────────────────────────────────────────── */
info() {
const pos = this.charges.filter(c => c.q > 0).length;
const neg = this.charges.filter(c => c.q < 0).length;
let maxE = 0;
for (let x = 20; x < this.W; x += 40)
for (let y = 20; y < this.H; y += 40) {
const f = this._fieldAt(x, y);
if (f.mag > maxE) maxE = f.mag;
}
const ce = this._cursorE;
return {
total: this.charges.length,
positive: pos,
negative: neg,
maxE: maxE.toFixed(0),
cursorE: ce ? ce.mag.toFixed(0) : '—',
cursorV: ce ? ce.v.toFixed(0) : '—',
};
}
/* ── Physics ────────────────────────────────────────────── */
_fieldAt(x, y) {
let ex = 0, ey = 0, v = 0;
for (const c of this.charges) {
const dx = x - c.x, dy = y - c.y;
const r2 = dx * dx + dy * dy;
if (r2 < 1) continue;
const r = Math.sqrt(r2);
const r3 = r2 * r;
ex += this.K * c.q * dx / r3;
ey += this.K * c.q * dy / r3;
v += this.K * c.q / r;
}
return { ex, ey, mag: Math.hypot(ex, ey), v };
}
/* ── HSL <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> RGB helper ───────────────────────────────────── */
_hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
s /= 100; l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
];
}
/* ── Colormap ───────────────────────────────────────────── */
_drawColormap(ctx) {
const W = this.W, H = this.H;
const STEP = 3;
if (this._cmDirty || !this._cmCache) {
const imgW = Math.ceil(W / STEP);
const imgH = Math.ceil(H / STEP);
const img = ctx.createImageData(imgW, imgH);
const d = img.data;
for (let py = 0; py < imgH; py++) {
for (let px = 0; px < imgW; px++) {
const x = px * STEP + STEP / 2;
const y = py * STEP + STEP / 2;
const { mag, v } = this._fieldAt(x, y);
/* hue based on potential sign */
let hue;
if (v > 0) hue = 0 + (v / (v + 30000)) * 30; // 030 red-orange
else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20; // 220240 blue
else hue = 0;
const sat = 80;
const lit = Math.tanh(mag / 3000) * 40
+ Math.tanh(Math.abs(v) / 50000) * 25;
const [r, g, b] = this._hslToRgb(hue, sat, lit);
const idx = (py * imgW + px) * 4;
d[idx] = r;
d[idx + 1] = g;
d[idx + 2] = b;
d[idx + 3] = 200;
}
}
this._cmCache = { img, imgW, imgH, STEP };
this._cmDirty = false;
}
const { img, imgW, imgH } = this._cmCache;
/* draw scaled up */
const oc = document.createElement('canvas');
oc.width = imgW;
oc.height = imgH;
oc.getContext('2d').putImageData(img, 0, 0);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'medium';
ctx.drawImage(oc, 0, 0, W, H);
ctx.restore();
}
/* ── Equipotentials ─────────────────────────────────────── */
_drawEquipotentials(ctx) {
const W = this.W, H = this.H;
const GRID = 8;
const LEVELS = [500, 2000, 8000, 30000, 100000, -500, -2000, -8000, -30000, -100000];
const cols = Math.ceil(W / GRID) + 1;
const rows = Math.ceil(H / GRID) + 1;
/* build V grid */
const vGrid = new Float64Array(cols * rows);
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++)
vGrid[r * cols + c] = this._fieldAt(c * GRID, r * GRID).v;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
ctx.lineWidth = 0.8;
ctx.setLineDash([4, 4]);
ctx.beginPath();
for (const level of LEVELS) {
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const v00 = vGrid[ r * cols + c ];
const v10 = vGrid[ r * cols + c + 1];
const v01 = vGrid[(r + 1) * cols + c ];
const v11 = vGrid[(r + 1) * cols + c + 1];
/* crossed edges: top, right, bottom, left */
const pts = [];
const interp = (va, vb, xa, ya, xb, yb) => {
const t = (level - va) / (vb - va);
return [xa + t * (xb - xa), ya + t * (yb - ya)];
};
if ((v00 - level) * (v10 - level) < 0)
pts.push(interp(v00, v10, c * GRID, r * GRID, (c + 1) * GRID, r * GRID));
if ((v10 - level) * (v11 - level) < 0)
pts.push(interp(v10, v11, (c + 1) * GRID, r * GRID, (c + 1) * GRID, (r + 1) * GRID));
if ((v01 - level) * (v11 - level) < 0)
pts.push(interp(v01, v11, c * GRID, (r + 1) * GRID, (c + 1) * GRID, (r + 1) * GRID));
if ((v00 - level) * (v01 - level) < 0)
pts.push(interp(v00, v01, c * GRID, r * GRID, c * GRID, (r + 1) * GRID));
if (pts.length >= 2) {
ctx.moveTo(pts[0][0], pts[0][1]);
ctx.lineTo(pts[1][0], pts[1][1]);
}
}
}
}
ctx.stroke();
ctx.restore();
}
/* ── Vector arrows ──────────────────────────────────────── */
_drawVectors(ctx) {
const GRID = 45;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
for (let x = GRID / 2; x < this.W; x += GRID) {
for (let y = GRID / 2; y < this.H; y += GRID) {
const { ex, ey, mag } = this._fieldAt(x, y);
if (mag < 1e-6) continue;
const len = Math.tanh(mag / 8000) * 18;
const nx = ex / mag, ny = ey / mag;
const x2 = x + nx * len, y2 = y + ny * len;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x2, y2);
ctx.stroke();
/* arrowhead */
const ax = -ny * 3, ay = nx * 3;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - nx * 6 + ax, y2 - ny * 6 + ay);
ctx.lineTo(x2 - nx * 6 - ax, y2 - ny * 6 - ay);
ctx.closePath();
ctx.fill();
}
}
ctx.restore();
}
/* ── Field lines ────────────────────────────────────────── */
_drawFieldLines(ctx) {
const W = this.W, H = this.H, sim = this;
const RAYS = 12;
const STEP = 2.5;
const MAX = 2500;
const MARGIN = 5;
const HIT_R = 12;
const START_R = 18;
function rkStep(x, y, h) {
const f = (px, py) => {
const e = sim._fieldAt(px, py);
const m = Math.hypot(e.ex, e.ey) || 1e-10;
return [e.ex / m, e.ey / m];
};
const [k1x, k1y] = f(x, y);
const [k2x, k2y] = f(x + h * k1x / 2, y + h * k1y / 2);
const [k3x, k3y] = f(x + h * k2x / 2, y + h * k2y / 2);
const [k4x, k4y] = f(x + h * k3x, y + h * k3y);
return [
x + h * (k1x + 2 * k2x + 2 * k3x + k4x) / 6,
y + h * (k1y + 2 * k2y + 2 * k3y + k4y) / 6,
];
}
const traceLine = (startX, startY, dir) => {
const pts = [[startX, startY]];
let px = startX, py = startY;
for (let s = 0; s < MAX; s++) {
const [nx, ny] = rkStep(px, py, dir * STEP);
if (nx < -MARGIN || nx > W + MARGIN || ny < -MARGIN || ny > H + MARGIN) break;
/* stop near negative charges */
let hitNeg = false;
for (const c of sim.charges) {
if (c.q < 0 && Math.hypot(nx - c.x, ny - c.y) < HIT_R) { hitNeg = true; break; }
}
if (hitNeg) break;
pts.push([nx, ny]);
px = nx; py = ny;
}
return pts;
};
ctx.save();
ctx.lineWidth = 1.2;
for (const charge of this.charges) {
const dir = charge.q > 0 ? 1 : -1;
for (let i = 0; i < RAYS; i++) {
const angle = (i / RAYS) * Math.PI * 2;
const sx = charge.x + START_R * Math.cos(angle);
const sy = charge.y + START_R * Math.sin(angle);
const pts = traceLine(sx, sy, dir);
if (pts.length < 2) continue;
const grad = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[pts.length - 1][0], pts[pts.length - 1][1]);
grad.addColorStop(0, 'rgba(255,255,255,0.75)');
grad.addColorStop(0.5, 'rgba(255,255,255,0.35)');
grad.addColorStop(1, 'rgba(255,255,255,0.0)');
ctx.strokeStyle = grad;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
}
}
ctx.restore();
}
/* ── Force arrows ───────────────────────────────────────── */
_drawForceArrows(ctx) {
ctx.save();
for (let i = 0; i < this.charges.length; i++) {
const ci = this.charges[i];
let fx = 0, fy = 0;
for (let j = 0; j < this.charges.length; j++) {
if (i === j) continue;
const cj = this.charges[j];
const dx = ci.x - cj.x, dy = ci.y - cj.y;
const r2 = dx * dx + dy * dy;
if (r2 < 1) continue;
const r3 = r2 * Math.sqrt(r2);
const F = this.K * ci.q * cj.q;
fx += F * dx / r3;
fy += F * dy / r3;
}
const mag = Math.hypot(fx, fy);
if (mag < 1e-6) continue;
const len = Math.tanh(mag / 50000) * 55;
const nx = fx / mag, ny = fy / mag;
const x2 = ci.x + nx * len, y2 = ci.y + ny * len;
ctx.strokeStyle = '#FFD166';
ctx.fillStyle = '#FFD166';
ctx.lineWidth = 2;
ctx.shadowBlur = 10;
ctx.shadowColor = '#FFD166';
ctx.beginPath();
ctx.moveTo(ci.x, ci.y);
ctx.lineTo(x2, y2);
ctx.stroke();
/* arrowhead */
const ax = -ny * 5, ay = nx * 5;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - nx * 10 + ax, y2 - ny * 10 + ay);
ctx.lineTo(x2 - nx * 10 - ax, y2 - ny * 10 - ay);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.restore();
}
/* ── Draw charges ───────────────────────────────────────── */
_drawCharges(ctx) {
for (let i = 0; i < this.charges.length; i++) {
const c = this.charges[i];
const r = 14 + Math.tanh(Math.abs(c.q) / 5) * 4;
const pos = c.q > 0;
ctx.save();
ctx.shadowBlur = 18;
ctx.shadowColor = pos ? '#EF476F' : '#4CC9F0';
/* hovered outer ring */
if (this._hovered === i) {
ctx.beginPath();
ctx.arc(c.x, c.y, r + 6, 0, Math.PI * 2);
ctx.strokeStyle = pos ? 'rgba(239,71,111,0.45)' : 'rgba(76,201,240,0.45)';
ctx.lineWidth = 2;
ctx.stroke();
}
/* body gradient */
const grd = ctx.createRadialGradient(c.x - r * 0.3, c.y - r * 0.3, r * 0.1, c.x, c.y, r);
if (pos) {
grd.addColorStop(0, '#FF7FA3');
grd.addColorStop(1, '#EF476F');
} else {
grd.addColorStop(0, '#90E0FF');
grd.addColorStop(1, '#4CC9F0');
}
ctx.beginPath();
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
ctx.fillStyle = grd;
ctx.fill();
/* label */
ctx.shadowBlur = 0;
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(pos ? '+' : '', c.x, c.y + 1);
ctx.restore();
}
}
/* ── Cursor E display ───────────────────────────────────── */
_drawCursorE(ctx) {
const { ex, ey, mag, v } = this._cursorE;
const { x, y } = this._mousePos;
if (mag < 1e-6) return;
const nx = ex / mag, ny = ey / mag;
const len = 20;
const x2 = x + nx * len, y2 = y + ny * len;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.8)';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x2, y2);
ctx.stroke();
const ax = -ny * 4, ay = nx * 4;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - nx * 8 + ax, y2 - ny * 8 + ay);
ctx.lineTo(x2 - nx * 8 - ax, y2 - ny * 8 - ay);
ctx.closePath();
ctx.fill();
/* text */
const eStr = mag >= 1000 ? (mag / 1000).toFixed(1) + 'k' : mag.toFixed(0);
const vStr = Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(0);
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.shadowBlur = 4;
ctx.shadowColor = '#000';
ctx.fillText(`|E| = ${eStr}`, x + 6, y - 14);
ctx.fillText(`V = ${vStr}`, x + 6, y - 2);
ctx.restore();
}
/* ── Hint ───────────────────────────────────────────────── */
_drawHint(ctx) {
const W = this.W, H = this.H;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '16px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillText('Нажмите чтобы добавить заряд', W / 2, H / 2 + 30);
/* simple circle icon */
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(W / 2, H / 2 - 14, 18, 0, Math.PI * 2);
ctx.stroke();
ctx.font = 'bold 22px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillText('+', W / 2, H / 2 - 13);
ctx.restore();
}
/* ── Main draw ──────────────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
ctx.clearRect(0, 0, W, H);
/* 1. background radial gradient */
const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) / 2);
bg.addColorStop(0, '#0D0D1A');
bg.addColorStop(1, '#050508');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
/* 2. subtle grid */
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.025)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x < W; x += 30) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
for (let y = 0; y < H; y += 30) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
ctx.stroke();
ctx.restore();
if (this.charges.length > 0) {
/* 3. colormap */
if (this.layers.colormap) this._drawColormap(ctx);
/* 4. equipotentials */
if (this.layers.equipotentials) this._drawEquipotentials(ctx);
/* 5. vectors */
if (this.layers.vectors) this._drawVectors(ctx);
/* 6. field lines */
if (this.layers.fieldlines) this._drawFieldLines(ctx);
/* 7. force arrows */
if (this.layers.forces) this._drawForceArrows(ctx);
}
/* 8. charges */
this._drawCharges(ctx);
/* 9. cursor E */
if (this._cursorE && this._mousePos && this.charges.length > 0)
this._drawCursorE(ctx);
/* 10. hint if empty */
if (this.charges.length === 0) this._drawHint(ctx);
}
/* ── Events ─────────────────────────────────────────────── */
_bindEvents() {
const canvas = this.canvas;
const pos = e => {
const r = canvas.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.charges.length - 1; i >= 0; i--)
if (Math.hypot(p.x - this.charges[i].x, p.y - this.charges[i].y) < 20) return i;
return -1;
};
/* ── mousedown ── */
canvas.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const p = pos(e);
const hi = hitIdx(p);
this._downPos = p;
if (hi >= 0) this._drag = hi;
});
/* ── mousemove ── */
canvas.addEventListener('mousemove', e => {
const p = pos(e);
this._mousePos = p;
if (this._drag !== null) {
this.charges[this._drag].x = p.x;
this.charges[this._drag].y = p.y;
this._cmDirty = true;
this._cursorE = this._fieldAt(p.x, p.y);
this.draw();
} else {
this._hovered = hitIdx(p);
this._cursorE = this.charges.length > 0 ? this._fieldAt(p.x, p.y) : null;
this.draw();
}
});
/* ── mouseup ── */
canvas.addEventListener('mouseup', e => {
if (e.button !== 0) return;
const p = pos(e);
const wasDragging = this._drag !== null;
if (wasDragging) {
this._drag = null;
this._cmDirty = true;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
return;
}
/* click (no drag) */
const dp = this._downPos || p;
const dist = Math.hypot(p.x - dp.x, p.y - dp.y);
if (dist < 5) {
const hi = hitIdx(p);
if (hi < 0) this.addCharge(p.x, p.y, this.addSign);
}
if (this.onUpdate) this.onUpdate(this.info());
});
/* ── contextmenu (remove) ── */
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
const p = pos(e);
const hi = hitIdx(p);
if (hi >= 0) this.removeCharge(hi);
});
/* ── dblclick (remove) ── */
canvas.addEventListener('dblclick', e => {
const p = pos(e);
const hi = hitIdx(p);
if (hi >= 0) this.removeCharge(hi);
});
/* ── mouseleave ── */
canvas.addEventListener('mouseleave', () => {
this._cursorE = null;
this._mousePos = null;
this._hovered = null;
this.draw();
});
/* ── touch support ── */
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const p = pos(e);
const hi = hitIdx(p);
this._downPos = p;
if (hi >= 0) this._drag = hi;
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const p = pos(e);
this._mousePos = p;
if (this._drag !== null) {
this.charges[this._drag].x = p.x;
this.charges[this._drag].y = p.y;
this._cmDirty = true;
this._cursorE = this._fieldAt(p.x, p.y);
this.draw();
}
}, { passive: false });
canvas.addEventListener('touchend', e => {
e.preventDefault();
const wasDragging = this._drag !== null;
if (wasDragging) {
this._drag = null;
this._cmDirty = true;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
return;
}
const p = pos({ touches: e.changedTouches });
const dp = this._downPos || p;
const dist = Math.hypot(p.x - dp.x, p.y - dp.y);
if (dist < 10) {
const hi = hitIdx(p);
if (hi < 0) this.addCharge(p.x, p.y, this.addSign);
}
if (this.onUpdate) this.onUpdate(this.info());
}, { passive: false });
}
}
/* ─── lab UI init ─────────────────────────────────── */
var csSim = null;
function _openCoulomb() {
document.getElementById('sim-topbar-title').textContent = 'Закон Кулона';
_simShow('sim-coulomb');
_simShow('ctrl-coulomb');
requestAnimationFrame(() => requestAnimationFrame(() => {
const canvas = document.getElementById('coulomb-canvas');
if (!csSim) {
csSim = new CoulombSim(canvas);
csSim.onUpdate = _coulombUpdateUI;
}
csSim.fit();
if (csSim.charges.length === 0) csSim.preset('dipole');
_coulombUpdateUI(csSim.info());
}));
}
function coulombSign(s) {
if (!csSim) return;
csSim.setSign(s);
document.getElementById('cbtn-pos').classList.toggle('active', s > 0);
document.getElementById('cbtn-neg').classList.toggle('active', s < 0);
document.getElementById('csign-pos').style.opacity = s > 0 ? '1' : '0.45';
document.getElementById('csign-neg').style.opacity = s < 0 ? '1' : '0.45';
}
function coulombLayer(name, rowEl) {
if (!csSim) return;
csSim.toggleLayer(name);
const on = csSim.layers[name];
rowEl.classList.toggle('active', on);
const tog = rowEl.querySelector('.tri-toggle');
if (tog) {
tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)';
const dot = tog.querySelector('span');
if (dot) dot.style.marginLeft = on ? '14px' : '2px';
}
csSim.draw();
}
function coulombPreset(name) {
if (!csSim) return;
csSim.preset(name);
}
function _coulombUpdateUI(info) {
if (!info) return;
document.getElementById('cs-total').textContent = info.total;
document.getElementById('cs-curE').textContent = info.cursorE;
document.getElementById('cs-curV').textContent = info.cursorV;
document.getElementById('csbar-total').textContent = info.total;
document.getElementById('csbar-pos').textContent = info.positive;
document.getElementById('csbar-neg').textContent = info.negative;
document.getElementById('csbar-maxE').textContent = info.maxE;
document.getElementById('csbar-curE').textContent = info.cursorE;
}
/* ════════════════════════════════
ЭЛЕКТРИЧЕСКИЕ ЦЕПИ
════════════════════════════════ */