4

Buoyancy Lab: Float, Sink, Archimedes

drag blocks into the tank · +/− changes the fluid density

A buoyancy simulator built on Archimedes' principle: each block feels its weight against the buoyant force , so whether it floats or sinks — and how deep it rides — comes straight from the density ratio. Drag wood, ice, plastic or aluminum into the water and watch them splash, bob and settle at their true submerged fraction (ice floats 92% underwater). Use +/− to swap the fluid for salt water or mercury, where even aluminum floats.

idle
133 lines · vanilla
view source
const BTN = 44, PAD = 12, NC = 96, GPX = 520, NB = 4, S = 48;
const MATS = [["wood", 0.60, "#b5854f"], ["ice", 0.92, "#cdeaf6"], ["plastic", 0.95, "#d95fa2"], ["aluminum", 2.70, "#a8b2bd"]];
const FLUIDS = [["fresh water", 1.00, "55,135,225", 0.45], ["salt water", 1.03, "45,160,200", 0.45],
  ["Dead Sea brine", 1.24, "70,175,185", 0.5], ["glycerin", 1.26, "175,150,75", 0.55], ["mercury", 13.6, "190,198,210", 0.88]];
let W, H, hgt, vel, fluidI, bx, by, bvx, bvy, pf, held, sel, wasDown, pmx, pmy;
let waterY, floorY, tankL, tankR;

