ae31e4c4e8
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.
813 lines
27 KiB
JavaScript
813 lines
27 KiB
JavaScript
'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; // 0–30 red-orange
|
||
else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20; // 220–240 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;
|
||
}
|
||
|
||
/* ════════════════════════════════
|
||
ЭЛЕКТРИЧЕСКИЕ ЦЕПИ
|
||
════════════════════════════════ */
|
||
|