15

Ripple Tank: Interference in 2D

drag a source, tap to splash, +/- changes frequency

A ripple tank simulation: two point sources oscillate on a 2D water surface governed by the wave equation , and their overlapping circular wavefronts carve a fence of nodal lines where crest meets trough and the water stays still. Watch the bright cyan crests interleave with deep-blue troughs, and count how the fringe spacing tightens as frequency rises. Drag either source to swing the interference pattern, tap anywhere to splash an extra ripple, and use the on-canvas +/- buttons to tune the frequency while the HUD tracks wavelength and source separation in wavelengths.

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.