12
Birthday Paradox Simulator
Tap + / − to change days per year
idle
143 lines · vanilla
view source
const DAY_OPTS = [30, 60, 100, 183, 365, 500, 1000];
const HMAX = 120, NB = 80, BTN = 44, PAD = 12, TAU = Math.PI * 2;
let W, H, days, daysIdx, hist, trials, match23;
let occGen, gen, occVis, visGen, people, nPeople, addAcc, flashT, flashDay;
let pNoMatch, pmf, theory23;
function computeTheory() {
pNoMatch[0] = 1; pNoMatch[1] = 1;
for (let n = 2; n <= HMAX; n++) {
pmf[n] = pNoMatch[n - 1] * Math.min(1, (n - 1) / days);
pNoMatch[n] = pNoMatch[n - 1] * Math.max(0, (days - (n - 1)) / days);
}
theory23 = 1 - pNoMatch[23];
}
function startTrial() { nPeople = 0; visGen++; flashT = 0; flashDay = -1; addAcc = 0; }
function record(n) { hist[Math.min(n, HMAX)]++; trials++; if (n <= 23) match23++; }
function silentTrial() {
gen++;
for (let n = 1; ; n++) {
const d = (Math.random() * days) | 0;
if (occGen[d] === gen || n >= HMAX) { record(n); return; }
occGen[d] = gen;
}
}
function addPerson() {
const d = (Math.random() * days) | 0;
people[nPeople++] = d;
if (occVis[d] === visGen || nPeople >= HMAX) { record(nPeople); flashT = 0.8; flashDay = d; }
else occVis[d] = visGen;
}
function setDays(idx) {
daysIdx = Math.max(0, Math.min(DAY_OPTS.length - 1, idx));
days = DAY_OPTS[daysIdx];
hist.fill(0); trials = 0; match23 = 0;
computeTheory();
startTrial();
for (let i = 0; i < 300; i++) silentTrial();
}
function init({ ctx, width, height }) {
W = width; H = height;
hist = new Int32Array(HMAX + 1);
occGen = new Int32Array(1000); gen = 0;
occVis = new Int32Array(1000); visGen = 0;
people = new Int16Array(HMAX + 1);
pNoMatch = new Float64Array(HMAX + 1);
pmf = new Float64Array(HMAX + 1);
setDays(4);
for (let i = 0; i < 10 && flashT <= 0; i++) addPerson();
}
function hit(px, py, x, y, w, h) { return px >= x && px <= x + w && py >= y && py <= y + h; }
function drawButton(ctx, x, y, label, hot) {
ctx.fillStyle = hot ? "rgba(255,160,60,0.85)" : "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(label, x + BTN / 2, y + BTN / 2);
ctx.textBaseline = "alphabetic";
}
function tick({ ctx, dt, time, width, height, input }) {
if (width !== W || height !== H) { W = width; H = height; }
const bx2 = W - PAD - BTN, bx1 = bx2 - BTN - 8, by = H - PAD - BTN;
for (const c of input.consumeClicks()) {
if (hit(c.x, c.y, bx1, by, BTN, BTN)) setDays(daysIdx - 1);
else if (hit(c.x, c.y, bx2, by, BTN, BTN)) setDays(daysIdx + 1);
}
for (let i = 0; i < 4; i++) silentTrial();
if (flashT > 0) { flashT -= dt; if (flashT <= 0) startTrial(); }
else { addAcc += dt * 40; while (addAcc >= 1 && flashT <= 0) { addAcc -= 1; addPerson(); } }
ctx.fillStyle = "#0b0e16"; ctx.fillRect(0, 0, W, H);
const histBottom = H - PAD - BTN - 10;
const portrait = H > W * 0.95;
let cx, cy, Rr, hx, hy, hw, hh;
if (portrait) {
cx = W / 2; cy = 110 + (H * 0.52 - 110) / 2;
Rr = Math.min(W / 2 - 26, (H * 0.52 - 110) / 2 - 8);
hx = PAD; hw = W - 2 * PAD; hy = H * 0.56; hh = histBottom - hy;
} else {
cx = W * 0.25; cy = H * 0.55;
Rr = Math.min(W * 0.24, H * 0.36) - 8;
hx = W * 0.52; hw = W - PAD - hx; hy = 118; hh = histBottom - hy;
}
ctx.strokeStyle = "rgba(255,255,255,0.18)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cx, cy, Rr, 0, TAU); ctx.stroke();
for (let i = 0; i < nPeople; i++) {
const d = people[i];
const a = (d / days) * TAU - Math.PI / 2;
const px = cx + Math.cos(a) * Rr, py = cy + Math.sin(a) * Rr;
if (flashT > 0 && d === flashDay) {
ctx.fillStyle = "#ff3b30";
ctx.beginPath(); ctx.arc(px, py, 5 + 3 * Math.sin(time * 18), 0, TAU); ctx.fill();
} else {
ctx.fillStyle = `hsl(${195 - i * 1.4}, 90%, 62%)`;
ctx.beginPath(); ctx.arc(px, py, 3, 0, TAU); ctx.fill();
}
}
ctx.fillStyle = flashT > 0 ? "#ff6b5e" : "#cfe3ff";
ctx.font = "bold 22px monospace"; ctx.textAlign = "center";
ctx.fillText(String(nPeople), cx, cy + 7);
ctx.font = "10px monospace"; ctx.fillStyle = "rgba(255,255,255,0.55)";
ctx.fillText(flashT > 0 ? "MATCH!" : "people", cx, cy + 22);
let ymax = 1;
for (let n = 2; n <= NB; n++) {
if (hist[n] > ymax) ymax = hist[n];
const e = trials * pmf[n];
if (e > ymax) ymax = e;
}
let med = 0, cum = 0;
for (let n = 2; n <= HMAX; n++) { cum += hist[n]; if (cum * 2 >= trials) { med = n; break; } }
ctx.fillStyle = "rgba(255,255,255,0.06)"; ctx.fillRect(hx, hy, hw, hh);
const bw = hw / (NB - 1);
for (let n = 2; n <= NB; n++) {
const bh = (hist[n] / ymax) * (hh - 14);
ctx.fillStyle = n === 23 ? "#ffb347" : n === med ? "#7fffd4" : "#3f6fd1";
ctx.fillRect(hx + (n - 2) * bw, hy + hh - bh, Math.max(1, bw - 0.5), bh);
}
ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 1.5;
ctx.beginPath();
for (let n = 2; n <= NB; n++) {
const py = hy + hh - ((trials * pmf[n]) / ymax) * (hh - 14);
const px = hx + (n - 2) * bw;
if (n === 2) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.6)"; ctx.font = "10px monospace";
ctx.textAlign = "left"; ctx.fillText("2", hx, hy + hh + 11);
ctx.textAlign = "center"; ctx.fillText("n=23", hx + 21 * bw, hy + hh + 11);
ctx.textAlign = "left"; ctx.fillText("group size at first shared birthday", hx + 2, hy - 4);
ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(PAD, PAD, 236, 86);
ctx.fillStyle = "#fff"; ctx.font = "13px monospace"; ctx.textAlign = "left";
ctx.fillText(`days=${days} trials=${trials}`, PAD + 10, PAD + 20);
ctx.fillText(`median group = ${med}`, PAD + 10, PAD + 38);
ctx.fillStyle = "#ffb347";
ctx.fillText(`P(match by 23) = ${(100 * match23 / Math.max(1, trials)).toFixed(1)}%`, PAD + 10, PAD + 56);
ctx.fillStyle = "#9fb6d4";
ctx.fillText(`theory = ${(100 * theory23).toFixed(1)}%`, PAD + 10, PAD + 74);
const hotM = input.mouseDown && hit(input.mouseX, input.mouseY, bx1, by, BTN, BTN);
const hotP = input.mouseDown && hit(input.mouseX, input.mouseY, bx2, by, BTN, BTN);
drawButton(ctx, bx1, by, "−", hotM);
drawButton(ctx, bx2, by, "+", hotP);
ctx.fillStyle = "rgba(255,255,255,0.55)"; ctx.font = "11px monospace"; ctx.textAlign = "right";
ctx.fillText("days/year", bx1 - 8, by + BTN / 2 + 4);
}
Comments (0)
Log in to comment.