function init({ width, height }) {
  W = width; H = height; layout();
  hgt = new Float32Array(NC); vel = new Float32Array(NC);
  fluidI = 0; held = -1; sel = 0; wasDown = false; pmx = 0; pmy = 0;
  bx = new Float32Array(NB); by = new Float32Array(NB);
  bvx = new Float32Array(NB); bvy = new Float32Array(NB); pf = new Float32Array(NB);
  for (let i = 0; i < NB; i++) {
    bx[i] = W * (0.2 + 0.2 * i);
    const fr = Math.min(1, MATS[i][1] / FLUIDS[0][1]);
    by[i] = fr >= 1 ? floorY - S / 2 : waterY - S / 2 + fr * S;
    bvx[i] = 0; bvy[i] = 0; pf[i] = fr;
  }
  for (let i = 0; i < NC; i++) vel[i] = (Math.random() - 0.5) * 8;
}
function layout() { waterY = H * 0.44; floorY = H - 16; tankL = 8; tankR = W - 8; }
function surfAt(x, time) {
  let c = ((x - tankL) / (tankR - tankL) * NC) | 0;
  if (c < 0) c = 0; if (c > NC - 1) c = NC - 1;
  return waterY + hgt[c] + Math.sin(time * 1.4 + x * 0.025) * 2.5;
}
function splash(x, amt) {
  const c = ((x - tankL) / (tankR - tankL) * NC) | 0;
  for (let o = -3; o <= 3; o++) {
    const j = c + o;
    if (j >= 0 && j < NC) vel[j] += amt * (1 - Math.abs(o) / 4);
  }
}
function arrow(ctx, x, y0, y1, color) {
  const d = y1 > y0 ? 1 : -1;
  ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 3;
  ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y1); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(x, y1 + d * 8); ctx.lineTo(x - 6, y1); ctx.lineTo(x + 6, y1); ctx.closePath(); ctx.fill();
}
function tick({ ctx, dt, time, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; layout(); }
  const mx = input.mouseX, my = input.mouseY, down = input.mouseDown;
  const dtc = Math.min(dt, 0.033) || 0.016;
  const mX = W - PAD - BTN, pX = mX, mY = H - PAD - 2 * BTN - 8, pY = H - PAD - BTN;
  for (const c of input.consumeClicks()) {
    if (c.x >= mX && c.x <= mX + BTN && c.y >= mY && c.y <= mY + BTN) fluidI = Math.max(0, fluidI - 1);
    else if (c.x >= pX && c.x <= pX + BTN && c.y >= pY && c.y <= pY + BTN) fluidI = Math.min(FLUIDS.length - 1, fluidI + 1);
  }
  if (down && !wasDown && held < 0)
    for (let i = NB - 1; i >= 0; i--)
      if (Math.abs(mx - bx[i]) < S / 2 + 10 && Math.abs(my - by[i]) < S / 2 + 10) { held = i; sel = i; break; }
  if (!down) held = -1;
  const rf = FLUIDS[fluidI][1];
  for (let i = 0; i < NB; i++) {
    if (i === held) {
      bvx[i] = Math.max(-600, Math.min(600, (mx - pmx) / dtc));
      bvy[i] = Math.max(-600, Math.min(600, (my - pmy) / dtc));
      bx[i] = Math.max(tankL + S / 2, Math.min(tankR - S / 2, mx));
      by[i] = Math.max(S / 2, Math.min(floorY - S / 2, my));
      pf[i] = Math.max(0, Math.min(1, (by[i] + S / 2 - surfAt(bx[i], time)) / S));
      continue;
    }
    const surf = surfAt(bx[i], time);
    const fr = Math.max(0, Math.min(1, (by[i] + S / 2 - surf) / S));
    if (pf[i] === 0 && fr > 0 && Math.abs(bvy[i]) > 60) splash(bx[i], bvy[i] * 0.05);
    if (fr > 0 && fr < 1) splash(bx[i], bvy[i] * dtc * 0.6);
    pf[i] = fr;
    bvy[i] += GPX * (1 - rf * fr / MATS[i][1]) * dtc;
    const damp = Math.exp(-(0.1 + fr * 4.5) * dtc);
    bvy[i] *= damp; bvx[i] *= damp;
    bx[i] += bvx[i] * dtc; by[i] += bvy[i] * dtc;
    if (bx[i] < tankL + S / 2) { bx[i] = tankL + S / 2; bvx[i] = 0; }
    if (bx[i] > tankR - S / 2) { bx[i] = tankR - S / 2; bvx[i] = 0; }
    if (by[i] > floorY - S / 2) { by[i] = floorY - S / 2; bvy[i] = 0; }
    if (by[i] < S / 2) { by[i] = S / 2; bvy[i] = 0; }
  }
  pmx = mx; pmy = my; wasDown = down;
  for (let i = 0; i < NC; i++) {
    const l = hgt[i > 0 ? i - 1 : i], r = hgt[i < NC - 1 ? i + 1 : i];
    vel[i] += ((l + r) * 0.5 - hgt[i]) * 0.3; vel[i] *= 0.982;
  }
  for (let i = 0; i < NC; i++) { hgt[i] += vel[i]; if (hgt[i] > 30) hgt[i] = 30; if (hgt[i] < -30) hgt[i] = -30; }

  ctx.fillStyle = "#0a0e18"; ctx.fillRect(0, 0, W, H);
  ctx.textAlign = "center"; ctx.textBaseline = "middle";
  for (let i = 0; i < NB; i++) {
    ctx.fillStyle = MATS[i][2];
    ctx.fillRect(bx[i] - S / 2, by[i] - S / 2, S, S);
    ctx.strokeStyle = i === sel ? "#ffd24d" : "rgba(0,0,0,0.4)";
    ctx.lineWidth = i === sel ? 3 : 1.5;
    ctx.strokeRect(bx[i] - S / 2, by[i] - S / 2, S, S);
    ctx.fillStyle = "#10141c"; ctx.font = "bold 12px monospace";
    ctx.fillText(MATS[i][1].toFixed(2), bx[i], by[i] - 6);
    ctx.font = "9px monospace"; ctx.fillText(MATS[i][0], bx[i], by[i] + 9);
  }
  const fc = FLUIDS[fluidI];
  ctx.fillStyle = "rgba(" + fc[2] + "," + fc[3] + ")";
  ctx.beginPath(); ctx.moveTo(tankL, floorY);
  for (let i = 0; i < NC; i++)
    ctx.lineTo(tankL + (i / (NC - 1)) * (tankR - tankL), surfAt(tankL + (i / (NC - 1)) * (tankR - tankL), time));
  ctx.lineTo(tankR, floorY); ctx.closePath(); ctx.fill();
  ctx.strokeStyle = "rgba(" + fc[2] + ",0.95)"; ctx.lineWidth = 2;
  ctx.beginPath();
  for (let i = 0; i < NC; i++) {
    const x = tankL + (i / (NC - 1)) * (tankR - tankL), y = surfAt(x, time);
    if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
  }
  ctx.stroke();
  ctx.strokeStyle = "#39476b"; ctx.lineWidth = 4;
  ctx.strokeRect(tankL, 4, tankR - tankL, floorY - 4);
  const wF = 46, bF = wF * Math.min(2.2, rf * pf[sel] / MATS[sel][1]);
  arrow(ctx, bx[sel] - 10, by[sel], by[sel] + wF, "#ff5560");
  if (bF > 2) arrow(ctx, bx[sel] + 10, by[sel], by[sel] - bF, "#39e0e8");
  ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(PAD, PAD, 235, 70);
  ctx.fillStyle = "#fff"; ctx.font = "13px monospace"; ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
  ctx.fillText("fluid: " + fc[0] + "  ρ=" + fc[1].toFixed(2), PAD + 10, PAD + 20);
  ctx.fillText("block: " + MATS[sel][0] + "  ρ=" + MATS[sel][1].toFixed(2), PAD + 10, PAD + 38);
  ctx.fillText("submerged: " + (pf[sel] * 100).toFixed(0) + "%", PAD + 10, PAD + 56);
  ctx.font = "10px monospace"; ctx.fillStyle = "rgba(255,255,255,0.7)";
  ctx.fillText("drag a block · +/− change fluid", PAD, PAD + 84);
  for (let b = 0; b < 2; b++) {
    const x = b ? pX : mX, y = b ? pY : mY;
    ctx.fillStyle = "rgba(0,0,0,0.65)"; ctx.fillRect(x, y, BTN, BTN);
    ctx.strokeStyle = "rgba(255,255,255,0.45)"; ctx.lineWidth = 1;
    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(b ? "+" : "−", x + BTN / 2, y + BTN / 2);
  }
  ctx.font = "10px monospace"; ctx.textAlign = "right"; ctx.textBaseline = "alphabetic";
  ctx.fillStyle = "rgba(255,255,255,0.7)";
  ctx.fillText("fluid ρ", mX - 6, pY + 4);
  ctx.textAlign = "left";
}

Comments (0)

Log in to comment.