9
Electric Field Lines: Drag the Charges
drag a charge, tap empty space to add one
idle
157 lines · vanilla
view source
const K = 26000, GRAB = 38, BTN = 44, PAD = 12;
let charges, W, H, dragIdx, nextQ, wasDown;
let fx = 0, fy = 0; // fieldAt output
function init({ ctx, width, height }) {
W = width; H = height;
dragIdx = -1; nextQ = 1; wasDown = false;
resetCharges();
}
function resetCharges() {
charges = [
{ x: W * 0.34, y: H * 0.5, q: 1 },
{ x: W * 0.66, y: H * 0.5, q: -1 },
];
nextQ = 1;
}
function fieldAt(x, y) {
fx = 0; fy = 0;
for (let i = 0; i < charges.length; i++) {
const c = charges[i];
const dx = x - c.x, dy = y - c.y;
const r2 = dx * dx + dy * dy + 40;
const inv = (c.q * K) / (r2 * Math.sqrt(r2));
fx += dx * inv; fy += dy * inv;
}
}
function nearest(mx, my) {
let best = -1, bd = GRAB * GRAB;
for (let i = 0; i < charges.length; i++) {
const dx = mx - charges[i].x, dy = my - charges[i].y;
const d = dx * dx + dy * dy;
if (d < bd) { bd = d; best = i; }
}
return best;
}
function traceLine(ctx, sx, sy) {
let x = sx, y = sy, ax = 0, ay = 0, adx = 0, ady = 0;
ctx.beginPath(); ctx.moveTo(x, y);
for (let s = 0; s < 340; s++) {
fieldAt(x, y);
let m = Math.sqrt(fx * fx + fy * fy);
if (m < 1e-6) break;
const mx2 = x + 2.5 * fx / m, my2 = y + 2.5 * fy / m;
fieldAt(mx2, my2);
m = Math.sqrt(fx * fx + fy * fy);
if (m < 1e-6) break;
const ux = fx / m, uy = fy / m;
x += 5 * ux; y += 5 * uy;
ctx.lineTo(x, y);
if (s === 42) { ax = x; ay = y; adx = ux; ady = uy; }
if (x < -60 || x > W + 60 || y < -60 || y > H + 60) break;
let stop = false;
for (let i = 0; i < charges.length; i++) {
if (charges[i].q < 0) {
const dx = x - charges[i].x, dy = y - charges[i].y;
if (dx * dx + dy * dy < 144) { ctx.lineTo(charges[i].x, charges[i].y); stop = true; break; }
}
}
if (stop) break;
}
ctx.stroke();
if (adx !== 0 || ady !== 0) {
ctx.beginPath();
ctx.moveTo(ax + adx * 6, ay + ady * 6);
ctx.lineTo(ax - adx * 4 - ady * 4, ay - ady * 4 + adx * 4);
ctx.lineTo(ax - adx * 4 + ady * 4, ay - ady * 4 - adx * 4);
ctx.fill();
}
}
function inRect(x, y, rx, ry, rw, rh) { return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh; }
function tick({ ctx, dt, width, height, input }) {
if (width !== W || height !== H) {
const sx = width / W, sy = height / H;
for (const c of charges) { c.x *= sx; c.y *= sy; }
W = width; H = height;
}
const rbW = 88, rbX = W - PAD - rbW, rbY = H - PAD - BTN;
if (input.mouseDown && !wasDown && !inRect(input.mouseX, input.mouseY, rbX, rbY, rbW, BTN))
dragIdx = nearest(input.mouseX, input.mouseY);
if (!input.mouseDown) dragIdx = -1;
wasDown = input.mouseDown;
if (dragIdx >= 0) {
charges[dragIdx].x = Math.max(10, Math.min(W - 10, input.mouseX));
charges[dragIdx].y = Math.max(10, Math.min(H - 10, input.mouseY));
}
for (const c of input.consumeClicks()) {
if (inRect(c.x, c.y, rbX, rbY, rbW, BTN)) { resetCharges(); continue; }
if (nearest(c.x, c.y) < 0 && charges.length < 6) {
charges.push({ x: c.x, y: c.y, q: nextQ });
nextQ = -nextQ;
}
}
ctx.fillStyle = '#070a12';
ctx.fillRect(0, 0, W, H);
// sparse field-vector texture
ctx.strokeStyle = 'rgba(120,150,210,0.28)';
ctx.lineWidth = 1;
for (let gy = 30; gy < H; gy += 48) {
for (let gx = 30; gx < W; gx += 48) {
fieldAt(gx, gy);
const m = Math.sqrt(fx * fx + fy * fy);
if (m < 1e-6) continue;
const L = Math.min(13, 4 + m * 3);
ctx.beginPath();
ctx.moveTo(gx - fx / m * L * 0.5, gy - fy / m * L * 0.5);
ctx.lineTo(gx + fx / m * L * 0.5, gy + fy / m * L * 0.5);
ctx.stroke();
}
}
// field lines from every positive charge
ctx.lineWidth = 1.4;
ctx.strokeStyle = 'rgba(255,214,110,0.75)';
ctx.fillStyle = 'rgba(255,214,110,0.75)';
for (let i = 0; i < charges.length; i++) {
if (charges[i].q <= 0) continue;
for (let s = 0; s < 16; s++) {
const a = (s / 16) * Math.PI * 2;
traceLine(ctx, charges[i].x + Math.cos(a) * 13, charges[i].y + Math.sin(a) * 13);
}
}
// charges
for (let i = 0; i < charges.length; i++) {
const c = charges[i];
const col = c.q > 0 ? '255,80,70' : '80,140,255';
const g = ctx.createRadialGradient(c.x, c.y, 2, c.x, c.y, 30);
g.addColorStop(0, `rgba(${col},0.85)`);
g.addColorStop(1, `rgba(${col},0)`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(c.x, c.y, 30, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = c.q > 0 ? '#ff5046' : '#508cff';
ctx.beginPath(); ctx.arc(c.x, c.y, 11, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px ui-sans-serif, system-ui';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(c.q > 0 ? '+' : '−', c.x, c.y + 1);
}
// HUD
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(PAD, PAD, 208, 62);
ctx.fillStyle = '#cfe0ff';
ctx.font = '13px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
ctx.fillText(`charges: ${charges.length}/6`, PAD + 10, PAD + 20);
if (input.mouseDown) {
fieldAt(input.mouseX, input.mouseY);
ctx.fillText(`|E| = ${Math.sqrt(fx * fx + fy * fy).toFixed(2)}`, PAD + 10, PAD + 38);
} else {
ctx.fillText('drag charge / tap = add', PAD + 10, PAD + 38);
}
ctx.fillStyle = 'rgba(207,224,255,0.7)';
ctx.font = '11px monospace';
ctx.fillText(`next tap adds ${nextQ > 0 ? '+' : '−'}`, PAD + 10, PAD + 56);
// reset button
const hot = input.mouseDown && inRect(input.mouseX, input.mouseY, rbX, rbY, rbW, BTN);
ctx.fillStyle = hot ? 'rgba(255,160,60,0.85)' : 'rgba(0,0,0,0.6)';
ctx.fillRect(rbX, rbY, rbW, BTN);
ctx.strokeStyle = 'rgba(255,255,255,0.45)';
ctx.strokeRect(rbX + 0.5, rbY + 0.5, rbW - 1, BTN - 1);
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px ui-sans-serif, system-ui';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('RESET', rbX + rbW / 2, rbY + BTN / 2);
}
Comments (0)
Log in to comment.