Birthday Paradox
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Twenty-three people is enough for a coin flip that two of them share a birthday, and almost nobody believes it until they watch it happen. This drops a room of people onto a 365-day calendar ring, lights up the days two of them landed on, and marks your group size on the exact probability curve — beside the number that usually breaks the intuition: the chance someone matches your birthday specifically is only 5.86%, because nobody is the reference point and 23 people quietly make 253 pairs.
Try this first: press Run 1000 rooms and watch roughly half the thousand squares fill amber, landing a point or two either side of the theoretical 50.73%. Then drag the slider to 41 and run it again — the measured rate climbs the curve to 90%, and every run leaves a hollow ring on the chart so you can see the simulation tracking the maths the whole way up.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Birthday Paradox — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* explicit light branch so the platform's data-theme="light" always wins */
:root[data-theme="light"] {
--bg: #F6F6F3; --surface: #FFFFFF; --ink: #1A1D21; --muted: #5A6068;
--line: #E3E2DC; --accent: #C05600; --accent-soft: #FFF3E6;
--run: #1A7F37; --run-soft: #E7F4EA; --danger: #C93C37;
}
/* ── end tokens ───────────────────────────────────────────────────────────── */
:root { color-scheme: light; --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { color-scheme: dark; --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="light"] { color-scheme: light; --shadow: 0 1px 2px rgba(26,29,33,.05); }
:root[data-theme="dark"] { color-scheme: dark; --shadow: 0 1px 2px rgba(0,0,0,.35); }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap { max-width: 1160px; margin: 0 auto; padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 16px; }
header .top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
header .top > div { min-width: 0; }
@media (max-width: 560px) {
header .top > div { flex: 1 1 100%; order: 2; }
header .top .status { order: 1; }
}
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 72ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.status {
flex: none; display: inline-flex; align-items: center; gap: 7px;
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .09em;
padding: 5px 10px 5px 8px; border-radius: 999px; white-space: nowrap;
border: 1px solid var(--line); color: var(--muted); background: var(--surface);
}
.status .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
.status[data-state="run"] { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.status[data-state="ok"] { color: var(--run); background: var(--run-soft); border-color: transparent; }
.stage { display: grid; grid-template-columns: minmax(0,1fr) 318px; gap: 16px; align-items: start; }
.col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
/* single column: interleave the two stacks so the headline number and the
slider sit with the ring instead of below three full-height canvases */
@media (max-width: 960px) {
.stage { display: flex; flex-direction: column; gap: 16px; }
.col { display: contents; }
.s-ring { order: 1; }
.s-odds { order: 2; }
.s-controls { order: 3; }
.s-curve { order: 4; }
.s-rooms { order: 5; }
.s-why { order: 6; }
}
.card { background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 13px 14px; box-shadow: var(--shadow); }
.card h2 { margin: 0 0 11px; display: flex; align-items: baseline; justify-content: space-between;
gap: 10px; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted); font-weight: 600; }
.card h2 b { font-weight: 600; color: var(--ink); letter-spacing: .05em; white-space: nowrap; }
.card h2 b.amb { color: var(--accent); }
.cv { position: relative; width: 100%; }
.cv-ring { aspect-ratio: 1 / 1; max-height: 460px; margin: 0 auto; }
.cv-curve { height: 236px; }
.cv-rooms { height: 168px; }
@media (max-width: 520px) { .cv-curve { height: 200px; } .cv-rooms { height: 132px; } }
/* only the draggable chart swallows touch gestures; the others must let the page scroll */
canvas { display: block; width: 100%; height: 100%; touch-action: pan-y; }
canvas.pick { touch-action: none; }
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }
canvas.pick { cursor: crosshair; }
.tip { position: absolute; z-index: 5; pointer-events: none; transform: translate(-50%, -128%);
background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
padding: 5px 8px; box-shadow: 0 4px 14px rgba(0,0,0,.22); white-space: nowrap; }
.tip[hidden] { display: none; }
.tip .n { font-size: 12.5px; font-weight: 600; line-height: 1.25; }
.tip .d { font-family: var(--font-mono); font-size: 10px; color: var(--muted); margin-top: 2px; letter-spacing: .03em; }
.readout { margin: 9px 0 0; font-family: var(--font-mono); font-size: 11px; line-height: 1.6;
color: var(--muted); letter-spacing: .02em; min-height: 17px; }
.readout b { color: var(--ink); font-weight: 600; }
.readout .amb { color: var(--accent); font-weight: 600; }
.caption { margin: 9px 0 0; font-size: 12px; line-height: 1.55; color: var(--muted); }
.caption b { color: var(--ink); font-weight: 600; }
.caption .n { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--ink); }
.big { font-family: var(--font-mono); font-size: clamp(34px, 8vw, 46px); line-height: 1;
letter-spacing: -.03em; font-variant-numeric: tabular-nums; color: var(--accent); }
.big-sub { margin: 7px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--muted); }
.big-sub .mono { font-family: var(--font-mono); color: var(--ink); font-variant-numeric: tabular-nums; }
.stats { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 11px 10px; }
.stats.four { grid-template-columns: repeat(4, minmax(0,1fr)); }
@media (max-width: 460px) { .stats.four { grid-template-columns: repeat(2, minmax(0,1fr)); } }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v { font-family: var(--font-mono); font-size: 17px; line-height: 1.05; letter-spacing: -.02em;
font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stat .v .u { font-size: 10.5px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .k { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase;
color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.divider { border: 0; border-top: 1px solid var(--line); margin: 12px 0; }
.formula { margin: 0; font-family: var(--font-mono); font-size: 11px; color: var(--muted);
letter-spacing: .01em; line-height: 1.6; overflow-x: auto; }
.formula b { color: var(--ink); font-weight: 600; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-variant-numeric: tabular-nums; }
/* ticks sit at their true positions on the 2..100 track, not evenly spaced */
.scale { position: relative; height: 13px; font-family: var(--font-mono);
font-size: 9.5px; color: var(--muted); margin-top: 1px; }
.scale span { position: absolute; top: 0; transform: translateX(-50%); white-space: nowrap; }
.scale span.mark { color: var(--accent); }
input[type=range] { -webkit-appearance: none; appearance: none; display: block;
width: 100%; height: 22px; margin: 0; background: transparent; cursor: pointer; }
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; margin-top: -6px;
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line); }
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line); }
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
button { font: inherit; font-size: 13px; font-family: var(--font-sans); color: var(--ink);
background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
padding: 7px 11px; cursor: pointer; line-height: 1.2;
transition: border-color .12s ease, color .12s ease, background .12s ease; }
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .45; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
.row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 11px; }
.row > button { flex: 1 1 130px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.chips button { font-family: var(--font-mono); font-size: 11.5px; padding: 5px 9px; letter-spacing: .02em; flex: 0 0 auto; }
.chips button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.note { margin: 0; font-size: 13px; line-height: 1.6; color: var(--muted); }
.note + .note { margin-top: 9px; }
.note b { color: var(--ink); font-weight: 600; }
.note .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--ink); }
.note .amb { color: var(--accent); font-family: var(--font-mono); font-weight: 600; }
.pill { display: inline-flex; align-items: center; gap: 7px; margin-top: 11px; max-width: 100%;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .02em; line-height: 1.4;
padding: 5px 11px 5px 9px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--bg); }
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
.pill.ok { color: var(--run); background: var(--run-soft); border-color: transparent; }
.bar { height: 6px; border-radius: 999px; background: var(--line); overflow: hidden; margin-top: 11px; }
.bar i { display: block; height: 100%; width: 0%; background: var(--accent); border-radius: 999px;
transition: width .12s linear; }
.legend { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-top: 9px;
font-family: var(--font-mono); font-size: 10px; letter-spacing: .05em; color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend i { width: 10px; height: 10px; border-radius: 3px; background: var(--accent); flex: none; }
.legend i.no { background: var(--line); }
.legend i.hollow { border-radius: 50%; background: transparent; border: 1.5px solid var(--muted); }
.keys { margin: 10px 0 0; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.8; color: var(--muted); }
kbd { font-family: var(--font-mono); font-size: 10px; border: 1px solid var(--line);
border-radius: 4px; padding: 1px 4px; color: var(--ink); background: var(--bg); }
footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em;
display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding-top: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="top">
<div>
<h1>Birthday Paradox</h1>
<p class="lede">
Put <b>23 people</b> in a room and it is a coin flip that two of them share a birthday.
Nobody believes it. So: fill a room, watch where everyone lands on the calendar, then
run a thousand rooms and see whether reality agrees with the arithmetic.
</p>
</div>
<div class="status" id="status" data-state="idle"><span class="dot"></span><span id="statusText">READY</span></div>
</div>
</header>
<main class="stage">
<div class="col">
<section class="card s-ring" aria-label="One room on the calendar ring">
<h2><span>One room on the calendar ring</span><b id="ringTag">23 PEOPLE</b></h2>
<div class="cv cv-ring">
<canvas id="ring" tabindex="0"
aria-label="Calendar ring. Each dot is one person placed on their birthday; days shared by two or more people light up. Use the arrow keys to step through the people in the room."></canvas>
<div class="tip" id="ringTip" hidden aria-hidden="true">
<div class="n" id="tipName"></div>
<div class="d" id="tipDate"></div>
</div>
</div>
<p class="readout" id="ringRead"></p>
<p class="keys"><kbd>←</kbd><kbd>→</kbd> step through the room · <kbd>Esc</kbd> clear</p>
</section>
<section class="card s-curve" aria-label="Probability curve">
<h2><span>Chance of a shared birthday, 2 to 100 people</span><b id="curveTag" class="amb">n = 23 → 50.73%</b></h2>
<div class="cv cv-curve">
<canvas id="curve" class="pick" tabindex="0" role="slider"
aria-valuemin="2" aria-valuemax="100" aria-valuenow="23"
aria-valuetext="23 people, 50.73% chance of a shared birthday"
aria-label="Group size on the shared-birthday probability curve. Click, drag, or use the arrow keys to change the group size."></canvas>
</div>
<div class="legend">
<span><i></i>theory</span>
<span><i class="hollow"></i>measured runs</span>
<span>click or arrow-key the chart to move the group size</span>
</div>
</section>
<section class="card s-rooms" aria-label="A thousand simulated rooms">
<h2><span>1000 rooms, one square each</span><b id="roomsTag">n = 23</b></h2>
<div class="cv cv-rooms">
<canvas id="rooms" role="img" aria-label="A grid of one thousand simulated rooms; a filled square is a room where at least two people shared a birthday."></canvas>
</div>
<div class="bar" id="barWrap" role="presentation"><i id="bar"></i></div>
<div class="legend"><span><i></i>shared birthday</span><span><i class="no"></i>all different</span></div>
<hr class="divider">
<div class="stats four">
<div class="stat"><span class="k">Rooms with a match</span><span class="v" id="sHits">—</span></div>
<div class="stat"><span class="k">Measured</span><span class="v" id="sEmp">—</span></div>
<div class="stat"><span class="k">Theory</span><span class="v" id="sThe">50.73<span class="u">%</span></span></div>
<div class="stat"><span class="k">Gap</span><span class="v" id="sGap">—</span></div>
</div>
<div class="pill" id="verdict"><span class="dot"></span><span id="verdictText">Run a thousand rooms to test the number on the left.</span></div>
<p class="readout" id="totals"></p>
</section>
</div>
<div class="col">
<section class="card s-odds" aria-label="Probability for this group size">
<h2><span>The odds right now</span></h2>
<div class="big" id="bigP">50.73%</div>
<p class="big-sub">chance that at least two of the <span class="mono" id="subN">23</span> people share a birthday</p>
<hr class="divider">
<div class="stats">
<div class="stat"><span class="k">Pairs in the room</span><span class="v" id="sPairs">253</span></div>
<div class="stat"><span class="k">Matches your day</span><span class="v" id="sSelf">5.86<span class="u">%</span></span></div>
</div>
<hr class="divider">
<p class="formula" id="formula"></p>
</section>
<section class="card s-controls" aria-label="Controls">
<h2><span>Controls</span></h2>
<div class="ctl-top">
<label for="nRange">People in the room</label>
<span class="val" id="nOut">23</span>
</div>
<input type="range" id="nRange" min="2" max="100" step="1" value="23"
aria-valuetext="23 people, 50.73% chance of a shared birthday">
<div class="scale" aria-hidden="true">
<span style="left:calc(8px + (100% - 16px) * 0)">2</span>
<span class="mark" style="left:calc(8px + (100% - 16px) * .2143)">23</span>
<span style="left:calc(8px + (100% - 16px) * .5918)">60</span>
<span style="left:calc(8px + (100% - 16px) * 1)">100</span>
</div>
<div class="chips" id="chips" role="group" aria-label="Quick group sizes"></div>
<div class="row">
<button type="button" id="btnFill">Fill a new room</button>
<button type="button" id="btnRun" class="primary">Run 1000 rooms</button>
</div>
</section>
<section class="card s-why" aria-label="Why twenty-three">
<h2><span>Why 23 is the surprise</span></h2>
<p class="note">
Ask around and most people guess <b>about 180</b> — half of 365. That answers a
different question: how many strangers you need before one matches <b>your</b> birthday.
In this room that chance is only <span class="amb" id="nSelf">5.86%</span>.
</p>
<p class="note">
Nobody is the reference point. Every <em>pair</em> counts, and
<span class="mono" id="nN">23</span> people quietly make <span class="mono" id="nPairs">253</span>
pairs — each one its own little chance to collide. That is why the answer is
<b>23</b> and not 180.
</p>
<p class="note" id="nCmp"></p>
<div class="row"><button type="button" id="btnJump">Set the room to 23</button></div>
</section>
</div>
</main>
<footer>
<span>birthday-paradox · sloprun.dev</span>
<span id="footNote">365 equally likely days · no leap years, no twins</span>
</footer>
</div>
<p class="sr-only" id="live" aria-live="polite"></p>
<script>
(function () {
"use strict";
/* ═══════════════════════════════════════════════════════════════════════
SETUP
═══════════════════════════════════════════════════════════════════════ */
function $(id) { return document.getElementById(id); }
var root = document.documentElement;
var live = $("live");
var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)");
var DAYS = 365;
var ML = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
var MN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
var MSTART = (function () { var a = [], s = 0, i; for (i = 0; i < 12; i++) { a.push(s); s += ML[i]; } return a; })();
function dateLabel(d) {
var m = 11;
while (m > 0 && d < MSTART[m]) m--;
return MN[m] + " " + (d - MSTART[m] + 1);
}
/* the exact numbers */
/* p(no two share) computed directly, so the tiny tail at large n keeps its
precision instead of being eaten by 1 - (1 - x). */
function pNone(n) {
var p = 1, k;
for (k = 1; k < n; k++) p *= (DAYS - k) / DAYS;
return p;
}
function pShared(n) { return 1 - pNone(n); }
function pSelf(n) { return 1 - Math.pow((DAYS - 1) / DAYS, Math.max(0, n - 1)); }
function pairsOf(n) { return n * (n - 1) / 2; }
/* never let rounding claim a certainty the maths does not have: at n = 100 the
chance is 99.99997%, not 100%, and the no-match tail is not 0.00%. */
function pct(x, d) {
d = d === undefined ? 2 : d;
var s = (x * 100).toFixed(d), v = parseFloat(s);
if (v >= 100 && x < 1) return "> " + (100 - Math.pow(10, -d)).toFixed(d);
if (v === 0 && x > 0) return "< " + Math.pow(10, -d).toFixed(d);
return s;
}
/* deterministic opening room: seed 23 gives a room of 23 with exactly one
shared day (Dec 26), so the demo opens already making its point. */
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
var st = {
n: 23,
room: [],
dupDays: [],
animAt: 0,
animating: false,
cursor: -1,
running: false,
runs: [], /* {n, rate} for the curve */
cells: null, /* Uint8Array(1000) 0 none 1 match, 2 = not yet drawn */
shown: 0,
hits: 0,
totRooms: 0,
totHits: 0,
runN: null, /* group size the in-flight run is simulating */
lastRunN: null, /* group size the numbers on screen belong to */
verdict: { cls: "pill", text: "Run a thousand rooms to test the number on the left." }
};
/* ═══════════════════════════════════════════════════════════════════════
THEME + CANVAS PLUMBING
═══════════════════════════════════════════════════════════════════════ */
var C = {};
function readPalette() {
var cs = getComputedStyle(root);
function v(k, f) { var s = cs.getPropertyValue(k).trim(); return s || f; }
C.bg = v("--bg", "#F6F6F3");
C.surface = v("--surface", "#FFFFFF");
C.ink = v("--ink", "#1A1D21");
C.muted = v("--muted", "#5A6068");
C.line = v("--line", "#E3E2DC");
C.accent = v("--accent", "#C05600");
C.accentSoft = v("--accent-soft", "#FFF3E6");
C.mono = v("--font-mono", "monospace");
C.sans = v("--font-sans", "sans-serif");
}
function rgba(hex, a) {
var h = String(hex).trim();
if (h.charAt(0) !== "#") return h;
if (h.length === 4) h = "#" + h[1] + h[1] + h[2] + h[2] + h[3] + h[3];
var r = parseInt(h.substr(1, 2), 16), g = parseInt(h.substr(3, 2), 16), b = parseInt(h.substr(5, 2), 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) return h;
return "rgba(" + r + "," + g + "," + b + "," + a + ")";
}
function surf(canvas) {
var ctx = canvas.getContext("2d");
return { c: canvas, ctx: ctx, w: 0, h: 0 };
}
function fit(s) {
var r = s.c.getBoundingClientRect();
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var w = Math.max(1, Math.round(r.width)), h = Math.max(1, Math.round(r.height));
if (s.c.width !== Math.round(w * dpr) || s.c.height !== Math.round(h * dpr)) {
s.c.width = Math.round(w * dpr); s.c.height = Math.round(h * dpr);
}
s.w = w; s.h = h;
s.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
s.ctx.clearRect(0, 0, w, h);
}
var RG = surf($("ring")), CU = surf($("curve")), RM = surf($("rooms"));
/* ═══════════════════════════════════════════════════════════════════════
ROOM MODEL
═══════════════════════════════════════════════════════════════════════ */
function findDups(room) {
var by = {}, i, out = [], k;
for (i = 0; i < room.length; i++) {
k = room[i];
if (!by[k]) by[k] = [];
by[k].push(i);
}
for (k in by) if (by.hasOwnProperty(k) && by[k].length > 1) out.push({ day: +k, who: by[k] });
out.sort(function (a, b) { return a.day - b.day; });
return out;
}
function newRoom(n, rnd) {
var a = [], i, r = rnd || Math.random;
for (i = 0; i < n; i++) a.push(Math.floor(r() * DAYS));
return a;
}
function resizeRoom(n) {
if (n < st.room.length) st.room = st.room.slice(0, n);
else while (st.room.length < n) st.room.push(Math.floor(Math.random() * DAYS));
st.dupDays = findDups(st.room);
if (st.cursor >= st.room.length) st.cursor = -1;
}
function fillRoom(animate) {
st.room = newRoom(st.n);
st.dupDays = findDups(st.room);
st.cursor = -1;
hideTip();
if (animate && !(reduce && reduce.matches)) {
st.animAt = performance.now();
st.animating = true;
requestAnimationFrame(ringFrame);
} else {
st.animating = false;
drawRing(1);
}
ringReadout();
}
/* ═══════════════════════════════════════════════════════════════════════
RING
═══════════════════════════════════════════════════════════════════════ */
var ringGeom = { cx: 0, cy: 0, R: 0, pts: [] };
function angleOf(day) { return -Math.PI / 2 + (day / DAYS) * Math.PI * 2; }
function drawRing(prog) {
fit(RG);
var ctx = RG.ctx, w = RG.w, h = RG.h;
var cx = w / 2, cy = h / 2;
var pad = w < 300 ? 20 : 28;
var R = Math.max(30, Math.min(w, h) / 2 - pad);
ringGeom.cx = cx; ringGeom.cy = cy; ringGeom.R = R; ringGeom.pts = [];
var dupSet = {}, i, j;
for (i = 0; i < st.dupDays.length; i++) dupSet[st.dupDays[i].day] = true;
/* month bands + separators */
ctx.lineWidth = 1;
for (i = 0; i < 12; i++) {
var a0 = angleOf(MSTART[i]), a1 = angleOf(MSTART[i] + ML[i]);
ctx.beginPath();
ctx.arc(cx, cy, R + 6, a0 + 0.006, a1 - 0.006);
ctx.strokeStyle = i % 2 ? rgba(C.ink, .14) : rgba(C.ink, .30);
ctx.lineWidth = 2.5;
ctx.stroke();
/* label */
var am = angleOf(MSTART[i] + ML[i] / 2);
var lr = R + (w < 300 ? 15 : 19);
ctx.fillStyle = rgba(C.muted, .95);
ctx.font = (w < 300 ? "8.5px " : "9.5px ") + C.mono;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(w < 340 ? MN[i].charAt(0) : MN[i].toUpperCase(), cx + Math.cos(am) * lr, cy + Math.sin(am) * lr);
}
/* day ticks */
ctx.lineWidth = 1;
for (i = 0; i < DAYS; i++) {
var a = angleOf(i);
var isDup = dupSet[i];
var t0 = isDup ? R - 11 : R - 4.5, t1 = isDup ? R + 1 : R;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * t0, cy + Math.sin(a) * t0);
ctx.lineTo(cx + Math.cos(a) * t1, cy + Math.sin(a) * t1);
ctx.strokeStyle = isDup ? C.accent : rgba(C.ink, .16);
ctx.lineWidth = isDup ? 2.4 : 1;
ctx.stroke();
}
/* spokes for the shared days */
for (i = 0; i < st.dupDays.length; i++) {
var ad = angleOf(st.dupDays[i].day);
var g = ctx.createLinearGradient(
cx + Math.cos(ad) * R * 0.30, cy + Math.sin(ad) * R * 0.30,
cx + Math.cos(ad) * R, cy + Math.sin(ad) * R);
g.addColorStop(0, rgba(C.accent, 0));
g.addColorStop(1, rgba(C.accent, .38));
ctx.beginPath();
ctx.moveTo(cx + Math.cos(ad) * R * 0.30, cy + Math.sin(ad) * R * 0.30);
ctx.lineTo(cx + Math.cos(ad) * R, cy + Math.sin(ad) * R);
ctx.strokeStyle = g; ctx.lineWidth = 2.5; ctx.stroke();
}
/* people dots, stacked inward when they land on the same day */
var stack = {}, dotR = st.n > 70 ? 3.4 : (st.n > 40 ? 3.9 : 4.4);
if (R < 110) dotR = Math.max(2.6, dotR - 1);
var step = dotR * 2 + 2.4;
for (i = 0; i < st.room.length; i++) {
var day = st.room[i];
var k = stack[day] || 0; stack[day] = k + 1;
var ang = angleOf(day);
var rad = R - (dotR + 8) - k * step;
if (rad < R * 0.34) rad = R * 0.34;
var px = cx + Math.cos(ang) * rad, py = cy + Math.sin(ang) * rad;
ringGeom.pts.push({ x: px, y: py, i: i, day: day, dup: !!dupSet[day] });
/* staggered entrance */
var local = 1;
if (prog < 1) {
var t0p = (i / Math.max(1, st.room.length)) * 0.72;
local = (prog - t0p) / 0.28;
local = local < 0 ? 0 : (local > 1 ? 1 : local);
local = 1 - Math.pow(1 - local, 3);
}
if (local <= 0) continue;
var rr = dotR * (0.4 + 0.6 * local);
var isDup = !!dupSet[day];
if (isDup) {
ctx.beginPath(); ctx.arc(px, py, rr + 3.4, 0, Math.PI * 2);
ctx.fillStyle = rgba(C.accent, .18 * local); ctx.fill();
}
ctx.beginPath(); ctx.arc(px, py, rr, 0, Math.PI * 2);
ctx.fillStyle = isDup ? C.accent : rgba(C.ink, .48 * local + .1);
if (!isDup) ctx.fillStyle = rgba(C.ink, .52 * local);
ctx.globalAlpha = isDup ? local : 1;
ctx.fill();
ctx.globalAlpha = 1;
if (i === st.cursor) {
ctx.beginPath(); ctx.arc(px, py, rr + 4.5, 0, Math.PI * 2);
ctx.strokeStyle = C.ink; ctx.lineWidth = 1.6; ctx.stroke();
}
}
/* centre readout */
var nd = st.dupDays.length;
var big, small, key;
if (nd === 0) {
key = "NO SHARED DAY";
big = "all " + st.n + " different";
small = "chance of that: " + pct(pNone(st.n)) + "%";
} else if (nd === 1) {
key = "SHARED BIRTHDAY";
big = dateLabel(st.dupDays[0].day);
small = st.dupDays[0].who.length + " people landed here";
} else {
key = nd + " SHARED DAYS";
big = dateLabel(st.dupDays[0].day) + " +" + (nd - 1);
small = "and " + (nd - 1) + " more collision" + (nd > 2 ? "s" : "");
}
var maxW = R * 1.15;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillStyle = nd ? C.accent : rgba(C.muted, .95);
ctx.font = "9.5px " + C.mono;
ctx.fillText(key, cx, cy - (R < 120 ? 15 : 19), maxW);
ctx.fillStyle = C.ink;
ctx.font = "600 " + (R < 120 ? 15 : 20) + "px " + C.sans;
ctx.fillText(big, cx, cy + 1, maxW);
ctx.fillStyle = rgba(C.muted, .95);
ctx.font = "10px " + C.mono;
ctx.fillText(small, cx, cy + (R < 120 ? 17 : 21), maxW);
}
function ringFrame(now) {
var t = (now - st.animAt) / 900;
if (t >= 1) { st.animating = false; drawRing(1); return; }
drawRing(t);
requestAnimationFrame(ringFrame);
}
function ringReadout() {
var el = $("ringRead");
var nd = st.dupDays.length;
if (st.cursor >= 0 && st.cursor < st.room.length) {
var d = st.room[st.cursor];
var shared = false, i;
for (i = 0; i < st.dupDays.length; i++) if (st.dupDays[i].day === d) shared = true;
el.innerHTML = "person <b>" + (st.cursor + 1) + "</b> of " + st.n + " · born <b>" + dateLabel(d) + "</b>"
+ (shared ? " · <span class='amb'>shares this day</span>" : " · alone on this day");
return;
}
if (nd === 0) {
el.innerHTML = "no two people share a day — the <b>" + pct(pNone(st.n)) + "%</b> outcome for a room of " + st.n + ".";
} else {
var parts = [];
for (i = 0; i < nd && i < 3; i++) {
parts.push(dateLabel(st.dupDays[i].day) + " (" + st.dupDays[i].who.map(function (x) { return "#" + (x + 1); }).join(" + ") + ")");
}
el.innerHTML = "<span class='amb'>shared:</span> " + parts.join(" · ") + (nd > 3 ? " · +" + (nd - 3) + " more" : "");
}
}
/* ring pointer + keyboard */
var ringTip = $("ringTip");
function hideTip() { ringTip.hidden = true; }
function showTip(p) {
$("tipName").textContent = "person " + (p.i + 1);
$("tipDate").textContent = dateLabel(p.day).toUpperCase() + (p.dup ? " · SHARED" : "");
ringTip.style.left = p.x + "px";
ringTip.style.top = p.y + "px";
ringTip.hidden = false;
}
function nearest(x, y) {
var best = null, bd = 16 * 16, i, p, dx, dy, d;
for (i = 0; i < ringGeom.pts.length; i++) {
p = ringGeom.pts[i]; dx = p.x - x; dy = p.y - y; d = dx * dx + dy * dy;
if (d < bd) { bd = d; best = p; }
}
return best;
}
function ringPoint(e) {
var r = RG.c.getBoundingClientRect();
var p = nearest(e.clientX - r.left, e.clientY - r.top);
if (p) { showTip(p); st.cursor = p.i; } else { hideTip(); st.cursor = -1; }
if (!st.animating) drawRing(1);
ringReadout();
}
RG.c.addEventListener("pointermove", ringPoint);
/* touch never fires a hover, so a tap has to do the same job */
RG.c.addEventListener("pointerdown", ringPoint);
RG.c.addEventListener("pointerleave", function () {
hideTip(); st.cursor = -1; if (!st.animating) drawRing(1); ringReadout();
});
RG.c.addEventListener("keydown", function (e) {
var k = e.key, moved = true;
if (k === "ArrowRight" || k === "ArrowDown") st.cursor = (st.cursor + 1) % st.room.length;
else if (k === "ArrowLeft" || k === "ArrowUp") st.cursor = (st.cursor <= 0 ? st.room.length : st.cursor) - 1;
else if (k === "Home") st.cursor = 0;
else if (k === "End") st.cursor = st.room.length - 1;
else if (k === "Escape") st.cursor = -1;
else moved = false;
if (!moved) return;
e.preventDefault();
if (st.cursor >= 0) { drawRing(1); showTip(ringGeom.pts[st.cursor]); }
else { hideTip(); drawRing(1); }
ringReadout();
if (st.cursor >= 0) say("Person " + (st.cursor + 1) + ", born " + dateLabel(st.room[st.cursor]));
});
/* ═══════════════════════════════════════════════════════════════════════
CURVE
═══════════════════════════════════════════════════════════════════════ */
var cg = { l: 0, r: 0, t: 0, b: 0 };
function drawCurve() {
fit(CU);
var ctx = CU.ctx, w = CU.w, h = CU.h;
var L = w < 420 ? 32 : 38, Rp = 12, T = 12, B = 24;
cg.l = L; cg.r = w - Rp; cg.t = T; cg.b = h - B;
var pw = cg.r - L, ph = cg.b - T;
function X(n) { return L + (n - 2) / 98 * pw; }
function Y(p) { return T + (1 - p) * ph; }
/* grid */
ctx.font = "9.5px " + C.mono;
ctx.textBaseline = "middle"; ctx.textAlign = "right";
var gl = [0, .25, .5, .75, 1], i;
for (i = 0; i < gl.length; i++) {
var y = Y(gl[i]);
ctx.beginPath(); ctx.moveTo(L, y); ctx.lineTo(cg.r, y);
ctx.strokeStyle = rgba(C.ink, gl[i] === .5 ? .18 : .09);
ctx.lineWidth = 1;
ctx.setLineDash(gl[i] === .5 ? [3, 3] : []);
ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = rgba(C.muted, .95);
ctx.fillText((gl[i] * 100) + "%", L - 6, y);
}
ctx.textAlign = "center"; ctx.textBaseline = "top";
var xt = w < 420 ? [2, 23, 50, 75, 100] : [2, 10, 23, 40, 60, 80, 100];
for (i = 0; i < xt.length; i++) {
ctx.fillStyle = rgba(C.muted, .95);
ctx.fillText(String(xt[i]), X(xt[i]), cg.b + 6);
}
/* area + curve */
ctx.beginPath();
ctx.moveTo(X(2), Y(pShared(2)));
for (i = 3; i <= 100; i++) ctx.lineTo(X(i), Y(pShared(i)));
ctx.lineTo(X(100), cg.b); ctx.lineTo(X(2), cg.b); ctx.closePath();
ctx.fillStyle = rgba(C.accent, .12); ctx.fill();
ctx.beginPath();
ctx.moveTo(X(2), Y(pShared(2)));
for (i = 3; i <= 100; i++) ctx.lineTo(X(i), Y(pShared(i)));
ctx.strokeStyle = C.accent; ctx.lineWidth = 2; ctx.lineJoin = "round";
ctx.stroke();
/* the 23 marker, always visible */
if (st.n !== 23) {
var x23 = X(23), y23 = Y(pShared(23));
ctx.beginPath(); ctx.moveTo(x23, y23); ctx.lineTo(x23, cg.b);
ctx.strokeStyle = rgba(C.ink, .3); ctx.setLineDash([2, 3]); ctx.lineWidth = 1; ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(x23, y23, 3, 0, Math.PI * 2);
ctx.fillStyle = C.surface; ctx.fill();
ctx.strokeStyle = rgba(C.ink, .55); ctx.lineWidth = 1.4; ctx.stroke();
ctx.font = "9.5px " + C.mono; ctx.textAlign = "left"; ctx.textBaseline = "bottom";
ctx.fillStyle = rgba(C.muted, .95);
/* clear of the measured-run ring that often sits on this same point */
if (x23 < cg.r - 76) ctx.fillText("23 · 50.7%", x23 + 12, y23 - 7);
}
/* current point */
var p = pShared(st.n), px = X(st.n), py = Y(p);
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, cg.b);
ctx.strokeStyle = rgba(C.accent, .55); ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]); ctx.stroke();
ctx.beginPath(); ctx.moveTo(L, py); ctx.lineTo(px, py); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(px, py, 5.5, 0, Math.PI * 2);
ctx.fillStyle = C.accent; ctx.fill();
ctx.strokeStyle = C.surface; ctx.lineWidth = 2; ctx.stroke();
/* measured runs LAST: a run almost always lands on the group size you are
looking at, and underneath the current dot it would be invisible. */
for (i = 0; i < st.runs.length; i++) {
var rr = st.runs[i];
ctx.beginPath(); ctx.arc(X(rr.n), Y(rr.rate), 8, 0, Math.PI * 2);
ctx.strokeStyle = rgba(C.ink, .5); ctx.lineWidth = 1.5; ctx.stroke();
}
/* label near the point, kept inside the plot */
var txt = "n=" + st.n + " · " + pct(p, 1) + "%";
ctx.font = "600 10.5px " + C.mono;
var tw = ctx.measureText(txt).width + 12, th = 17;
var bx = px + 10, by = py - th - 9;
if (bx + tw > cg.r) bx = px - 10 - tw;
if (by < T) by = py + 9;
ctx.fillStyle = C.surface;
ctx.strokeStyle = rgba(C.accent, .55); ctx.lineWidth = 1;
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(bx, by, tw, th, 5); ctx.fill(); ctx.stroke(); }
else { ctx.fillRect(bx, by, tw, th); ctx.strokeRect(bx, by, tw, th); }
ctx.fillStyle = C.accent; ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(txt, bx + tw / 2, by + th / 2 + .5);
}
function curveSetFromX(clientX) {
var r = CU.c.getBoundingClientRect();
var x = clientX - r.left;
var pw = cg.r - cg.l;
var n = Math.round(2 + (x - cg.l) / pw * 98);
n = Math.max(2, Math.min(100, n));
if (n !== st.n) setN(n);
}
var dragging = false;
CU.c.addEventListener("pointerdown", function (e) {
dragging = true;
if (CU.c.setPointerCapture) { try { CU.c.setPointerCapture(e.pointerId); } catch (err) {} }
curveSetFromX(e.clientX);
/* preventDefault would otherwise swallow the focus, stranding arrow keys */
e.preventDefault();
CU.c.focus();
});
CU.c.addEventListener("pointermove", function (e) { if (dragging) curveSetFromX(e.clientX); });
window.addEventListener("pointerup", function () { dragging = false; });
window.addEventListener("pointercancel", function () { dragging = false; });
CU.c.addEventListener("keydown", function (e) {
var k = e.key, n = st.n;
if (k === "ArrowRight" || k === "ArrowUp") n += (e.shiftKey ? 10 : 1);
else if (k === "ArrowLeft" || k === "ArrowDown") n -= (e.shiftKey ? 10 : 1);
else if (k === "Home") n = 2;
else if (k === "End") n = 100;
else return;
e.preventDefault();
setN(n);
});
/* ═══════════════════════════════════════════════════════════════════════
1000 ROOMS
═══════════════════════════════════════════════════════════════════════ */
var TOTAL = 1000, COLS = 50, ROWS = 20;
function drawRooms() {
fit(RM);
var ctx = RM.ctx, w = RM.w, h = RM.h;
var gap = w < 420 ? 1 : 1.5;
var cw = (w - gap * (COLS - 1)) / COLS;
var ch = (h - gap * (ROWS - 1)) / ROWS;
var rad = Math.min(2, cw / 3);
var i, r, c, x, y, v;
for (i = 0; i < TOTAL; i++) {
r = Math.floor(i / COLS); c = i % COLS;
x = c * (cw + gap); y = r * (ch + gap);
v = st.cells && i < st.shown ? st.cells[i] : 2;
ctx.fillStyle = v === 1 ? C.accent : (v === 0 ? rgba(C.ink, .13) : rgba(C.ink, .05));
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(x, y, Math.max(1, cw), Math.max(1, ch), rad); ctx.fill(); }
else ctx.fillRect(x, y, Math.max(1, cw), Math.max(1, ch));
}
}
function simulate(n) {
var cells = new Uint8Array(TOTAL), hits = 0, i, j, seen, day, hit;
for (i = 0; i < TOTAL; i++) {
seen = {}; hit = 0;
for (j = 0; j < n; j++) {
day = (Math.random() * DAYS) | 0;
if (seen[day]) { hit = 1; break; }
seen[day] = 1;
}
cells[i] = hit;
hits += hit;
}
return { cells: cells, hits: hits };
}
function setStatus(state, text) {
$("status").setAttribute("data-state", state);
$("statusText").textContent = text;
}
function runRooms() {
if (st.running) return;
st.running = true;
$("btnRun").disabled = true;
$("btnFill").disabled = true;
setStatus("run", "RUNNING 1000 ROOMS");
/* lock the group size for the whole run: the slider stays live, and every
number this card reports must belong to the n we actually simulated. */
var runN = st.n;
st.runN = runN;
if (st.lastRunN !== runN) { st.totRooms = 0; st.totHits = 0; }
st.lastRunN = runN;
$("roomsTag").textContent = "n = " + runN;
var res = simulate(runN);
st.cells = res.cells; st.hits = res.hits; st.shown = 0;
var instant = reduce && reduce.matches;
var perFrame = instant ? TOTAL : 40;
function step() {
st.shown = Math.min(TOTAL, st.shown + perFrame);
drawRooms();
var done = 0, i;
for (i = 0; i < st.shown; i++) done += st.cells[i];
$("bar").style.width = (st.shown / TOTAL * 100) + "%";
$("sHits").innerHTML = done + " <span class='u'>/ " + st.shown + "</span>";
$("sEmp").innerHTML = pct(done / Math.max(1, st.shown)) + "<span class='u'>%</span>";
if (st.shown < TOTAL) { requestAnimationFrame(step); return; }
finishRun();
}
if (instant) { step(); } else { requestAnimationFrame(step); }
}
function finishRun() {
var n = st.runN, hits = st.hits;
var emp = hits / TOTAL, th = pShared(n);
var gap = (emp - th) * 100;
var sd = Math.sqrt(th * (1 - th) / TOTAL) * 100;
st.totRooms += TOTAL; st.totHits += hits;
st.runs.push({ n: n, rate: emp });
if (st.runs.length > 60) st.runs.shift();
$("sHits").innerHTML = hits + " <span class='u'>/ 1000</span>";
$("sEmp").innerHTML = pct(emp) + "<span class='u'>%</span>";
$("sThe").innerHTML = pct(th) + "<span class='u'>%</span>";
$("sGap").innerHTML = (gap >= 0 ? "+" : "−") + Math.abs(gap).toFixed(2) + "<span class='u'>pts</span>";
$("roomsTag").textContent = "n = " + n;
/* the 2σ band collapses below 0.1 pt at the ends of the range, so keep
enough decimals for it to stay a number instead of "±0.0" */
var band = 2 * sd, bandTxt = band < 0.1 ? band.toFixed(3) : band.toFixed(1);
if (Math.abs(gap) <= band) {
st.verdict = { cls: "pill ok", text: "measured " + pct(emp) + "% vs theory " + pct(th) + "% — inside the ±" + bandTxt + " pt noise band. The math holds." };
} else {
st.verdict = { cls: "pill", text: "measured " + pct(emp) + "% vs theory " + pct(th) + "% — outside ±" + bandTxt + " pts, which is roughly a 1-in-20 run. Run it again." };
}
$("totals").innerHTML = "all runs at n=" + n + ": <b>" + st.totRooms.toLocaleString() + "</b> rooms · <b>"
+ pct(st.totHits / st.totRooms) + "%</b> shared · theory <b>" + pct(th) + "%</b>";
setStatus("ok", "RAN 1000 ROOMS");
st.running = false;
st.runN = null;
$("btnRun").disabled = false;
$("btnFill").disabled = false;
syncRunCard();
drawCurve();
say("Ran a thousand rooms of " + n + ". " + hits + " had a shared birthday, " + pct(emp) + " percent, against a theory of " + pct(th) + " percent.");
}
/* The four stats, the grid and the verdict all describe ONE run at lastRunN.
They are never mixed with the live slider value: if the two drift apart the
card says so instead of pairing a stale measurement with a fresh theory. */
function syncRunCard() {
var v = $("verdict"), vt = $("verdictText");
if (!st.running && st.lastRunN !== null && st.lastRunN !== st.n) {
v.className = "pill";
vt.textContent = "these numbers are the run at n = " + st.lastRunN + ". Run 1000 rooms again to test n = " + st.n + ".";
} else {
v.className = st.verdict.cls;
vt.textContent = st.verdict.text;
}
}
/* ═══════════════════════════════════════════════════════════════════════
WIRING
═══════════════════════════════════════════════════════════════════════ */
function setN(n) {
n = Math.max(2, Math.min(100, Math.round(n)));
st.n = n;
$("nRange").value = String(n);
resizeRoom(n);
if (st.cursor < 0) hideTip();
syncNumbers();
syncRunCard();
drawRing(1);
drawCurve();
ringReadout();
updateChips();
}
function syncNumbers() {
var n = st.n, p = pShared(n), ps = pSelf(n);
$("nOut").textContent = String(n);
$("nRange").setAttribute("aria-valuetext", n + " people, " + pct(p) + "% chance of a shared birthday");
$("bigP").textContent = pct(p) + "%";
$("subN").textContent = String(n);
$("sPairs").textContent = pairsOf(n).toLocaleString();
$("sSelf").innerHTML = pct(ps) + "<span class='u'>%</span>";
$("ringTag").textContent = n + " PEOPLE";
$("curveTag").innerHTML = "n = " + n + " → " + pct(p) + "%";
CU.c.setAttribute("aria-valuenow", String(n));
CU.c.setAttribute("aria-valuetext", n + " people, " + pct(p) + "% chance of a shared birthday");
$("nSelf").textContent = pct(ps) + "%";
$("nN").textContent = String(n);
$("nPairs").textContent = pairsOf(n).toLocaleString();
$("btnJump").disabled = (n === 23);
$("btnJump").textContent = n === 23 ? "You are at 23" : "Set the room to 23";
var terms = [];
var i;
for (i = 1; i < Math.min(n, 5); i++) terms.push("<b>" + (365 - i) + "</b>/365");
/* only show an ellipsis when terms really were skipped: at n = 6 the last
factor is the next one along, so print it plainly. */
var tail = "";
if (n === 6) tail = " × <b>360</b>/365";
else if (n > 6) tail = " × … × <b>" + (365 - (n - 1)) + "</b>/365";
var f = "p = 1 − (" + terms.join(" × ") + tail + ")";
if (n === 2) f = "p = 1 − <b>364</b>/365";
$("formula").innerHTML = f;
var cmp;
if (n < 23) cmp = "At <span class='mono'>" + n + "</span> the room is still under even odds — push the slider up to <span class='mono'>23</span> and watch it cross 50%.";
else if (n === 23) cmp = "At <span class='mono'>23</span> it is <span class='mono'>50.73%</span>: closer to a coin flip than any other room size.";
else if (n < 60) cmp = "At <span class='mono'>" + n + "</span> the odds are already <span class='mono'>" + pct(p, 1) + "%</span> — a room this size almost always has a pair.";
/* past ~60 the miss chance rounds to 0.000% — say it as odds instead of a dead zero */
else cmp = "At <span class='mono'>" + n + "</span> a room with no shared birthday is about <span class='mono'>1 in "
+ Math.round(1 / pNone(n)).toLocaleString() + "</span> — a freak event.";
$("nCmp").innerHTML = cmp;
}
var CHIPS = [5, 10, 23, 41, 70];
function buildChips() {
var box = $("chips"), i;
for (i = 0; i < CHIPS.length; i++) {
(function (v) {
var b = document.createElement("button");
b.type = "button";
b.textContent = v + (v === 23 ? " ★" : "");
b.setAttribute("aria-pressed", "false");
b.setAttribute("aria-label", v + " people");
b.addEventListener("click", function () { setN(v); say(v + " people, " + pct(pShared(v)) + " percent chance of a shared birthday."); });
box.appendChild(b);
})(CHIPS[i]);
}
}
function updateChips() {
var bs = $("chips").children, i;
for (i = 0; i < bs.length; i++) bs[i].setAttribute("aria-pressed", CHIPS[i] === st.n ? "true" : "false");
}
function say(m) { live.textContent = m; }
$("nRange").addEventListener("input", function () { setN(+this.value); });
$("btnFill").addEventListener("click", function () {
fillRoom(true);
var nd = st.dupDays.length;
say(nd ? ("New room of " + st.n + ". " + (nd === 1 ? "One shared day: " + dateLabel(st.dupDays[0].day) : nd + " shared days") + ".")
: ("New room of " + st.n + ". No two people share a day."));
});
$("btnRun").addEventListener("click", runRooms);
$("btnJump").addEventListener("click", function () { setN(23); say("Group size set to 23."); $("nRange").focus(); });
/* theme: platform postMessage, plus standalone prefers-color-scheme */
function repaint() { readPalette(); drawRing(st.animating ? 0 : 1); drawCurve(); drawRooms(); }
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
repaint();
}
});
if (window.matchMedia) {
var mq = window.matchMedia("(prefers-color-scheme: dark)");
if (mq.addEventListener) mq.addEventListener("change", repaint);
else if (mq.addListener) mq.addListener(repaint);
}
var rt = null;
function onResize() {
if (rt) clearTimeout(rt);
rt = setTimeout(function () { rt = null; repaint(); }, 80);
}
if (window.ResizeObserver) new ResizeObserver(onResize).observe(document.body);
else window.addEventListener("resize", onResize);
/* ═══════════════════════════════════════════════════════════════════════
BOOT — opens with a real room, a marked curve, and a finished run
═══════════════════════════════════════════════════════════════════════ */
readPalette();
buildChips();
st.room = newRoom(23, mulberry32(23)); /* one shared day: Dec 26 */
st.dupDays = findDups(st.room);
syncNumbers();
updateChips();
drawRing(1);
drawCurve();
drawRooms();
ringReadout();
runRooms();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.