0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
480 café visits, one histogram, and one slider for the bar width — the single arbitrary choice every histogram has to make. Two real crowds live in this data (the ten-minute coffee run and the half-hour laptop sitting), and a smooth curve with a fixed bandwidth sits on top as a reference that never moves, so you can watch the bars disagree with it. Drag the slider: somewhere around 15-minute bins the second crowd stops existing, and the chart gets more convincing as it gets more wrong — switch on reveal crowds at that point to see both populations still sitting inside those innocent-looking bars. Try the three textbook rules first, then look at 10.4 minutes versus 12.0 — the damage isn't even monotone, because where the bin edges happen to fall matters as much as how wide they are.
Source — the code you see is the code that just ran 850 lines 38.3 KB 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">
<meta name="color-scheme" content="light dark">
<title>The Binning Illusion — sloprun.dev</title>
<link rel="icon" href="data:,">
<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); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local additions: a second series colour + alpha helpers.
Tokens are opaque hex, so anything translucent is declared here. */
:root {
--c-b: #1C6E8C; /* the second crowd — reads clearly beside amber */
--c-b-soft: #E4F1F6;
--shadow: 0 1px 2px rgba(26,29,33,.05);
--danger-soft: #FBEAE9;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--c-b: #6BC2E2; --c-b-soft: #16242B;
--shadow: 0 1px 2px rgba(0,0,0,.35);
--danger-soft: #2C1A19;
}
}
:root[data-theme="dark"] {
--c-b: #6BC2E2; --c-b-soft: #16242B;
--shadow: 0 1px 2px rgba(0,0,0,.35);
--danger-soft: #2C1A19;
}
* { 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: 1120px; margin: 0 auto; padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 16px; }
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; }
.stage { display: grid; grid-template-columns: minmax(0,1fr) 304px; gap: 12px 16px;
grid-template-areas: "chart panel" "lesson panel"; align-items: start; }
@media (max-width: 900px) {
.stage { grid-template-columns: minmax(0,1fr); grid-template-areas: "chart" "panel" "lesson"; }
}
.a-chart { grid-area: chart; min-width: 0; }
.a-lesson { grid-area: lesson; min-width: 0; }
.panel { grid-area: panel; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.card { background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow); }
.card > h2 { margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
/* ── chart ────────────────────────────────────────────────────────────────── */
.chead { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px;
margin-bottom: 10px; }
.chead h2 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: -.01em; }
.chead .sub { font-family: var(--font-mono); font-size: 11px; color: var(--muted);
letter-spacing: .04em; font-variant-numeric: tabular-nums; }
.toggles { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
button { font: inherit; color: inherit; cursor: pointer; }
button:focus { outline: none; }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tgl { font-family: var(--font-mono); font-size: 11px; letter-spacing: .03em;
padding: 5px 9px; border-radius: 999px; border: 1px solid var(--line);
background: var(--bg); color: var(--muted); display: inline-flex; align-items: center; gap: 6px;
transition: background-color .15s ease, color .15s ease, border-color .15s ease; }
.tgl:hover { border-color: var(--muted); }
.tgl .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--line); flex: none; }
.tgl[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.tgl[aria-pressed="true"] .dot { background: currentColor; }
#t-crowds[aria-pressed="true"] { background: var(--c-b-soft); color: var(--c-b); border-color: var(--c-b); }
.plotbox { position: relative; }
canvas { display: block; width: 100%; height: clamp(238px, 40vw, 340px);
border-radius: 8px; touch-action: pan-y; }
.legend { margin: 9px 0 0; display: flex; flex-wrap: wrap; gap: 5px 14px;
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: .03em; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend span[hidden] { display: none; }
.legend i { width: 11px; height: 9px; border-radius: 2px; flex: none; }
.readout { margin-top: 9px; padding: 7px 9px; border-radius: 8px;
background: var(--bg); border: 1px solid var(--line);
font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
letter-spacing: .02em; font-variant-numeric: tabular-nums;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.readout b { color: var(--ink); font-weight: 600; }
/* ── verdict ──────────────────────────────────────────────────────────────── */
.verdict { display: flex; gap: 10px; align-items: flex-start;
border: 1px solid var(--line); border-radius: var(--radius); padding: 11px 12px;
background: var(--surface); box-shadow: var(--shadow);
transition: background-color .2s ease, border-color .2s ease; }
.verdict .dot { width: 9px; height: 9px; border-radius: 50%; margin-top: 5px; flex: none;
background: var(--muted); }
.verdict h3 { margin: 0 0 3px; font-size: 13.5px; font-weight: 600; letter-spacing: -.01em; }
.verdict p { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--muted); }
.verdict[data-state="true"] { border-color: var(--run); background: var(--run-soft); }
.verdict[data-state="true"] .dot { background: var(--run); }
.verdict[data-state="true"] h3 { color: var(--run); }
.verdict[data-state="lost"] { border-color: var(--danger); background: var(--danger-soft); }
.verdict[data-state="lost"] .dot { background: var(--danger); }
.verdict[data-state="lost"] h3 { color: var(--danger); }
.verdict[data-state="noise"] { border-color: var(--accent); background: var(--accent-soft); }
.verdict[data-state="noise"] .dot { background: var(--accent); }
.verdict[data-state="noise"] h3 { color: var(--accent); }
/* ── controls ─────────────────────────────────────────────────────────────── */
.ctl-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.ctl-top label { font-size: 13px; font-weight: 600; }
.ctl-val { font-family: var(--font-mono); font-size: 13px; color: var(--accent);
font-variant-numeric: tabular-nums; }
/* 28px tall so the touch target clears the 24px minimum; the track and thumb are
pseudo-elements and stay vertically centred, so it looks identical. */
input[type=range] { -webkit-appearance: none; appearance: none; width: 100%;
height: 28px; margin: 10px 0 0; background: transparent; display: block; }
input[type=range]:focus { outline: none; }
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 999px; background: var(--line); }
input[type=range]::-moz-range-track { height: 4px; border-radius: 999px; background: var(--line); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
width: 18px; height: 18px; margin-top: -7px; border-radius: 50%;
background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--accent); }
input[type=range]::-moz-range-thumb { width: 15px; height: 15px; border-radius: 50%;
background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--accent); }
.scale { margin-top: 7px; display: flex; justify-content: space-between;
font-family: var(--font-mono); font-size: 10px; color: var(--muted); letter-spacing: .05em; }
.rules { margin-top: 12px; }
.rules > .cap { margin: 0 0 7px; font-family: var(--font-mono); font-size: 10px;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips button { font-size: 12px; padding: 6px 9px; border-radius: 8px;
border: 1px solid var(--line); background: var(--bg); color: var(--ink);
display: inline-flex; align-items: baseline; gap: 6px;
transition: background-color .15s ease, border-color .15s ease, color .15s ease; }
.chips button:hover { border-color: var(--muted); }
.chips button span { font-family: var(--font-mono); font-size: 11px; color: var(--muted);
font-variant-numeric: tabular-nums; }
.chips button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.chips button[aria-pressed="true"] span { color: var(--accent); }
.foot-note { margin: 8px 0 0; font-size: 11.5px; line-height: 1.5; color: var(--muted); }
.play { width: 100%; margin-top: 10px; justify-content: center; font-size: 13px;
padding: 9px 10px; border-radius: 8px; border: 1px solid var(--accent);
background: var(--accent-soft); color: var(--accent); font-weight: 600;
display: inline-flex; align-items: center; gap: 8px; }
.play:hover { background: var(--accent); color: var(--surface); }
.play .gl { font-family: var(--font-mono); font-size: 11px; }
/* ── stats ────────────────────────────────────────────────────────────────── */
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 1px;
background: var(--line); border: 1px solid var(--line);
border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); }
.stat { background: var(--surface); padding: 9px 11px; min-width: 0; }
.stat dt { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted); margin: 0 0 3px; }
.stat dd { margin: 0; font-family: var(--font-mono); font-size: 17px; color: var(--ink);
font-variant-numeric: tabular-nums; letter-spacing: -.01em; line-height: 1.2; }
.stat dd small { display: block; font-size: 10px; color: var(--muted); letter-spacing: .04em;
margin-top: 2px; font-variant-numeric: tabular-nums; }
.stat.fixed dd { color: var(--muted); }
.lesson { border: 1px solid var(--line); border-left: 3px solid var(--accent);
border-radius: var(--radius); background: var(--surface); padding: 11px 13px;
box-shadow: var(--shadow); }
.lesson p { margin: 0; font-size: 13.5px; line-height: 1.55; }
.lesson p b { font-weight: 600; }
.lesson .k { font-family: var(--font-mono); font-size: 12.5px; color: var(--accent); }
footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted);
letter-spacing: .05em; padding-top: 2px;
display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (max-width: 560px) {
.readout { white-space: normal; }
}
@media (max-width: 400px) {
.wrap { padding: 18px 12px 22px; }
.card { padding: 11px; }
.stat dd { font-size: 15.5px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>The Binning Illusion</h1>
<p class="lede">
A histogram has one setting nobody argues about: how wide the bars are. Here are
<b>480 café visits</b> — a quick-coffee crowd and a settle-in-with-a-laptop crowd.
<b>Drag the bin width</b> and watch two real crowds melt into one tidy hump that
was never in the data.
</p>
</header>
<main class="stage">
<section class="card a-chart" aria-labelledby="chartH">
<div class="chead">
<h2 id="chartH">How long people stayed</h2>
<span class="sub" id="chartSub">n = 480 · 2 crowds by construction</span>
<div class="toggles">
<button type="button" class="tgl" id="t-kde" aria-pressed="true"><i class="dot"></i>smooth curve</button>
<button type="button" class="tgl" id="t-crowds" aria-pressed="false"><i class="dot"></i>reveal crowds</button>
</div>
</div>
<div class="plotbox">
<canvas id="chart" role="img" aria-label="Histogram of café visit lengths."></canvas>
</div>
<p class="legend" aria-hidden="true">
<span id="lg-all"><i style="background:var(--accent)"></i>visits per bin</span>
<span id="lg-a" hidden><i style="background:var(--accent)"></i>quick coffee</span>
<span id="lg-b" hidden><i style="background:var(--c-b)"></i>settled in</span>
<span id="lg-kde"><i style="height:3px;background:var(--ink)"></i>smooth curve (fixed)</span>
<span><i style="height:11px;width:3px;background:var(--muted);opacity:.55"></i>every single visit</span>
</p>
<p class="readout" id="readout"> </p>
</section>
<div class="panel">
<h2 class="sr">Verdict</h2>
<div class="verdict" id="verdict" data-state="true">
<span class="dot"></span>
<div>
<h3 id="vTitle">Two crowds, both visible</h3>
<p id="vBody">At this width the bars agree with the data.</p>
</div>
</div>
<section class="card" aria-labelledby="ctlH">
<h2 id="ctlH">Controls</h2>
<div class="ctl-top">
<label for="w">Bin width</label>
<span class="ctl-val" id="v-w">2.4 min</span>
</div>
<input type="range" id="w" min="0.4" max="25" step="0.2" value="2.4">
<div class="scale" aria-hidden="true"><span>0.4 min</span><span>25 min</span></div>
<div class="rules">
<p class="cap">Textbook rules pick for you</p>
<div class="chips" id="rules">
<button type="button" data-rule="fd" aria-pressed="false">Freedman–Diaconis <span id="r-fd">—</span></button>
<button type="button" data-rule="scott" aria-pressed="false">Scott <span id="r-scott">—</span></button>
<button type="button" data-rule="sturges" aria-pressed="false">Sturges <span id="r-sturges">—</span></button>
</div>
<p class="foot-note">Three respected rules, three different answers. Each one guesses
from the count and the spread alone — none of them ever looks for a second crowd.</p>
</div>
<button type="button" class="play" id="play" aria-pressed="false">
<span class="gl" id="playGl">▶</span><span id="playLabel">Watch them merge</span>
</button>
<p class="foot-note" id="playNote" hidden></p>
</section>
<dl class="stats">
<div class="stat"><dt>Bins</dt><dd id="s-bins">—</dd></div>
<div class="stat"><dt>Bin width</dt><dd id="s-width">—<small>minutes</small></dd></div>
<div class="stat"><dt>Peaks in the bars</dt><dd id="s-pk">—<small> </small></dd></div>
<div class="stat fixed"><dt>Peaks in the data</dt><dd id="s-pkk">—<small>never changes</small></dd></div>
</dl>
</div>
<section class="lesson a-lesson" aria-label="The lesson">
<p id="lessonText"></p>
</section>
</main>
<footer>
<span>demo · sloprun.dev</span>
<span id="footRight">480 visits · 1 slider</span>
</footer>
</div>
<p class="sr" id="live" role="status" aria-live="polite"></p>
<script>
(function () {
"use strict";
var root = document.documentElement;
function $(id) { return document.getElementById(id); }
var cv = $("chart");
var ctx = cv && cv.getContext ? cv.getContext("2d") : null;
if (!ctx) return;
var mqRM = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduced = !!(mqRM && mqRM.matches);
/* ── the dataset ────────────────────────────────────────────────────────────
Deterministic: the same 480 visits on every load, in every browser. Two
components by construction, so we always know the true answer is "2". */
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 rnd = mulberry32(20260813);
function gauss() {
var u = 0, v = 0;
while (u === 0) u = rnd();
while (v === 0) v = rnd();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function draw1(m, s, lo, hi) {
for (var k = 0; k < 60; k++) { var x = m + s * gauss(); if (x >= lo && x <= hi) return x; }
return m;
}
var XMAX = 52; /* fixed x domain, so the axis never jumps */
var VAL = [], LAB = [], i;
for (i = 0; i < 272; i++) { VAL.push(draw1(9.2, 2.7, 1.5, 24)); LAB.push(0); } /* quick coffee */
for (i = 0; i < 208; i++) { VAL.push(draw1(31.5, 7.0, 13, 60)); LAB.push(1); } /* settled in */
var N = VAL.length;
var SORTED = VAL.slice().sort(function (a, b) { return a - b; });
function quant(p) {
var h = (SORTED.length - 1) * p, lo = Math.floor(h), hi = Math.ceil(h);
return SORTED[lo] + (SORTED[hi] - SORTED[lo]) * (h - lo);
}
var MEAN = 0; for (i = 0; i < N; i++) MEAN += VAL[i]; MEAN /= N;
var SD = 0; for (i = 0; i < N; i++) SD += (VAL[i] - MEAN) * (VAL[i] - MEAN);
SD = Math.sqrt(SD / (N - 1));
var IQR = quant(0.75) - quant(0.25);
var VMIN = SORTED[0], VMAX = SORTED[N - 1];
var MINW = 0.4, MAXW = 25, STEP = 0.2;
function snap(h) {
var v = Math.round(h / STEP) * STEP;
return Math.min(MAXW, Math.max(MINW, Math.round(v * 10) / 10));
}
var RULES = {
fd: snap(2 * IQR * Math.pow(N, -1 / 3)),
scott: snap(3.49 * SD * Math.pow(N, -1 / 3)),
sturges: snap((VMAX - VMIN) / Math.ceil(Math.log(N) / Math.LN2 + 1))
};
/* ── the fixed reference: one kernel density estimate, one bandwidth ───────
The bandwidth is computed once from the data and never touched again, so
the smooth curve is the thing the bars are allowed to disagree with. */
var BW = 0.9 * Math.min(SD, IQR / 1.34) * Math.pow(N, -0.2);
var GN = 260, GX = new Array(GN), GD = new Array(GN);
(function () {
var inv = 1 / (N * BW * Math.sqrt(2 * Math.PI));
for (var g = 0; g < GN; g++) {
var x = (g / (GN - 1)) * XMAX, s = 0;
for (var j = 0; j < N; j++) {
var z = (x - VAL[j]) / BW;
if (z > -5 && z < 5) s += Math.exp(-0.5 * z * z);
}
GX[g] = x; GD[g] = s * inv;
}
})();
/* ── peaks ─────────────────────────────────────────────────────────────────
A bump counts as a peak only if it clears the noise floor and is separated
from its neighbour by a real valley — the same judgement a reader makes. */
function findPeaks(v, floorFrac, valleyFrac) {
var n = v.length, mx = 0, k;
for (k = 0; k < n; k++) if (v[k] > mx) mx = v[k];
if (mx <= 0) return [];
var floor = mx * floorFrac, pk = [], a = 0;
while (a < n) {
var b = a;
while (b + 1 < n && v[b + 1] === v[a]) b++;
var L = a === 0 ? -1 : v[a - 1];
var R = b === n - 1 ? -1 : v[b + 1];
if (v[a] > L && v[a] > R && v[a] >= floor) pk.push((a + b) >> 1);
a = b + 1;
}
var moved = true;
while (moved && pk.length > 1) {
moved = false;
for (k = 0; k < pk.length - 1; k++) {
var lo = Infinity, m;
for (m = pk[k]; m <= pk[k + 1]; m++) if (v[m] < lo) lo = v[m];
var mn = Math.min(v[pk[k]], v[pk[k + 1]]);
if (lo > valleyFrac * mn) {
pk.splice(v[pk[k]] < v[pk[k + 1]] ? k : k + 1, 1);
moved = true; break;
}
}
}
return pk;
}
/* Computed once — the curve never changes, so neither do its peaks. */
var KDE_PK = findPeaks(GD, 0.12, 0.85);
var KDE_PEAKS = KDE_PK.length;
function binsOf(h) {
var nb = Math.ceil(XMAX / h), A = [], B = [], C = [], k;
for (k = 0; k < nb; k++) { A.push(0); B.push(0); C.push(0); }
for (k = 0; k < N; k++) {
var idx = Math.floor(VAL[k] / h);
if (idx >= nb) idx = nb - 1; if (idx < 0) idx = 0;
if (LAB[k]) B[idx]++; else A[idx]++;
C[idx]++;
}
return { h: h, nb: nb, A: A, B: B, C: C };
}
/* ── colours straight from the tokens, so both themes just work ──────────── */
var COL = {};
function hexToRgb(h) {
h = String(h).trim();
if (h.charAt(0) === "#") h = h.slice(1);
if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2);
var n = parseInt(h, 16);
if (h.length !== 6 || isNaN(n)) return null;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function tok(name, fb) { return hexToRgb(getComputedStyle(root).getPropertyValue(name)) || fb; }
function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function readTheme() {
COL.surface = tok("--surface", [255, 255, 255]);
COL.bg = tok("--bg", [246, 246, 243]);
COL.ink = tok("--ink", [26, 29, 33]);
COL.muted = tok("--muted", [90, 96, 104]);
COL.line = tok("--line", [227, 226, 220]);
COL.accent = tok("--accent", [192, 86, 0]);
COL.cb = tok("--c-b", [28, 110, 140]);
}
var MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
/* ── canvas geometry ─────────────────────────────────────────────────────── */
var Wpx = 600, Hpx = 300, PAD = { l: 42, r: 10, t: 26, b: 46 }, plotW = 1, plotH = 1;
function fit() {
var w = cv.clientWidth || 600, h = cv.clientHeight || 300;
var dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.max(1, Math.round(w * dpr));
cv.height = Math.max(1, Math.round(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
Wpx = w; Hpx = h;
PAD.l = w < 430 ? 32 : 42;
PAD.b = w < 430 ? 42 : 46;
plotW = Math.max(10, Wpx - PAD.l - PAD.r);
plotH = Math.max(10, Hpx - PAD.t - PAD.b);
}
function X(v) { return PAD.l + (v / XMAX) * plotW; }
function niceStep(v) {
if (v <= 0) return 1;
var e = Math.pow(10, Math.floor(Math.log(v) / Math.LN10));
var m = [1, 2, 2.5, 5, 10], k;
for (k = 0; k < m.length; k++) if (v <= m[k] * e + 1e-9) return m[k] * e;
return 10 * e;
}
/* ── state ───────────────────────────────────────────────────────────────── */
var W = 2.4, showKde = true, showCrowds = false, hoverBin = -1, lastX = null;
var B = null, PK = [], yMax = 1;
var sl = $("w"), vW = $("v-w"), live = $("live");
var verdict = $("verdict"), vTitle = $("vTitle"), vBody = $("vBody");
var readout = $("readout"), lessonText = $("lessonText");
function fmt(x, d) { return x.toFixed(d === undefined ? 1 : d); }
function draw() {
fit();
ctx.clearRect(0, 0, Wpx, Hpx);
var h = B.h, C = B.C, nb = B.nb, k;
var barMax = 0;
for (k = 0; k < nb; k++) if (C[k] > barMax) barMax = C[k];
/* The curve sets the ceiling whether or not it is drawn, so hiding it never
changes how tall the bars look. Without this, toggling the overlay at wide
bins doubles every bar and the comparison stops meaning anything. */
var kdeMax = 0;
for (k = 0; k < GN; k++) { var s = GD[k] * N * h; if (s > kdeMax) kdeMax = s; }
var raw = Math.max(barMax, kdeMax, 1) * 1.06;
var step = niceStep(raw / 5);
yMax = Math.ceil(raw / step) * step;
function Y(c) { return PAD.t + plotH - (c / yMax) * plotH; }
/* grid + y labels */
var ticks = Math.round(yMax / step), t;
ctx.font = "10px " + MONO;
ctx.textAlign = "right"; ctx.textBaseline = "middle";
for (t = 0; t <= ticks; t++) {
var val = step * t, y = Y(val);
ctx.strokeStyle = t === 0 ? rgba(COL.muted, 0.5) : rgba(COL.line, 1);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PAD.l, Math.round(y) + 0.5); ctx.lineTo(PAD.l + plotW, Math.round(y) + 0.5); ctx.stroke();
ctx.fillStyle = rgba(COL.muted, 1);
ctx.fillText(step % 1 ? val.toFixed(1) : String(Math.round(val)), PAD.l - 7, y);
}
/* bars */
ctx.save();
ctx.beginPath(); ctx.rect(PAD.l, PAD.t - 8, plotW, plotH + 8); ctx.clip();
var bw = (h / XMAX) * plotW;
var gap = bw > 7 ? 1.5 : (bw > 3 ? 0.7 : 0);
for (k = 0; k < nb; k++) {
if (C[k] === 0) continue;
var x0 = X(k * h) + gap / 2, w = Math.max(0.7, bw - gap);
var base = Y(0), hot = hoverBin === k;
if (showCrowds) {
var ha = base - Y(B.A[k]), hb = base - Y(B.A[k] + B.B[k]);
ctx.fillStyle = rgba(COL.accent, hot ? 0.98 : 0.82);
ctx.fillRect(x0, base - ha, w, ha);
ctx.fillStyle = rgba(COL.cb, hot ? 0.98 : 0.82);
ctx.fillRect(x0, base - hb, w, hb - ha);
} else {
var hc = base - Y(C[k]);
ctx.fillStyle = rgba(COL.accent, hot ? 0.98 : 0.78);
ctx.fillRect(x0, base - hc, w, hc);
}
if (hot) {
ctx.strokeStyle = rgba(COL.ink, 0.85); ctx.lineWidth = 1.5;
ctx.strokeRect(x0 - 0.5, Y(C[k]) - 0.5, w + 1, base - Y(C[k]) + 1);
}
}
/* the fixed smooth curve */
if (showKde) {
ctx.strokeStyle = rgba(COL.ink, 0.9);
ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.beginPath();
for (k = 0; k < GN; k++) {
var px = X(GX[k]), py = Y(GD[k] * N * h);
if (k === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
var kp = KDE_PK;
for (k = 0; k < kp.length; k++) {
var cx = X(GX[kp[k]]), cy = Y(GD[kp[k]] * N * h);
ctx.beginPath(); ctx.arc(cx, cy, 3.2, 0, Math.PI * 2);
ctx.fillStyle = rgba(COL.surface, 1); ctx.fill();
ctx.strokeStyle = rgba(COL.ink, 0.9); ctx.lineWidth = 1.6; ctx.stroke();
}
}
/* carets over the peaks the bars claim */
for (k = 0; k < PK.length; k++) {
var bxc = X((PK[k] + 0.5) * h), byc = Y(C[PK[k]]) - 7;
ctx.fillStyle = rgba(COL.ink, 0.75);
ctx.beginPath();
ctx.moveTo(bxc, byc + 5); ctx.lineTo(bxc - 4, byc - 1); ctx.lineTo(bxc + 4, byc - 1);
ctx.closePath(); ctx.fill();
}
ctx.restore();
/* rug: every single visit, one hairline each, below the axis */
var ry = PAD.t + plotH + 5, rh = 9;
ctx.lineWidth = 1;
for (k = 0; k < N; k++) {
ctx.strokeStyle = rgba(showCrowds ? (LAB[k] ? COL.cb : COL.accent) : COL.muted, 0.4);
var rx = Math.round(X(VAL[k])) + 0.5;
ctx.beginPath(); ctx.moveTo(rx, ry); ctx.lineTo(rx, ry + rh); ctx.stroke();
}
/* x axis */
ctx.font = "10px " + MONO;
ctx.textAlign = "center"; ctx.textBaseline = "top";
ctx.fillStyle = rgba(COL.muted, 1);
var xs = Wpx < 430 ? 20 : 10;
for (var v = 0; v <= 50; v += xs) ctx.fillText(String(v), X(v), PAD.t + plotH + 21);
ctx.textAlign = "right";
ctx.fillText("minutes stayed", PAD.l + plotW, PAD.t + plotH + 33);
ctx.textAlign = "left";
ctx.fillText("visits", 0, 4);
}
/* ── verdict / stats / copy ──────────────────────────────────────────────── */
function verdictFor(p) {
if (p <= 1) return { s: "lost", t: "One hump — a crowd has vanished",
b: "The bars now say most people stay a middling amount of time. Nobody in this data does." };
if (p === 2) return { s: "true", t: "Two crowds, both visible",
b: "The bars agree with the data: a quick-coffee peak and a settle-in peak." };
return { s: "noise", t: p + " peaks — most are noise",
b: "Bins this narrow give each bar a handful of visits, so random wobble looks like structure." };
}
function lessonFor(p) {
if (p === 2) return "Same 480 numbers, same people. <b>The bars only tell the truth inside a narrow band of bin widths</b> — and nothing on the chart tells you when you have left it.";
if (p <= 1) return "Nothing about the data changed — only the bar width did. <b>A second crowd of real customers just stopped existing</b>, and the chart looks more convincing than it did before.";
return "Nothing about the data changed — only the bar width did. <b>Every one of those little spikes is an accident</b>, and a chart this jagged still looks like evidence.";
}
function tallest() {
var best = 0, k;
for (k = 0; k < B.nb; k++) if (B.C[k] > B.C[best]) best = k;
return best;
}
function binText(k) {
/* Real bin edges, not clipped to the axis, so the range always spans exactly
one bin width — clamping the last bin made it contradict the width stat. */
var lo = k * B.h, hi = (k + 1) * B.h, c = B.C[k];
var pct = Math.round((c / N) * 1000) / 10;
var extra = showCrowds ? " · " + B.A[k] + " quick / " + B.B[k] + " settled" : "";
return "<b>" + fmt(lo) + "–" + fmt(hi) + " min</b> · <b>" + c + "</b> visits · " + fmt(pct) + "% of all" + extra;
}
function syncReadout() {
readout.innerHTML = (hoverBin < 0 || hoverBin >= B.nb)
? "tallest bin → " + binText(tallest())
: "bin " + (hoverBin + 1) + " → " + binText(hoverBin);
}
var liveTimer = 0;
function announce(msg) {
if (liveTimer) clearTimeout(liveTimer);
liveTimer = setTimeout(function () { live.textContent = msg; }, 350);
}
function update(fromUser) {
B = binsOf(W);
PK = findPeaks(B.C, 0.15, 0.72);
var p = PK.length;
vW.textContent = fmt(W) + " min";
sl.setAttribute("aria-valuetext", fmt(W) + " minutes per bin");
$("s-bins").textContent = String(B.nb);
$("s-width").innerHTML = fmt(W) + "<small>minutes</small>";
$("s-pk").innerHTML = String(p) + '<small>' + (p === KDE_PEAKS ? "matches the data" : (p < KDE_PEAKS ? "one crowd erased" : "invented by the bins")) + "</small>";
$("s-pkk").innerHTML = String(KDE_PEAKS) + "<small>never changes</small>";
var vd = verdictFor(p);
verdict.setAttribute("data-state", vd.s);
vTitle.textContent = vd.t;
vBody.textContent = vd.b;
lessonText.innerHTML = lessonFor(p);
var chips = $("rules").getElementsByTagName("button");
for (var k = 0; k < chips.length; k++) {
chips[k].setAttribute("aria-pressed", Math.abs(RULES[chips[k].getAttribute("data-rule")] - W) < 0.01 ? "true" : "false");
}
/* The pointer may be resting on the chart while the width changes under it,
so re-derive which bin it is actually over instead of keeping a stale index. */
if (lastX !== null) hoverBin = hoverAt(lastX);
syncReadout();
cv.setAttribute("aria-label",
"Histogram of 480 café visit lengths. Bin width " + fmt(W) + " minutes, " + B.nb +
" bins. " + p + (p === 1 ? " peak" : " peaks") + " visible in the bars; the data has " + KDE_PEAKS + ".");
draw();
if (fromUser) announce(fmt(W) + " minute bins, " + B.nb + " bins, " + p + (p === 1 ? " peak" : " peaks") + " visible. " + vd.t + ".");
}
function setW(v, fromUser) {
W = Math.min(MAXW, Math.max(MINW, Math.round(v * 10) / 10));
sl.value = String(W);
update(fromUser);
}
/* ── controls ────────────────────────────────────────────────────────────── */
sl.addEventListener("input", function () { stopPlay(); setW(parseFloat(sl.value), true); });
$("t-kde").addEventListener("click", function () {
showKde = !showKde;
this.setAttribute("aria-pressed", showKde ? "true" : "false");
$("lg-kde").hidden = !showKde;
update(false);
announce(showKde ? "Smooth curve on." : "Smooth curve off.");
});
$("t-crowds").addEventListener("click", function () {
showCrowds = !showCrowds;
this.setAttribute("aria-pressed", showCrowds ? "true" : "false");
$("lg-all").hidden = showCrowds;
$("lg-a").hidden = !showCrowds;
$("lg-b").hidden = !showCrowds;
update(false);
announce(showCrowds ? "Crowds revealed: each bar split into quick and settled visits." : "Crowds hidden.");
});
(function initRules() {
$("r-fd").textContent = fmt(RULES.fd) + "m";
$("r-scott").textContent = fmt(RULES.scott) + "m";
$("r-sturges").textContent = fmt(RULES.sturges) + "m";
$("rules").addEventListener("click", function (e) {
var b = e.target;
while (b && b !== this && b.tagName !== "BUTTON") b = b.parentNode;
if (!b || b === this) return;
stopPlay();
setW(RULES[b.getAttribute("data-rule")], true);
});
})();
/* ── the sweep ───────────────────────────────────────────────────────────── */
var playBtn = $("play"), playLabel = $("playLabel"), playGl = $("playGl"), playNote = $("playNote");
var playing = false, raf = 0, timer = 0, t0 = 0, tourAt = 0;
var TOUR = [0.6, 1.6, 3.0, 5.0, 8.0, 12.0, 16.0, 21.0, 25.0];
var DUR = 7200;
function setPlayUI() {
playBtn.setAttribute("aria-pressed", playing ? "true" : "false");
playGl.innerHTML = playing ? "■" : "▶";
playLabel.textContent = playing ? "Stop" : "Watch them merge";
}
function stopPlay() {
if (!playing) return;
playing = false;
if (raf) { cancelAnimationFrame(raf); raf = 0; }
if (timer) { clearInterval(timer); timer = 0; }
setPlayUI();
}
function tick(now) {
if (!playing) return;
var u = Math.min(1, (now - t0) / DUR);
var e = u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2;
setW(MINW + (MAXW - MINW) * e, false);
if (u >= 1) { stopPlay(); announce("Sweep finished at " + fmt(W) + " minute bins."); return; }
raf = requestAnimationFrame(tick);
}
function tourStep() {
if (!playing) return;
tourAt++;
if (tourAt >= TOUR.length) { stopPlay(); announce("Sweep finished at " + fmt(W) + " minute bins."); return; }
setW(TOUR[tourAt], false);
}
playBtn.addEventListener("click", function () {
if (playing) { stopPlay(); return; }
playing = true; setPlayUI();
if (reduced) {
tourAt = 0; setW(TOUR[0], false);
timer = setInterval(tourStep, 900);
} else {
t0 = (window.performance && performance.now) ? performance.now() : Date.now();
setW(MINW, false);
raf = requestAnimationFrame(tick);
}
});
function applyRM() {
playNote.hidden = !reduced;
playNote.textContent = reduced
? "Reduced motion is on, so the sweep steps through nine widths instead of gliding."
: "";
}
/* ── hover ───────────────────────────────────────────────────────────────── */
function hoverAt(clientX) {
var r = cv.getBoundingClientRect();
var mx = clientX - r.left;
if (mx < PAD.l || mx > PAD.l + plotW) return -1;
var minutes = ((mx - PAD.l) / plotW) * XMAX;
var k = Math.floor(minutes / B.h);
return (k >= 0 && k < B.nb) ? k : -1;
}
function pointAt(e) {
lastX = e.clientX;
var k = hoverAt(e.clientX);
if (k === hoverBin) return;
hoverBin = k;
syncReadout();
draw();
}
cv.addEventListener("pointermove", pointAt);
/* A tap on touch fires no pointermove, so inspect on down too. */
cv.addEventListener("pointerdown", pointAt);
cv.addEventListener("pointerleave", function () {
lastX = null;
if (hoverBin < 0) return;
hoverBin = -1;
syncReadout();
draw();
});
/* ── theme + resize ──────────────────────────────────────────────────────── */
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);
readTheme(); draw();
}
});
var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
function onScheme() { readTheme(); draw(); }
if (mqDark) {
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
if (mqRM) {
var onRM = function (e) { reduced = e.matches; applyRM(); if (playing) stopPlay(); };
if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
else if (mqRM.addListener) mqRM.addListener(onRM);
}
var rz = 0;
function onResize() {
if (rz) return;
rz = requestAnimationFrame(function () { rz = 0; draw(); });
}
window.addEventListener("resize", onResize);
if (window.ResizeObserver) { try { new ResizeObserver(onResize).observe(cv); } catch (e) {} }
/* ── boot: already loaded, already showing both crowds ───────────────────── */
readTheme();
applyRM();
setPlayUI();
$("chartSub").textContent = "n = " + N + " · " + KDE_PEAKS + " crowds by construction";
$("footRight").textContent = N + " visits · 1 slider";
setW(2.4, false);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.