LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,748 @@
|
||||
'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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user