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:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+748
View File
@@ -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; // 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 });
}
}