$sloprun.dev

Birthday Paradox

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 5 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

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.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/birthday-paradox" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Birthday Paradox — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/birthday-paradox

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1127 lines 51.4 KB index.html
demos/birthday-paradox/index.html
<!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>&larr;</kbd><kbd>&rarr;</kbd> step through the room &middot; <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 &rarr; 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">&mdash;</span></div>
          <div class="stat"><span class="k">Measured</span><span class="v" id="sEmp">&mdash;</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">&mdash;</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> &mdash; 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 &mdash; 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 &middot; sloprun.dev</span>
    <span id="footNote">365 equally likely days &middot; 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 + " &middot; born <b>" + dateLabel(d) + "</b>"
        + (shared ? " &middot; <span class='amb'>shares this day</span>" : " &middot; alone on this day");
      return;
    }
    if (nd === 0) {
      el.innerHTML = "no two people share a day &mdash; 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(" &middot; ") + (nd > 3 ? " &middot; +" + (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 &middot; <b>"
      + pct(st.totHits / st.totRooms) + "%</b> shared &middot; 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 + " &rarr; " + 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 = " &times; <b>360</b>/365";
    else if (n > 6) tail = " &times; … &times; <b>" + (365 - (n - 1)) + "</b>/365";
    var f = "p = 1 &minus; (" + terms.join(" &times; ") + tail + ")";
    if (n === 2) f = "p = 1 &minus; <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 &mdash; 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> &mdash; 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> &mdash; 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 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong