05A Narrowing Band as the Heads Ratio Settles Toward Half With More Tosses
Results from a fixed sequence stack one at a time, the heads ratio is joined into a line, and a band of width 1 over 2√n narrows around it. The first few tosses swing wide, then the line settles toward 0.5 inside the band, and any point that leaves the band is marked in pink.
Best forStatistics explainers that show convergence by eye
TOSS_NbandHalfratioLine

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="Coin flip stage: Enter, Space or a click tosses, P pauses, N steps once, R restarts">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="The heads ratio is joined into a line up to 100 tosses. The horizontal axis is the logarithm of the toss count, the mint band around it narrows with a half width of 1 over 2 root n, and points that leave the band are marked pink. The small coin at the top flips on every toss"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">Coin flip demo</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
<button class="vj__step" data-role="step" type="button">Step</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 누르면 던진다. 키보드 포커스도 여기서 받는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP = [250, 246, 252];
const BG_BOT = [228, 234, 250];
const TABLE_TOP = [240, 226, 238];
const TABLE_BOT = [222, 208, 236];
const GOLD = [250, 226, 150];
const GOLD_DEEP = [196, 150, 52];
const SKY = [176, 210, 246];
const SKY_DEEP = [74, 120, 176];
const RIM_LO = [184, 156, 100];
const RIM_HI = [255, 248, 222];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const MINT_DEEP = [22, 112, 82];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function tossHalf(k, t) { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k) { return k.h0 + k.hr * k.T; }
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(Half turns 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — even + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face, rng, minPairs) {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 Heads(0)과 Tails(1) — 값이 p 보다 작으면 Heads
function outcomes(seed, n, p) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() < p ? 0 : 1);
}
return out;
}
// 가장 긴 연속 길이
function longestRun(seq) {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) {
best = cur;
}
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x, y, rx, ry, c, a) {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) {
return;
}
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face, R, a) {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) {
ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
else {
ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
}
else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) {
disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a);
}
}
}
function coinShadow(cx, gy, R, lift, a) {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) {
continue;
}
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx, cy, R, thick, a) {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart() {
lap += 1;
doneSaid = false;
if (still) {
showRest();
return;
}
begin();
showNow();
if (!paused) {
startLoop();
}
}
// Step — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 Pause, N 은 Step, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function () {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 05 던진 횟수가 쌓일수록 Heads 비율이 5:5 로 모이는 좁아지는 띠 ─────────────────────
// 같은 수열에서 Heads 비율 ratioLine 을 잇고, 둘레에 반폭 bandHalf = 1 / (2√n) 의 띠를 겹친다. 선 밖으로 나간 점은 분홍
const TOSS_N = 100;
const SEED = 5055;
const TOSS_GAP = 0.07;
const T_START = 0.1;
const LAP_S = 9.6;
const START_S = 0.0;
const REST_S = 8.6;
const RESULT_S = 8.2;
const PX0 = 40;
const PX1 = 456;
const PY0 = 74;
const PY1 = 262;
// SIM-BEGIN
function bandHalf(n) { return 1 / (2 * Math.sqrt(n)); }
// n 번째 던지기까지의 Heads 비율(n = 1 에서 시작)
function ratioLine(seq) {
const out = [];
let heads = 0;
for (let i = 0; i < seq.length; i += 1) {
if (seq[i] === 0) {
heads += 1;
}
out.push(heads / (i + 1));
}
return out;
}
// 띠 밖으로 나간 던지기 번호 수 — n 번째 비율이 0.5 에서 bandHalf(n) 보다 멀면 센다
function outsideCount(seq) {
const r = ratioLine(seq);
let c = 0;
for (let i = 0; i < r.length; i += 1) {
if (Math.abs(r[i] - 0.5) > bandHalf(i + 1) + 1e-9) {
c += 1;
}
}
return c;
}
// SIM-END
let seq = [];
let ratios = [];
function build() {
seq = outcomes(SEED + lap, TOSS_N, 0.5);
ratios = ratioLine(seq);
}
function update(dt) { if (dt < 0) {
return;
} }
function nShown() { return clamp(Math.floor((sceneT - T_START) / TOSS_GAP) + 1, 0, TOSS_N); }
function px(n) { return PX0 + (PX1 - PX0) * Math.log(n) / Math.log(TOSS_N); }
// 세로 범위는 0.2 에서 0.8 — 첫 몇 번의 0 이나 1 은 가장자리에 붙여 그린다
function py(v) { return PY1 - (PY1 - PY0) * (clamp(v, 0.2, 0.8) - 0.2) / 0.6; }
function draw() {
skyFill(BG_TOP, BG_BOT);
box(PX0 - 14, PY0 - 14, PX1 - PX0 + 28, PY1 - PY0 + 28, 14, [255, 255, 255], 0.7);
const n = nShown();
// 눈금 — 0.3, 0.5, 0.7
for (let k = 0; k < 3; k += 1) {
const v = 0.3 + 0.2 * k;
seg(PX0, py(v), PX1, py(v), k === 1 ? 2 : 1, k === 1 ? INK : LAV, k === 1 ? 0.55 : 0.6);
text(v.toFixed(1), PX0 - 18, py(v), INK, 0.8, 10, 700, 'right');
}
if (n >= 1) {
// 띠 — 반폭이 1/(2√n) 로 좁아진다
ctx.beginPath();
for (let i = 1; i <= n; i += 1) {
ctx.lineTo(px(i), py(0.5 + bandHalf(i)));
}
for (let i = n; i >= 1; i -= 1) {
ctx.lineTo(px(i), py(0.5 - bandHalf(i)));
}
ctx.closePath();
ctx.fillStyle = rgba(MINT, 0.55);
ctx.fill();
// 비율 선
ctx.beginPath();
for (let i = 1; i <= n; i += 1) {
if (i === 1) {
ctx.moveTo(px(i), py(ratios[i - 1]));
}
else {
ctx.lineTo(px(i), py(ratios[i - 1]));
}
}
ctx.lineWidth = 2.6;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(GOLD_DEEP, 1);
ctx.stroke();
for (let i = 1; i <= n; i += 1) {
const out = Math.abs(ratios[i - 1] - 0.5) > bandHalf(i) + 1e-9;
disc(px(i), py(ratios[i - 1]), out ? 3.6 : 2.4, out ? ROSE_DEEP : GOLD_DEEP, 1);
}
// 끝점 윤기
disc(px(n), py(ratios[n - 1]), 7, GOLD, 0.8);
// 지금 던지는 동전
const lastFace = seq[n - 1];
const prev = n > 1 ? seq[n - 2] : 0;
const u = clamp((sceneT - (T_START + (n - 1) * TOSS_GAP)) / TOSS_GAP, 0, 1);
const h0 = prev;
const h1 = h0 + 2 + (lastFace !== prev ? 1 : 0);
coinShadow(420, 120, 18, 0, 1);
drawCoin(420, 100, 18, PI * (h0 + (h1 - h0) * ease(u)), 5, 0.36, -0.46, 0.81, 1, 0);
tag('{n} tosses - heads ratio {r}'.replace('{n}', String(n)).replace('{r}', ratios[n - 1].toFixed(2)), 40, 282, PAPER, INK, 11, 'left');
tag('Band half width {b}'.replace('{b}', bandHalf(n).toFixed(3)), 456, 282, MINT, INK, 11, 'right');
}
}
function statusText() {
const n = nShown();
if (n < 1) {
return 'Waiting for the toss';
}
return '{n} tosses - heads ratio {r} - band half width {b}'.replace('{n}', String(n)).replace('{r}', ratios[n - 1].toFixed(2)).replace('{b}', bandHalf(n).toFixed(3));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// TallyRatioConverge - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./TallyRatioConverge.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TallyRatioConverge() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP: RGB = [250, 246, 252];
const BG_BOT: RGB = [228, 234, 250];
const TABLE_TOP: RGB = [240, 226, 238];
const TABLE_BOT: RGB = [222, 208, 236];
const GOLD: RGB = [250, 226, 150];
const GOLD_DEEP: RGB = [196, 150, 52];
const SKY: RGB = [176, 210, 246];
const SKY_DEEP: RGB = [74, 120, 176];
const RIM_LO: RGB = [184, 156, 100];
const RIM_HI: RGB = [255, 248, 222];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const MINT_DEEP: RGB = [22, 112, 82];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 던지기 물리 ───────────────────────────────────────────
// 한 번의 던지기: 출발 (x0, y0) → 도착 (x1, y1), 체공 T 초, 꼭대기 peak 픽셀, 초당 반 바퀴 수 hr, 시작 반 바퀴 수 h0.
// 위치는 포물선(중력만), 각도는 반 바퀴 수 × π — 체공 중 회전 속도는 일정하다
type Toss = { t0: number; T: number; x0: number; y0: number; x1: number; y1: number; peak: number; hr: number; h0: number };
function tossHalf(k: Toss, t: number): number { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k: Toss): number { return k.h0 + k.hr * k.T; }
function tossXY(k: Toss, t: number): [number, number] {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
if (q <= 0) { return tossHalf(k, t); }
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h: number): number { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 짝수 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face: number, rng: () => number, minPairs: number): number {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 앞면(0)과 뒷면(1) — 값이 p 보다 작으면 앞면
function outcomes(seed: number, n: number, p: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() < p ? 0 : 1); }
return out;
}
// 가장 긴 연속 길이
function longestRun(seq: number[]): number {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) { best = cur; }
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x: number, y: number, rx: number, ry: number, c: RGB, a: number): void {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) { return; }
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face: number, R: number, a: number): void {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) { ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr); } else { ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr); }
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
} else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) { disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a); }
}
}
function coinShadow(cx: number, gy: number, R: number, lift: number, a: number): void {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx: number, cy: number, R: number, th: number, thick: number, lx: number, ly: number, lz: number, a: number, startFace: number): void {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) { continue; }
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx: number, cy: number, R: number, thick: number, a: number): void {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest(): void {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart(): void {
lap += 1;
doneSaid = false;
if (still) { showRest(); return; }
begin();
showNow();
if (!paused) { startLoop(); }
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function (): void {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 05 던진 횟수가 쌓일수록 앞면 비율이 5:5 로 모이는 좁아지는 띠 ─────────────────────
// 같은 수열에서 앞면 비율 ratioLine 을 잇고, 둘레에 반폭 bandHalf = 1 / (2√n) 의 띠를 겹친다. 선 밖으로 나간 점은 분홍
const TOSS_N = 100;
const SEED = 5055;
const TOSS_GAP = 0.07;
const T_START = 0.1;
const LAP_S = 9.6;
const START_S = 0.0;
const REST_S = 8.6;
const RESULT_S = 8.2;
const PX0 = 40;
const PX1 = 456;
const PY0 = 74;
const PY1 = 262;
// SIM-BEGIN
function bandHalf(n: number): number { return 1 / (2 * Math.sqrt(n)); }
// n 번째 던지기까지의 앞면 비율(n = 1 에서 시작)
function ratioLine(seq: number[]): number[] {
const out: number[] = [];
let heads = 0;
for (let i = 0; i < seq.length; i += 1) {
if (seq[i] === 0) { heads += 1; }
out.push(heads / (i + 1));
}
return out;
}
// 띠 밖으로 나간 던지기 번호 수 — n 번째 비율이 0.5 에서 bandHalf(n) 보다 멀면 센다
function outsideCount(seq: number[]): number {
const r = ratioLine(seq);
let c = 0;
for (let i = 0; i < r.length; i += 1) { if (Math.abs(r[i] - 0.5) > bandHalf(i + 1) + 1e-9) { c += 1; } }
return c;
}
// SIM-END
let seq: number[] = [];
let ratios: number[] = [];
function build(): void {
seq = outcomes(SEED + lap, TOSS_N, 0.5);
ratios = ratioLine(seq);
}
function update(dt: number): void { if (dt < 0) { return; } }
function nShown(): number { return clamp(Math.floor((sceneT - T_START) / TOSS_GAP) + 1, 0, TOSS_N); }
function px(n: number): number { return PX0 + (PX1 - PX0) * Math.log(n) / Math.log(TOSS_N); }
// 세로 범위는 0.2 에서 0.8 — 첫 몇 번의 0 이나 1 은 가장자리에 붙여 그린다
function py(v: number): number { return PY1 - (PY1 - PY0) * (clamp(v, 0.2, 0.8) - 0.2) / 0.6; }
function draw(): void {
skyFill(BG_TOP, BG_BOT);
box(PX0 - 14, PY0 - 14, PX1 - PX0 + 28, PY1 - PY0 + 28, 14, [255, 255, 255], 0.7);
const n = nShown();
// 눈금 — 0.3, 0.5, 0.7
for (let k = 0; k < 3; k += 1) {
const v = 0.3 + 0.2 * k;
seg(PX0, py(v), PX1, py(v), k === 1 ? 2 : 1, k === 1 ? INK : LAV, k === 1 ? 0.55 : 0.6);
text(v.toFixed(1), PX0 - 18, py(v), INK, 0.8, 10, 700, 'right');
}
if (n >= 1) {
// 띠 — 반폭이 1/(2√n) 로 좁아진다
ctx.beginPath();
for (let i = 1; i <= n; i += 1) { ctx.lineTo(px(i), py(0.5 + bandHalf(i))); }
for (let i = n; i >= 1; i -= 1) { ctx.lineTo(px(i), py(0.5 - bandHalf(i))); }
ctx.closePath();
ctx.fillStyle = rgba(MINT, 0.55);
ctx.fill();
// 비율 선
ctx.beginPath();
for (let i = 1; i <= n; i += 1) {
if (i === 1) { ctx.moveTo(px(i), py(ratios[i - 1])); } else { ctx.lineTo(px(i), py(ratios[i - 1])); }
}
ctx.lineWidth = 2.6;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(GOLD_DEEP, 1);
ctx.stroke();
for (let i = 1; i <= n; i += 1) {
const out = Math.abs(ratios[i - 1] - 0.5) > bandHalf(i) + 1e-9;
disc(px(i), py(ratios[i - 1]), out ? 3.6 : 2.4, out ? ROSE_DEEP : GOLD_DEEP, 1);
}
// 끝점 윤기
disc(px(n), py(ratios[n - 1]), 7, GOLD, 0.8);
// 지금 던지는 동전
const lastFace = seq[n - 1];
const prev = n > 1 ? seq[n - 2] : 0;
const u = clamp((sceneT - (T_START + (n - 1) * TOSS_GAP)) / TOSS_GAP, 0, 1);
const h0 = prev;
const h1 = h0 + 2 + (lastFace !== prev ? 1 : 0);
coinShadow(420, 120, 18, 0, 1);
drawCoin(420, 100, 18, PI * (h0 + (h1 - h0) * ease(u)), 5, 0.36, -0.46, 0.81, 1, 0);
tag('던진 {n}번 · 앞면 비율 {r}'.replace('{n}', String(n)).replace('{r}', ratios[n - 1].toFixed(2)), 40, 282, PAPER, INK, 11, 'left');
tag('띠 반폭 {b}'.replace('{b}', bandHalf(n).toFixed(3)), 456, 282, MINT, INK, 11, 'right');
}
}
function statusText(): string {
const n = nShown();
if (n < 1) { return '던지기를 기다리는 중'; }
return '던진 {n}번 · 앞면 비율 {r} · 띠 반폭 {b}'.replace('{n}', String(n)).replace('{r}', ratios[n - 1].toFixed(2)).replace('{b}', bandHalf(n).toFixed(3));
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'동전 던지기 시연 무대 — Enter 나 Space 나 클릭으로 던지기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'던진 횟수 100 까지 앞면 비율을 꺾은선으로 잇는다. 가로는 던진 횟수의 로그이고 둘레의 민트색 띠는 반폭 1 나누기 2√n 으로 좁아지며, 띠 밖으로 나간 점은 분홍으로 표시된다. 위쪽 작은 동전이 던질 때마다 뒤집힌다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'동전 던지기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}05 A Narrowing Band as the Heads Ratio Settles Toward Half With More Tosses — Measured area 19.085% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Coin Flip: 9 Canvas Toss Scenes
The post this demo lives in
Coin Flip: 9 Canvas Toss ScenesHow does a coin flip pick a side? 03 multiplies airtime by spin rate and reads the parity of half turns, 05 draws a rati See all demos in this post
If you like this effect - similar demos
Interactions01 A Wireframe Polyhedron That Eases Toward the Pointer and Fades With DepthJavaScript 3D Animation: 9 Pointer-Led Models
Interactions01 Product mockup that turns with the pointer3D Hover Effect CSS: 9 Pointer-Following Scenes
Interactions04 Perspective Grid Floor Converging Toward the Horizon9 CSS 3D Background Animation Depth Layers