15
Ripple Tank: Interference in 2D
drag a source, tap to splash, +/- changes frequency
idle
123 lines · vanilla
view source
const GW = 200, GH = 150, BTN = 44, PAD = 12;
const C = 0.25, SPS = 240, CCELL = Math.sqrt(C) * SPS; // cells/sec
let cur, prv, dampMap, buf, bctx, imgData, px;
let W, H, freq, simT, srcs, dragIdx, wasDown;
function init({ ctx, width, height }) {
W = width; H = height;
cur = new Float32Array(GW * GH);
prv = new Float32Array(GW * GH);
dampMap = new Float32Array(GW * GH);
for (let y = 0; y < GH; y++) {
for (let x = 0; x < GW; x++) {
const d = Math.min(x, y, GW - 1 - x, GH - 1 - y);
dampMap[y * GW + x] = 0.998 * (d < 14 ? 0.86 + 0.14 * (d / 14) : 1);
}
}
buf = new OffscreenCanvas(GW, GH);
bctx = buf.getContext('2d');
imgData = bctx.createImageData(GW, GH);
px = imgData.data;
for (let i = 3; i < px.length; i += 4) px[i] = 255;
freq = 3.0; simT = 0; dragIdx = -1; wasDown = false;
srcs = [{ gx: 70, gy: 75 }, { gx: 130, gy: 75 }];
for (let i = 0; i < 300; i++) step();
}
function step() {
simT += 1 / SPS;
const drive = Math.sin(2 * Math.PI * freq * simT) * 1.6;
for (let s = 0; s < srcs.length; s++) cur[(srcs[s].gy | 0) * GW + (srcs[s].gx | 0)] = drive;
for (let y = 1; y < GH - 1; y++) {
const r = y * GW;
for (let x = 1; x < GW - 1; x++) {
const i = r + x;
const lap = cur[i - 1] + cur[i + 1] + cur[i - GW] + cur[i + GW] - 4 * cur[i];
prv[i] = (2 * cur[i] - prv[i] + C * lap) * dampMap[i];
}
}
const t = cur; cur = prv; prv = t;
}
function splash(gx, gy, amp) {
gx |= 0; gy |= 0;
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const d2 = dx * dx + dy * dy;
if (d2 > 9) continue;
const X = gx + dx, Y = gy + dy;
if (X < 2 || Y < 2 || X >= GW - 2 || Y >= GH - 2) continue;
cur[Y * GW + X] += amp * Math.exp(-d2 * 0.4);
}
}
}
function nearestSrc(mx, my) {
let best = -1, bd = 40 * 40;
for (let i = 0; i < srcs.length; i++) {
const dx = mx - srcs[i].gx * W / GW, dy = my - srcs[i].gy * H / GH;
const d = dx * dx + dy * dy;
if (d < bd) { bd = d; best = i; }
}
return best;
}
function inRect(x, y, rx, ry, rw, rh) { return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh; }
function drawBtn(ctx, x, y, label, hot) {
ctx.fillStyle = hot ? 'rgba(90,200,255,0.85)' : 'rgba(0,0,0,0.6)';
ctx.fillRect(x, y, BTN, BTN);
ctx.strokeStyle = 'rgba(255,255,255,0.45)';
ctx.strokeRect(x + 0.5, y + 0.5, BTN - 1, BTN - 1);
ctx.fillStyle = '#fff';
ctx.font = 'bold 26px ui-sans-serif, system-ui';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(label, x + BTN / 2, y + BTN / 2);
}
function tick({ ctx, dt, width, height, input }) {
if (width !== W || height !== H) { W = width; H = height; }
const plusX = W - PAD - BTN, minusX = plusX - BTN - 8, btnY = H - PAD - BTN;
const hotMinus = input.mouseDown && inRect(input.mouseX, input.mouseY, minusX, btnY, BTN, BTN);
const hotPlus = input.mouseDown && inRect(input.mouseX, input.mouseY, plusX, btnY, BTN, BTN);
if (input.mouseDown && !wasDown && !hotMinus && !hotPlus) dragIdx = nearestSrc(input.mouseX, input.mouseY);
if (!input.mouseDown) dragIdx = -1;
wasDown = input.mouseDown;
if (dragIdx >= 0) {
srcs[dragIdx].gx = Math.max(4, Math.min(GW - 5, input.mouseX * GW / W));
srcs[dragIdx].gy = Math.max(4, Math.min(GH - 5, input.mouseY * GH / H));
}
for (const c of input.consumeClicks()) {
if (inRect(c.x, c.y, minusX, btnY, BTN, BTN)) freq = Math.max(1, freq - 0.25);
else if (inRect(c.x, c.y, plusX, btnY, BTN, BTN)) freq = Math.min(6, freq + 0.25);
else if (nearestSrc(c.x, c.y) < 0) splash(c.x * GW / W, c.y * GH / H, 3.2);
}
const n = Math.max(1, Math.min(8, Math.round(dt * SPS)));
for (let i = 0; i < n; i++) step();
for (let i = 0; i < GW * GH; i++) {
let v = cur[i];
if (v > 1) v = 1; else if (v < -1) v = -1;
const o = i << 2;
if (v >= 0) { px[o] = 20 + v * 60; px[o + 1] = 80 + v * 160; px[o + 2] = 160 + v * 95; }
else { px[o] = 20 + v * 15; px[o + 1] = 80 + v * 60; px[o + 2] = 160 + v * 95; }
}
bctx.putImageData(imgData, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(buf, 0, 0, W, H);
// source markers
for (let i = 0; i < srcs.length; i++) {
const sx = srcs[i].gx * W / GW, sy = srcs[i].gy * H / GH;
ctx.strokeStyle = dragIdx === i ? '#ffe27a' : 'rgba(255,255,255,0.85)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(sx, sy, 9, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(sx, sy, 2.5, 0, Math.PI * 2); ctx.stroke();
}
// HUD
const lamCells = CCELL / freq;
const dgx = srcs[0].gx - srcs[1].gx, dgy = srcs[0].gy - srcs[1].gy;
const sep = Math.sqrt(dgx * dgx + dgy * dgy);
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(PAD, PAD, 196, 78);
ctx.fillStyle = '#cfeaff';
ctx.font = '13px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
ctx.fillText(`f = ${freq.toFixed(2)} Hz`, PAD + 10, PAD + 20);
ctx.fillText(`lambda = ${(lamCells * W / GW).toFixed(0)} px`, PAD + 10, PAD + 38);
ctx.fillText(`d = ${(sep / lamCells).toFixed(2)} lambda`, PAD + 10, PAD + 56);
ctx.fillStyle = 'rgba(207,234,255,0.7)';
ctx.font = '11px monospace';
ctx.fillText('drag a source - tap to splash', PAD + 10, PAD + 72);
drawBtn(ctx, minusX, btnY, '−', hotMinus);
drawBtn(ctx, plusX, btnY, '+', hotPlus);
}
Comments (0)
Log in to comment.