<p class="hint">Flip batches of fair coins and watch the running proportion of heads
converge toward <strong>0.5</strong>. The gray band shows ±1 standard deviation
(σ = √(p(1−p)/n)). Early flips swing wildly; the band tightens with every batch.</p>
<canvas id="chart" width="520" height="220"></canvas>
<div class="stats" id="stats">Flips: 0 | Heads: 0 | Proportion: —</div>
<div class="btns">
<button id="btn10" type="button">+10 flips</button>
<button id="btn100" type="button">+100 flips</button>
<button id="btn500" type="button">+500 flips</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; width: 100%; max-width: 520px; border: 1px solid #dde3ea;
border-radius: 10px; background: #f7f9fb; }
.stats { font-size: .92rem; margin: .5rem 0; color: #333; min-height: 1.4em; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .4rem; }
button { font: 600 14px system-ui, sans-serif; padding: .42rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Law of Large Numbers demo: flip fair coins, track running proportion of heads.
// After n flips the proportion X̄ₙ = (# heads) / n.
// LLN guarantees X̄ₙ → 0.5 (the expected value) as n → ∞.
// The gray band is ±1 SD = ±√(0.25/n) — shrinks like 1/√n.
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 18, right: 16, bottom: 28, left: 42 };
const PW = W - PAD.left - PAD.right;
const PH = H - PAD.top - PAD.bottom;
let totalFlips = 0, totalHeads = 0;
// Store (n, proportion) samples to plot
const samples = []; // {n, p}
function seeded() {
// fast xorshift32 — deterministic enough for a demo
// We just use Math.random for simplicity
return Math.random() < 0.5 ? 1 : 0;
}
function flip(n) {
for (let i = 0; i < n; i++) {
totalFlips++;
totalHeads += seeded();
// Record every flip up to 50, then thin out
if (totalFlips <= 50 || totalFlips % Math.ceil(totalFlips / 200) === 0) {
samples.push({ n: totalFlips, p: totalHeads / totalFlips });
}
}
// Always record the final point
samples.push({ n: totalFlips, p: totalHeads / totalFlips });
draw();
updateStats();
}
function updateStats() {
const p = totalFlips > 0 ? (totalHeads / totalFlips).toFixed(4) : '—';
document.getElementById('stats').innerHTML =
fmt('Flips: <b>%totalFlips%</b> | Heads: <b>%totalHeads%</b> | Proportion: <b>%p%</b>', { totalFlips: totalFlips, totalHeads: totalHeads, p: p });
}
function xFor(n) {
if (samples.length < 2) return PAD.left;
const maxN = samples[samples.length - 1].n;
return PAD.left + (n / maxN) * PW;
}
function yFor(p) {
// p in [0,1], map to canvas y (0=top, H=bottom)
// show range [0,1]
return PAD.top + PH * (1 - p);
}
function draw() {
ctx.clearRect(0, 0, W, H);
if (samples.length === 0) { drawAxes(); return; }
const maxN = samples[samples.length - 1].n;
// --- SD band ---
ctx.beginPath();
// upper band
for (let i = 0; i < samples.length; i++) {
const s = samples[i];
const sd = Math.sqrt(0.25 / s.n);
const x = PAD.left + (s.n / maxN) * PW;
const y = yFor(Math.min(1, 0.5 + sd));
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
// lower band (reverse)
for (let i = samples.length - 1; i >= 0; i--) {
const s = samples[i];
const sd = Math.sqrt(0.25 / s.n);
const x = PAD.left + (s.n / maxN) * PW;
const y = yFor(Math.max(0, 0.5 - sd));
ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fillStyle = 'rgba(100,160,220,0.15)';
ctx.fill();
// --- 0.5 reference line ---
ctx.beginPath();
ctx.moveTo(PAD.left, yFor(0.5));
ctx.lineTo(PAD.left + PW, yFor(0.5));
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.2;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
// --- proportion curve ---
ctx.beginPath();
for (let i = 0; i < samples.length; i++) {
const s = samples[i];
const x = PAD.left + (s.n / maxN) * PW;
const y = yFor(s.p);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 1.8;
ctx.stroke();
drawAxes(maxN);
// label 0.5 line
ctx.fillStyle = '#e63946';
ctx.font = '11px system-ui, sans-serif';
ctx.fillText('0.5', PAD.left + PW + 2, yFor(0.5) + 4);
}
function drawAxes(maxN) {
ctx.strokeStyle = '#aab';
ctx.lineWidth = 1;
// y-axis
ctx.beginPath(); ctx.moveTo(PAD.left, PAD.top); ctx.lineTo(PAD.left, PAD.top + PH); ctx.stroke();
// x-axis
ctx.beginPath(); ctx.moveTo(PAD.left, PAD.top + PH); ctx.lineTo(PAD.left + PW, PAD.top + PH); ctx.stroke();
ctx.fillStyle = '#555';
ctx.font = '10px system-ui, sans-serif';
// y labels
[0, 0.25, 0.5, 0.75, 1].forEach(p => {
const y = yFor(p);
ctx.fillText(p.toFixed(2), 2, y + 3);
ctx.beginPath(); ctx.moveTo(PAD.left - 3, y); ctx.lineTo(PAD.left, y); ctx.stroke();
});
// x label
if (maxN) {
ctx.fillText('n=' + maxN, PAD.left + PW - 20, PAD.top + PH + 16);
ctx.fillText('n=1', PAD.left, PAD.top + PH + 16);
}
// axis title
ctx.save();
ctx.translate(10, PAD.top + PH / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('proportion', -28, 0);
ctx.restore();
}
document.getElementById('btn10').addEventListener('click', () => flip(10));
document.getElementById('btn100').addEventListener('click', () => flip(100));
document.getElementById('btn500').addEventListener('click', () => flip(500));
document.getElementById('reset').addEventListener('click', () => {
totalFlips = 0; totalHeads = 0; samples.length = 0;
ctx.clearRect(0, 0, W, H); drawAxes(); updateStats();
});
// Draw empty axes on load
drawAxes();
updateStats();
// Quick self-test (runs silently in the console)
(function selfTest() {
// Simulate 10000 fair coin flips and check proportion within 1%
let h = 0, n = 10000;
// Use a deterministic PRNG (xorshift32) so the test is reproducible
let seed = 123456789;
function rand() { seed ^= seed << 13; seed ^= seed >> 17; seed ^= seed << 5; return (seed >>> 0) / 4294967296; }
for (let i = 0; i < n; i++) h += rand() < 0.5 ? 1 : 0;
const prop = h / n;
const ok = Math.abs(prop - 0.5) < 0.02;
console.log(fmt('[LLN self-test] n=%n% proportion=%prop% ok=%ok%', { n: n, prop: prop.toFixed(4), ok: ok }));
if (!ok) console.error('[LLN self-test] FAILED — proportion too far from 0.5');
})();
<p class="hint">Flip batches of fair coins and watch the running proportion of heads
converge toward <strong>0.5</strong>. The gray band shows ±1 standard deviation
(σ = √(p(1−p)/n)). Early flips swing wildly; the band tightens with every batch.</p>
<canvas id="chart" width="520" height="220"></canvas>
<div class="stats" id="stats">Flips: 0 | Heads: 0 | Proportion: —</div>
<div class="btns">
<button id="btn10" type="button">+10 flips</button>
<button id="btn100" type="button">+100 flips</button>
<button id="btn500" type="button">+500 flips</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; width: 100%; max-width: 520px; border: 1px solid #dde3ea;
border-radius: 10px; background: #f7f9fb; }
.stats { font-size: .92rem; margin: .5rem 0; color: #333; min-height: 1.4em; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .4rem; }
button { font: 600 14px system-ui, sans-serif; padding: .42rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Law of Large Numbers demo: flip fair coins, track running proportion of heads.
// After n flips the proportion X̄ₙ = (# heads) / n.
// LLN guarantees X̄ₙ → 0.5 (the expected value) as n → ∞.
// The gray band is ±1 SD = ±√(0.25/n) — shrinks like 1/√n.
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { top: 18, right: 16, bottom: 28, left: 42 };
const PW = W - PAD.left - PAD.right;
const PH = H - PAD.top - PAD.bottom;
let totalFlips = 0, totalHeads = 0;
// Store (n, proportion) samples to plot
const samples = []; // {n, p}
function seeded() {
// fast xorshift32 — deterministic enough for a demo
// We just use Math.random for simplicity
return Math.random() < 0.5 ? 1 : 0;
}
function flip(n) {
for (let i = 0; i < n; i++) {
totalFlips++;
totalHeads += seeded();
// Record every flip up to 50, then thin out
if (totalFlips <= 50 || totalFlips % Math.ceil(totalFlips / 200) === 0) {
samples.push({ n: totalFlips, p: totalHeads / totalFlips });
}
}
// Always record the final point
samples.push({ n: totalFlips, p: totalHeads / totalFlips });
draw();
updateStats();
}
function updateStats() {
const p = totalFlips > 0 ? (totalHeads / totalFlips).toFixed(4) : '—';
document.getElementById('stats').innerHTML =
fmt('Flips: <b>%totalFlips%</b> | Heads: <b>%totalHeads%</b> | Proportion: <b>%p%</b>', { totalFlips: totalFlips, totalHeads: totalHeads, p: p });
}
function xFor(n) {
if (samples.length < 2) return PAD.left;
const maxN = samples[samples.length - 1].n;
return PAD.left + (n / maxN) * PW;
}
function yFor(p) {
// p in [0,1], map to canvas y (0=top, H=bottom)
// show range [0,1]
return PAD.top + PH * (1 - p);
}
function draw() {
ctx.clearRect(0, 0, W, H);
if (samples.length === 0) { drawAxes(); return; }
const maxN = samples[samples.length - 1].n;
// --- SD band ---
ctx.beginPath();
// upper band
for (let i = 0; i < samples.length; i++) {
const s = samples[i];
const sd = Math.sqrt(0.25 / s.n);
const x = PAD.left + (s.n / maxN) * PW;
const y = yFor(Math.min(1, 0.5 + sd));
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
// lower band (reverse)
for (let i = samples.length - 1; i >= 0; i--) {
const s = samples[i];
const sd = Math.sqrt(0.25 / s.n);
const x = PAD.left + (s.n / maxN) * PW;
const y = yFor(Math.max(0, 0.5 - sd));
ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fillStyle = 'rgba(100,160,220,0.15)';
ctx.fill();
// --- 0.5 reference line ---
ctx.beginPath();
ctx.moveTo(PAD.left, yFor(0.5));
ctx.lineTo(PAD.left + PW, yFor(0.5));
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.2;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
// --- proportion curve ---
ctx.beginPath();
for (let i = 0; i < samples.length; i++) {
const s = samples[i];
const x = PAD.left + (s.n / maxN) * PW;
const y = yFor(s.p);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 1.8;
ctx.stroke();
drawAxes(maxN);
// label 0.5 line
ctx.fillStyle = '#e63946';
ctx.font = '11px system-ui, sans-serif';
ctx.fillText('0.5', PAD.left + PW + 2, yFor(0.5) + 4);
}
function drawAxes(maxN) {
ctx.strokeStyle = '#aab';
ctx.lineWidth = 1;
// y-axis
ctx.beginPath(); ctx.moveTo(PAD.left, PAD.top); ctx.lineTo(PAD.left, PAD.top + PH); ctx.stroke();
// x-axis
ctx.beginPath(); ctx.moveTo(PAD.left, PAD.top + PH); ctx.lineTo(PAD.left + PW, PAD.top + PH); ctx.stroke();
ctx.fillStyle = '#555';
ctx.font = '10px system-ui, sans-serif';
// y labels
[0, 0.25, 0.5, 0.75, 1].forEach(p => {
const y = yFor(p);
ctx.fillText(p.toFixed(2), 2, y + 3);
ctx.beginPath(); ctx.moveTo(PAD.left - 3, y); ctx.lineTo(PAD.left, y); ctx.stroke();
});
// x label
if (maxN) {
ctx.fillText('n=' + maxN, PAD.left + PW - 20, PAD.top + PH + 16);
ctx.fillText('n=1', PAD.left, PAD.top + PH + 16);
}
// axis title
ctx.save();
ctx.translate(10, PAD.top + PH / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('proportion', -28, 0);
ctx.restore();
}
document.getElementById('btn10').addEventListener('click', () => flip(10));
document.getElementById('btn100').addEventListener('click', () => flip(100));
document.getElementById('btn500').addEventListener('click', () => flip(500));
document.getElementById('reset').addEventListener('click', () => {
totalFlips = 0; totalHeads = 0; samples.length = 0;
ctx.clearRect(0, 0, W, H); drawAxes(); updateStats();
});
// Draw empty axes on load
drawAxes();
updateStats();
// Quick self-test (runs silently in the console)
(function selfTest() {
// Simulate 10000 fair coin flips and check proportion within 1%
let h = 0, n = 10000;
// Use a deterministic PRNG (xorshift32) so the test is reproducible
let seed = 123456789;
function rand() { seed ^= seed << 13; seed ^= seed >> 17; seed ^= seed << 5; return (seed >>> 0) / 4294967296; }
for (let i = 0; i < n; i++) h += rand() < 0.5 ? 1 : 0;
const prop = h / n;
const ok = Math.abs(prop - 0.5) < 0.02;
console.log(fmt('[LLN self-test] n=%n% proportion=%prop% ok=%ok%', { n: n, prop: prop.toFixed(4), ok: ok }));
if (!ok) console.error('[LLN self-test] FAILED — proportion too far from 0.5');
})();
Comments
Loading comments...