0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Complexity is usually something you argue about at a whiteboard; this measures it instead. It opens already profiling a linear scan across a dozen geometrically growing input sizes — auto-calibrating the repetition count so every batch runs at least 12 ms — then fits the timing curve against O(1), O(log n), O(n), O(n log n), O(n²) and O(bⁿ) and reports the winner with its R² and the empirical exponent. Try fib (recursive) next: it comes back as O(1.62ⁿ), the profiler rediscovering the golden ratio from nothing but a stopwatch. Then hit runaway loop — your code runs in a Web Worker built from a blob: URL, so a function that never returns is simply killed by the 3-second watchdog while the page stays responsive.
Source — the code you see is the code that just ran 1236 lines 56.7 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Empirical Big-O Profiler</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ─────────────── */
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
: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 token block ───────────────────────────────────────────────────── */
/* derived tokens (theme-paired, defined in all three states above by role) */
:root { --on-accent: #FFFFFF; --grid: #E4E3DA; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --on-accent: #14161A; --grid: #272D36; }
}
:root[data-theme="dark"] { --on-accent: #14161A; --grid: #272D36; }
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; font-size: 15px; line-height: 1.5; }
main { max-width: 1220px; margin: 0 auto; padding: 22px 16px 34px; }
h1 { font-size: clamp(1.25rem, 3.4vw, 1.55rem); margin: 0 0 5px; letter-spacing: -0.015em; }
.sub { margin: 0; color: var(--muted); max-width: 64ch; font-size: 14.5px; }
h2 { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase;
letter-spacing: .1em; color: var(--muted); margin: 0 0 10px; font-weight: 600; }
.note { display: flex; gap: 10px; align-items: flex-start; margin: 16px 0 18px;
padding: 10px 12px; border: 1px solid var(--line); border-left: 3px solid var(--accent);
background: var(--accent-soft); border-radius: 8px; font-size: 13.5px; }
.note svg { flex: none; margin-top: 2px; }
.note b { font-weight: 650; }
.note code { font-family: var(--font-mono); font-size: 12.5px; }
.grid { display: grid; gap: 16px; align-items: start;
grid-template-columns: minmax(0, 1fr) minmax(318px, 400px); }
@media (max-width: 940px) { .grid { grid-template-columns: minmax(0, 1fr); } }
.panel { background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px; min-width: 0; }
/* verdict ---------------------------------------------------------------- */
.verdict { border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
padding: 12px 14px; display: flex; flex-wrap: wrap; gap: 12px 26px; align-items: center; }
.v-left { min-width: 132px; }
.eyebrow { display: block; font-family: var(--font-mono); font-size: 9.5px;
text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
#vBig { font-family: var(--font-mono); font-size: clamp(1.45rem, 5.2vw, 1.95rem);
font-weight: 600; letter-spacing: -.02em; line-height: 1.15; display: block; }
.verdict.weak #vBig { color: var(--muted); }
.verdict.nofit #vBig { font-size: clamp(1.05rem, 4vw, 1.25rem); letter-spacing: 0; }
.v-stats { display: flex; flex-wrap: wrap; gap: 10px 22px; margin: 0; }
.v-stats > div { min-width: 62px; }
.v-stats dt { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase;
letter-spacing: .12em; color: var(--muted); }
.v-stats dd { margin: 0; font-family: var(--font-mono); font-size: 14px; }
.match { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
.match.ok { color: var(--run); }
.prog { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; margin: 10px 0 0; }
.prog i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .16s linear; }
.statusrow { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 10px; }
.chip { font-family: var(--font-mono); font-size: 11.5px; padding: 3px 8px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--surface); }
.chip.busy { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.chip.ok { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.chip.bad { color: var(--danger); border-color: var(--danger); background: var(--surface); }
/* chart ------------------------------------------------------------------ */
.charthead { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center;
justify-content: space-between; margin: 16px 0 8px; }
.charthead h2 { margin: 0; }
.opts { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; }
.swi { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px;
font-family: var(--font-mono); color: var(--muted); cursor: pointer; }
.swi input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
canvas { display: block; width: 100%; height: clamp(216px, 30vw, 306px); touch-action: pan-y;
border: 1px solid var(--line); border-radius: 8px; background: var(--bg); }
.readout { font-family: var(--font-mono); font-size: 12px; color: var(--muted);
margin: 8px 0 0; min-height: 18px; }
.readout b { color: var(--ink); font-weight: 600; }
.legend { display: flex; gap: 14px; flex-wrap: wrap; font-family: var(--font-mono);
font-size: 11px; color: var(--muted); margin-top: 6px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.k-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.k-line { width: 16px; height: 0; border-top: 2px dashed var(--muted); }
/* tables ----------------------------------------------------------------- */
.tabs { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 14px; margin-top: 16px; }
@media (max-width: 620px) { .tabs { grid-template-columns: minmax(0, 1fr); } }
.tabs > div { display: flex; flex-direction: column; }
.tabs .hint { margin-top: auto; padding-top: 8px; }
.scroll { overflow: auto; max-height: 208px; border: 1px solid var(--line); border-radius: 8px; }
table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
thead th { position: sticky; top: 0; background: var(--surface); z-index: 1;
font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase;
letter-spacing: .1em; color: var(--muted); font-weight: 600; text-align: right;
padding: 6px 9px; border-bottom: 1px solid var(--line); }
thead th:first-child { text-align: left; }
thead th .u { text-transform: none; letter-spacing: .04em; }
td { padding: 4px 9px; border-bottom: 1px solid var(--line); text-align: right;
font-family: var(--font-mono); white-space: nowrap; }
td:first-child { text-align: left; }
tbody tr:last-child td { border-bottom: 0; }
tr.sel td { background: var(--accent-soft); }
.rowbtn { font: inherit; font-family: var(--font-mono); background: none; border: 0; padding: 2px 4px;
margin: -2px -4px; color: var(--ink); cursor: pointer; border-radius: 5px; text-align: left; }
.rowbtn:hover { background: var(--accent-soft); }
.rowbtn[aria-pressed="true"] { color: var(--accent); font-weight: 650; }
.tick { color: var(--run); }
.tick.off { visibility: hidden; }
.dim { color: var(--muted); }
@media (max-width: 540px) { .fcol { display: none; } }
/* editor ----------------------------------------------------------------- */
fieldset { border: 0; margin: 0 0 10px; padding: 0; }
legend { padding: 0; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; position: relative; }
/* Absolutely-positioned flex children all resolve to the container's start
corner, so these five 1px radios stack on top of the first chip. Without
pointer-events:none a click on the top-left pixel of "linear scan" lands on
the hidden "runaway loop" radio instead. The labels drive them anyway. */
.chips input { position: absolute; top: 0; left: 0; opacity: 0; width: 1px; height: 1px;
margin: 0; pointer-events: none; }
.chips label { display: inline-block; padding: 5px 10px; border: 1px solid var(--line);
border-radius: 999px; font-size: 12.5px; cursor: pointer; background: var(--bg);
color: var(--muted); user-select: none; }
.chips label:hover { border-color: var(--accent); color: var(--ink); }
.chips input:checked + label { background: var(--accent-soft); border-color: var(--accent);
color: var(--ink); font-weight: 600; }
.chips input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; }
.lbl { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--muted);
margin: 0 0 6px; line-height: 1.45; }
.lbl b { color: var(--ink); font-weight: 600; }
textarea { width: 100%; min-height: 216px; height: 34vh; max-height: 430px; resize: vertical;
font-family: var(--font-mono); font-size: 11.6px; line-height: 1.6; padding: 10px;
border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
color: var(--ink); tab-size: 2; }
.nrow { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 10px 0; }
.nrow label { display: block; font-family: var(--font-mono); font-size: 10px;
text-transform: uppercase; letter-spacing: .09em; color: var(--muted); }
.nrow input { width: 100%; margin-top: 4px; padding: 6px 8px; font-family: var(--font-mono);
font-size: 13px; border: 1px solid var(--line); border-radius: 6px;
background: var(--bg); color: var(--ink); }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
button { font-family: var(--font-sans); font-size: 13.5px; padding: 8px 14px; border-radius: 8px;
border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: .45; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent);
font-weight: 600; }
.hint { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin: 10px 0 0; }
.err { display: none; margin: 10px 0 0; padding: 8px 10px; border: 1px solid var(--danger);
border-radius: 8px; color: var(--danger); font-family: var(--font-mono);
font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
.err.on { display: block; }
footer { margin-top: 22px; font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important;
transition-duration: .001ms !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<main>
<header>
<h1>Empirical Big-O Profiler</h1>
<p class="sub">Don't reason about the complexity of a function — measure it. This runs your
code at geometrically growing input sizes, times each one, and fits the curve.</p>
</header>
<p class="note">
<svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 4.6v4.2M8 11.2v.2"/></svg>
<span><b>Your code runs on your machine.</b> It is executed inside a Web Worker built from a
<code>blob:</code> URL, on a separate thread from this page. Nothing is uploaded — this demo makes
no network requests at all. A runaway function is killed by a 3 s watchdog.</span>
</p>
<div class="grid">
<!-- results (left on desktop, first on mobile: DOM order == visual order) -->
<section class="panel" aria-label="Results">
<div class="verdict" id="verdict">
<div class="v-left">
<span class="eyebrow">best fit</span>
<span id="vBig">—</span>
<span class="match" id="vMatch"> </span>
</div>
<dl class="v-stats">
<div><dt>R²</dt><dd id="vR2">—</dd></div>
<div><dt>exponent p</dt><dd id="vP">—</dd></div>
<div><dt>runner-up</dt><dd id="vNext">—</dd></div>
<div><dt>points</dt><dd id="vPts">0</dd></div>
</dl>
</div>
<div class="prog"><i id="prog"></i></div>
<div class="statusrow">
<span class="chip" id="status" role="status">idle</span>
<span class="chip" id="engine">worker</span>
<span class="chip" id="sep">Δ —</span>
</div>
<p class="hint" id="ambig" hidden></p>
<div class="charthead">
<h2>time per call vs n</h2>
<div class="opts">
<label class="swi"><input type="checkbox" id="logToggle" checked> log–log axes</label>
<label class="swi"><input type="checkbox" id="fitToggle" checked> fit curve</label>
</div>
</div>
<canvas id="chart" tabindex="0" role="img" aria-describedby="readout"
aria-label="Plot of measured time per call against input size n."></canvas>
<p class="readout" id="readout" aria-live="polite">Hover the chart — or focus it and use ← → — to inspect a measurement.</p>
<p class="legend"><span><i class="k-dot"></i> measured</span><span><i class="k-line"></i> fitted model</span></p>
<div class="tabs">
<div>
<h2>candidate models</h2>
<div class="scroll">
<table>
<thead><tr><th>model</th><th class="fcol">fitted form (ms)</th><th>R²</th></tr></thead>
<tbody id="mtBody"></tbody>
</table>
</div>
<p class="hint">Relative-error weighted least squares, so small n counts as much
as large n. Pick a row to overlay its curve.</p>
</div>
<div>
<h2>measurements</h2>
<div class="scroll">
<table>
<thead><tr><th>n</th><th id="tHead">t</th><th>reps × trials</th></tr></thead>
<tbody id="msBody"></tbody>
</table>
</div>
<p class="hint">t is the fastest of k trials; reps are auto-calibrated so every
batch runs ≥ 12 ms.</p>
</div>
</div>
</section>
<!-- editor -->
<section class="panel" aria-label="Function under test">
<h2>function under test</h2>
<fieldset class="chips-wrap">
<legend class="lbl">Examples — pick one and it profiles itself.</legend>
<div class="chips" id="chips"></div>
</fieldset>
<label class="lbl" for="code">
<b>fn(n, data, run)</b> is timed. <b>setup(n)</b> is optional and untimed —
build inputs there so allocation doesn't pollute the measurement.
</label>
<textarea id="code" spellcheck="false" autocapitalize="off" autocorrect="off"
aria-label="JavaScript source for fn(n, data, run)"></textarea>
<div class="nrow">
<label>n from <input type="number" id="nMin" min="1" step="1"></label>
<label>n to <input type="number" id="nMax" min="2" step="1"></label>
<label>points <input type="number" id="nPts" min="4" max="24" step="1"></label>
</div>
<div class="actions">
<button class="primary" id="run">Run profile</button>
<button id="stop" disabled>Stop</button>
</div>
<p class="hint">watchdog 3.0 s · worker terminated on overrun</p>
<p class="err" id="err" role="alert"></p>
</section>
</div>
<footer>demo · sloprun.dev</footer>
</main>
<script>
(function () {
'use strict';
/* ── measurement engine ────────────────────────────────────────────────
Shared source: runs inside the Worker normally, or on the main thread
as an explicit fallback if blob: workers are blocked by policy. */
var ENGINE = [
'function __profile(spec, emit) {',
' var now = function () { return performance.now(); };',
' var RET = "\\n;return { fn: (typeof fn === \\"function\\") ? fn : null, setup: (typeof setup === \\"function\\") ? setup : null };";',
' var mod = (new Function(spec.src + RET))();',
' if (!mod.fn) throw new Error("No function named fn was found. Define: function fn(n, data, run) { ... }");',
' var SINK = 0;',
' for (var i = 0; i < spec.ns.length; i++) {',
' var n = spec.ns[i], t0 = now();',
' var data = mod.setup ? mod.setup(n) : null;',
' SINK = mod.fn(n, data, 0);',
' var reps = 1, dt = 0, a = 0, k = 0;',
' for (;;) {',
' a = now();',
' for (k = 0; k < reps; k++) SINK = mod.fn(n, data, k);',
' dt = now() - a;',
' if (dt >= spec.minBatch || reps >= spec.maxReps) break;',
' var grow = (dt <= 0.05) ? 8 : Math.min(8, Math.max(2, Math.ceil(spec.minBatch / dt)));',
' reps = Math.min(spec.maxReps, reps * grow);',
' }',
' var s = [dt / reps];',
' while (s.length < spec.trials && (now() - t0) < spec.perN) {',
' a = now();',
' for (k = 0; k < reps; k++) SINK = mod.fn(n, data, k);',
' s.push((now() - a) / reps);',
' }',
' s.sort(function (x, y) { return x - y; });',
' emit({ type: "point", n: n, t: s[0], reps: reps, trials: s.length,',
' sink: (typeof SINK === "number" && isFinite(SINK)) ? SINK : 0 });',
' data = null;',
' }',
' emit({ type: "done" });',
'}'
].join('\n');
var WORKER_SRC = ENGINE + '\n' + [
'self.onmessage = function (e) {',
' try { __profile(e.data, function (m) { self.postMessage(m); }); }',
' catch (err) { self.postMessage({ type: "error", message: String((err && err.message) || err) }); }',
'};'
].join('\n');
var workerURL = null;
try {
workerURL = URL.createObjectURL(new Blob([WORKER_SRC], { type: 'text/javascript' }));
} catch (e) { workerURL = null; }
/* ── presets ───────────────────────────────────────────────────────────── */
var PRESETS = [
{ id: 'scan', name: 'linear scan', expect: 'lin', nMin: 1000, nMax: 1000000, pts: 12, code:
'// Sum every element of an n-element array.\n' +
'// setup() builds it untimed, so what we\n' +
'// measure is the scan, not the allocation.\n' +
'function setup(n) {\n' +
' const a = new Int32Array(n);\n' +
' for (let i = 0; i < n; i++)\n' +
' a[i] = (i * 2654435761) % 1000;\n' +
' return a;\n' +
'}\n\n' +
'function fn(n, a, run) {\n' +
' let sum = 0;\n' +
' for (let i = 0; i < n; i++) sum += a[i];\n' +
' return sum;\n' +
'}\n' },
{ id: 'bsearch', name: 'binary search', expect: 'log', nMin: 500, nMax: 2000000, pts: 14, code:
'// Binary search in a sorted array. It is\n' +
'// built untimed in setup(), so only the\n' +
'// probing is measured. `run` moves the target.\n' +
'function setup(n) {\n' +
' const a = new Int32Array(n);\n' +
' for (let i = 0; i < n; i++) a[i] = i * 2;\n' +
' return a;\n' +
'}\n\n' +
'function fn(n, a, run) {\n' +
' const target = (run * 7919) % (2 * n);\n' +
' let lo = 0, hi = n - 1;\n' +
' while (lo <= hi) {\n' +
' const mid = (lo + hi) >> 1;\n' +
' if (a[mid] === target) return mid;\n' +
' if (a[mid] < target) lo = mid + 1;\n' +
' else hi = mid - 1;\n' +
' }\n' +
' return -1;\n' +
'}\n' },
{ id: 'bubble', name: 'bubble sort', expect: 'quad', nMin: 60, nMax: 1400, pts: 10, code:
'// Bubble sort. Each timed call sorts a fresh\n' +
'// copy of the same random array. The O(n) copy\n' +
'// is dwarfed by the sort: the fit stays clean.\n' +
'function setup(n) {\n' +
' const a = new Int32Array(n);\n' +
' let s = 12345;\n' +
' for (let i = 0; i < n; i++) {\n' +
' s = (s * 1103515 + 12345) & 0x7fffffff;\n' +
' a[i] = s % 1000;\n' +
' }\n' +
' return a;\n' +
'}\n\n' +
'function fn(n, src, run) {\n' +
' const a = src.slice();\n' +
' for (let i = 0; i < n; i++)\n' +
' for (let j = 0; j < n - i - 1; j++)\n' +
' if (a[j] > a[j + 1]) {\n' +
' const t = a[j];\n' +
' a[j] = a[j + 1];\n' +
' a[j + 1] = t;\n' +
' }\n' +
' return a[0];\n' +
'}\n' },
{ id: 'fib', name: 'fib (recursive)', expect: 'exp', nMin: 12, nMax: 27, pts: 10, code:
'// The classic naive Fibonacci: two recursive\n' +
'// calls per level. Small n only -- the work\n' +
'// multiplies by ~1.618 with every extra n.\n' +
'function fn(n, data, run) {\n' +
' function fib(k) {\n' +
' if (k < 2) return k;\n' +
' return fib(k - 1) + fib(k - 2);\n' +
' }\n' +
' return fib(n);\n' +
'}\n' },
{ id: 'runaway', name: 'runaway loop', expect: null, nMin: 8, nMax: 64, pts: 6, code:
'// Proof the sandbox works: it never returns.\n' +
'// The worker is terminated after the 3 s\n' +
'// budget; this page stays fully responsive.\n' +
'// Try scrolling while it hangs.\n' +
'function fn(n, data, run) {\n' +
' let x = 0;\n' +
' while (true) x = (x + 1) % 1000;\n' +
' return x;\n' +
'}\n' }
];
/* ── model library ─────────────────────────────────────────────────────── */
var MODELS = [
{ id: 'const', label: 'O(1)', term: '1', cx: 0, basis: function (n) { return 1; } },
{ id: 'log', label: 'O(log n)', term: 'log₂n', cx: 1, basis: function (n) { return Math.log2(n); } },
{ id: 'lin', label: 'O(n)', term: 'n', cx: 2, basis: function (n) { return n; } },
{ id: 'nlogn', label: 'O(n log n)', term: 'n·log₂n', cx: 3, basis: function (n) { return n * Math.log2(n); } },
{ id: 'quad', label: 'O(n²)', term: 'n²', cx: 4, basis: function (n) { return n * n; } },
{ id: 'exp', label: 'O(bⁿ)', term: 'b^n', cx: 5, kind: 'exp' }
];
/* ── dom ───────────────────────────────────────────────────────────────── */
var $ = function (s) { return document.querySelector(s); };
var elVerdict = $('#verdict'), elChips = $('#chips'), elCode = $('#code'), elMin = $('#nMin'), elMax = $('#nMax'),
elPts = $('#nPts'), elRun = $('#run'), elStop = $('#stop'), elErr = $('#err'),
elStatus = $('#status'), elEngine = $('#engine'), elProg = $('#prog'),
elBig = $('#vBig'), elR2 = $('#vR2'), elP = $('#vP'), elNext = $('#vNext'),
elCount = $('#vPts'), elMatch = $('#vMatch'), elMt = $('#mtBody'), elMs = $('#msBody'),
elTHead = $('#tHead'), elReadout = $('#readout'), elLog = $('#logToggle'),
elSep = $('#sep'), elAmbig = $('#ambig'),
elFit = $('#fitToggle'), cv = $('#chart'), ctx = cv.getContext('2d');
/* ── state ─────────────────────────────────────────────────────────────── */
var points = [], fits = [], best = null, expectId = null, selPoint = -1, selModel = null,
userPicked = false, lastPick = null, inlineAck = false, curPreset = null,
unit = { u: 'ms', k: 1 }, worker = null, watchdog = null, running = false,
expected = 0, allowInline = false, usedInline = false;
var BUDGET = 3000;
/* ── helpers ───────────────────────────────────────────────────────────── */
function fmtN(v) {
if (v >= 1e6) return trim((v / 1e6).toFixed(v >= 1e7 ? 0 : 1)) + 'M';
if (v >= 1e3) return trim((v / 1e3).toFixed(v >= 1e4 ? 0 : 1)) + 'k';
return String(v);
}
function trim(s) { return s.indexOf('.') < 0 ? s : s.replace(/\.?0+$/, ''); }
function sig(v) {
var a = Math.abs(v);
if (a >= 1000) return v.toFixed(0);
if (a >= 100) return v.toFixed(1);
if (a >= 10) return v.toFixed(2);
if (a >= 1) return v.toFixed(3);
return v.toFixed(4);
}
function expo(v) {
if (v === 0) return '0';
var a = Math.abs(v);
if (a >= 0.001 && a < 10000) return trim(v.toPrecision(3));
return v.toExponential(2);
}
function unitFor(maxT) {
if (!(maxT > 0)) return { u: 'ms', k: 1 };
if (maxT >= 1) return { u: 'ms', k: 1 };
if (maxT >= 1e-3) return { u: 'µs', k: 1e3 };
return { u: 'ns', k: 1e6 };
}
function geom(a, b, k) {
var out = [], r = Math.pow(b / a, 1 / (k - 1)), i, v;
for (i = 0; i < k; i++) {
v = Math.max(1, Math.round(a * Math.pow(r, i)));
if (out.length === 0 || out[out.length - 1] !== v) out.push(v);
}
if (out[out.length - 1] !== b) out.push(b);
return out;
}
function css(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888';
}
/* ── fitting ───────────────────────────────────────────────────────────── */
function fitAll(pts) {
var n = pts.map(function (p) { return p.n; });
var t = pts.map(function (p) { return Math.max(p.t, 1e-7); });
var i, w, sw = 0, st = 0;
for (i = 0; i < t.length; i++) { w = 1 / (t[i] * t[i]); sw += w; st += w * t[i]; }
var tbar = st / sw, sst = 0;
for (i = 0; i < t.length; i++) { w = 1 / (t[i] * t[i]); sst += w * (t[i] - tbar) * (t[i] - tbar); }
var out = MODELS.map(function (m) {
var at, form, ok = true;
if (m.kind === 'exp') {
var N = n.length, sx = 0, sy = 0, sxx = 0, sxy = 0, x, y;
for (i = 0; i < N; i++) { x = n[i]; y = Math.log(t[i]); sx += x; sy += y; sxx += x * x; sxy += x * y; }
var den = N * sxx - sx * sx;
var B = den ? (N * sxy - sx * sy) / den : 0;
var A = (sy - B * sx) / N;
var aa = Math.exp(A), bb = Math.exp(B);
if (!(B > 1e-9) || !isFinite(aa)) ok = false;
at = function (v) { return aa * Math.pow(bb, v); };
form = 't ≈ ' + expo(aa) + '·' + (isFinite(bb) ? bb.toFixed(3) : '?') + 'ⁿ';
m.base = bb;
} else if (m.id === 'const') {
var c = tbar;
at = function () { return c; };
form = 't ≈ ' + expo(c);
} else {
var sf = 0, sff = 0, sft = 0, f;
for (i = 0; i < n.length; i++) {
w = 1 / (t[i] * t[i]); f = m.basis(n[i]);
sf += w * f; sff += w * f * f; sft += w * f * t[i];
}
var det = sw * sff - sf * sf;
var a1 = det ? (sw * sft - sf * st) / det : 0;
var b1 = det ? (sff * st - sf * sft) / det : tbar;
if (!(a1 > 0) || !isFinite(a1)) { a1 = 0; b1 = tbar; ok = false; }
at = function (v) { return a1 * m.basis(v) + b1; };
form = 't ≈ ' + expo(a1) + '·' + m.term +
(b1 >= 0 ? ' + ' + expo(b1) : ' − ' + expo(-b1));
}
var ssr = 0, finite = true, pr;
for (i = 0; i < n.length; i++) {
pr = at(n[i]);
if (!isFinite(pr)) { finite = false; break; }
w = 1 / (t[i] * t[i]);
ssr += w * (t[i] - pr) * (t[i] - pr);
}
var r2 = (!ok || !finite || !(sst > 0)) ? 0 : 1 - ssr / sst;
if (!isFinite(r2) || r2 < 0) r2 = 0;
return { model: m, r2: r2, form: form, at: at, ok: ok && finite };
});
out.sort(function (x, y) { return y.r2 - x.r2 || x.model.cx - y.model.cx; });
return out;
}
// empirical exponent p from log-log OLS: t ~ n^p
function exponent(pts) {
var N = pts.length, sx = 0, sy = 0, sxx = 0, sxy = 0, i, x, y;
for (i = 0; i < N; i++) {
x = Math.log(pts[i].n); y = Math.log(Math.max(pts[i].t, 1e-7));
sx += x; sy += y; sxx += x * x; sxy += x * y;
}
var den = N * sxx - sx * sx;
return den ? (N * sxy - sx * sy) / den : 0;
}
// Constant time is the one case least-squares cannot see: when t never grows,
// every model degenerates and the "winner" is whichever one best traces the
// noise. So test it directly — compare the median of the smallest third of n
// against the median of the largest third. Medians shrug off the odd spike
// from a GC pause or a busy core.
function flatness(pts) {
if (pts.length < 4) return null;
var span = pts[pts.length - 1].n / pts[0].n;
if (!(span > 20)) return null;
var k = Math.max(1, Math.floor(pts.length / 3));
var med = function (arr) {
var v = arr.map(function (p) { return p.t; }).sort(function (a, b) { return a - b; });
return v[v.length >> 1];
};
var head = med(pts.slice(0, k)), tail = med(pts.slice(pts.length - k));
if (!(head > 0)) return null;
var ratio = tail / head;
// A band, not a ceiling. A series that gets *faster* as n grows is a
// measurement artifact (JIT warm-up, a cold cache on the first points),
// not constant time — let the normal fitter call that "no clear fit"
// instead of claiming a flat line the data does not show.
if (!(ratio > 0.65)) return null;
// And "barely grew" is relative to the range. Over n = 1e6 … 1e8 an honest
// O(log n) only grows ×1.33, so a fixed ceiling would report it as O(1).
// Compare against what log n would have done over *this* span instead, and
// only claim flat when the curve stayed nearer 1 than that.
var logGrowth = Math.log(pts[pts.length - 1].n) / Math.log(Math.max(2, pts[0].n));
var ceil = Math.min(1.5, 1 + Math.max(0.06, (logGrowth - 1) * 0.5));
if (!(ratio < ceil)) return null;
return { ratio: ratio, span: span };
}
function pickBest(list, pts) {
var i;
var f = flatness(pts);
if (f) {
for (i = 0; i < list.length; i++)
if (list[i].model.id === 'const') return { fit: list[i], flat: true, span: f.span, ratio: f.ratio };
}
var top = list[0], cand = top;
for (i = 1; i < list.length; i++) {
if (list[i].r2 >= top.r2 - 0.0015 && list[i].r2 > 0.9 &&
list[i].model.cx < cand.model.cx) cand = list[i];
}
return { fit: cand, flat: false, top: top };
}
/* ── rendering ─────────────────────────────────────────────────────────── */
function setStatus(text, kind) {
elStatus.textContent = text;
elStatus.className = 'chip' + (kind ? ' ' + kind : '');
}
function showErr(msg) {
if (!msg) { elErr.className = 'err'; elErr.textContent = ''; return; }
elErr.className = 'err on';
elErr.textContent = msg;
}
function renderTables() {
var i, tr, td, btn, r, html = '';
// This table is rebuilt after every measurement, so a keyboard user who is
// sitting on a model row would otherwise lose focus mid-run.
var act = document.activeElement;
var refocus = (act && act.className === 'rowbtn') ? act.getAttribute('data-id') : null;
unit = unitFor(points.length ? Math.max.apply(null, points.map(function (p) { return p.t; })) : 0);
// unit.u is one of 'ms' | 'µs' | 'ns' — literals, never user input.
elTHead.innerHTML = 't <span class="u">(' + unit.u + '/call)</span>';
for (i = 0; i < points.length; i++) {
html += '<tr><td>' + points[i].n.toLocaleString('en-US') + '</td><td>' +
sig(points[i].t * unit.k) + '</td><td class="dim">' +
fmtN(points[i].reps) + '×' + points[i].trials + '</td></tr>';
}
elMs.innerHTML = html || '<tr><td colspan="3" class="dim">' +
(running ? 'measuring…' : 'no measurements yet') + '</td></tr>';
elMt.innerHTML = '';
if (!fits.length) {
elMt.innerHTML = '<tr><td colspan="3" class="dim">' +
(points.length ? 'need 4 or more sizes of n to fit' : 'run to fit models') + '</td></tr>';
return;
}
for (i = 0; i < fits.length; i++) {
r = fits[i];
tr = document.createElement('tr');
if (selModel === r.model.id) tr.className = 'sel';
td = document.createElement('td');
btn = document.createElement('button');
btn.className = 'rowbtn';
btn.type = 'button';
btn.setAttribute('aria-pressed', selModel === r.model.id ? 'true' : 'false');
btn.textContent = (best && best.model.id === r.model.id ? '✓ ' : ' ') + r.model.label;
if (best && best.model.id === r.model.id) {
btn.innerHTML = '<span class="tick">✓</span> ' + r.model.label;
btn.setAttribute('aria-label', r.model.label + ' — best fit');
}
btn.setAttribute('data-id', r.model.id);
btn.addEventListener('click', function () {
userPicked = true;
selModel = this.getAttribute('data-id');
// renderTables() rebuilds these rows and puts focus back on the
// replacement node, so a keyboard user is not dumped to the top.
renderTables(); draw();
});
td.appendChild(btn); tr.appendChild(td);
td = document.createElement('td');
td.className = 'fcol dim'; td.textContent = r.ok ? r.form : 'no growth fit';
tr.appendChild(td);
td = document.createElement('td');
td.textContent = r.ok ? r.r2.toFixed(4) : '—';
tr.appendChild(td);
elMt.appendChild(tr);
}
if (refocus) {
var back = elMt.querySelector('.rowbtn[data-id="' + refocus + '"]');
if (back) { try { back.focus({ preventScroll: true }); } catch (e) { back.focus(); } }
}
}
function renderVerdict(flat, info) {
elCount.textContent = String(points.length);
if (!best) {
elBig.textContent = '—'; elR2.textContent = '—';
elP.textContent = '—'; elNext.textContent = '—';
elMatch.textContent = ' '; elMatch.className = 'match';
elSep.textContent = 'Δ —'; elSep.hidden = false;
if (points.length && !running) {
elAmbig.hidden = false;
elAmbig.textContent = 'Only ' + points.length + ' distinct value' +
(points.length === 1 ? '' : 's') + ' of n came back — the fitter needs at least 4. ' +
'Widen the gap between “n from” and “n to”, or raise “points”.';
} else { elAmbig.hidden = true; elAmbig.textContent = ''; }
elVerdict.className = 'verdict';
// Without this the canvas keeps describing the *previous* run's verdict
// to a screen reader for the whole of the next one.
cv.setAttribute('aria-label', points.length
? 'Plot of ' + points.length + ' measurements of time per call against input size n. No fit yet.'
: 'Plot of time per call against input size n. No measurements yet.');
return;
}
var label = best.model.label;
if (best.model.id === 'exp' && isFinite(best.model.base)) {
label = 'O(' + best.model.base.toFixed(2) + 'ⁿ)';
}
var noFit = !flat && best.r2 < 0.2;
elBig.textContent = noFit ? 'no clear fit' : label;
elR2.textContent = flat ? 'flat' : best.r2.toFixed(4);
elP.textContent = exponent(points).toFixed(2);
var next = null, i;
for (i = 0; i < fits.length; i++) if (fits[i].model.id !== best.model.id) { next = fits[i]; break; }
elNext.textContent = next ? next.model.label + ' ' + next.r2.toFixed(3) : '—';
if (next && !flat) {
var gap = Math.abs(best.r2 - next.r2);
elSep.hidden = false;
elSep.textContent = 'Δ ' + gap.toFixed(3) + ' · ' + (gap < 0.02 ? 'close call' : 'clear');
if (best.r2 < 0.75) {
elAmbig.hidden = false;
elAmbig.textContent = 'Best R² is only ' + best.r2.toFixed(3) + ' — nothing in this library ' +
'really fits. The timings are probably dominated by something else: cache behaviour, ' +
'garbage collection, JIT warm-up, or plain noise from other tabs.';
} else if (best.r2 < 0.9 && gap >= 0.02) {
elAmbig.hidden = false;
elAmbig.textContent = best.model.label + ' still wins clearly, but at R² ' + best.r2.toFixed(3) +
' the curve is noisy — something is disturbing the timings (garbage collection, cache ' +
'effects, or a busy machine). Run it again for a cleaner reading.';
} else if (gap < 0.02) {
elAmbig.hidden = false;
elAmbig.textContent = best.model.label + ' and ' + next.model.label + ' fit within ' +
gap.toFixed(3) + ' R² of each other — over this range of n they are not distinguishable. ' +
'Widen the range of n, or add points.';
} else { elAmbig.hidden = true; elAmbig.textContent = ''; }
} else if (flat && info && info.span) {
elSep.hidden = false;
elSep.textContent = 'flat · ' + fmtN(Math.round(info.span)) + '× range';
elAmbig.hidden = false;
elAmbig.textContent = 'n grew ' + fmtN(Math.round(info.span)) + '× and the time per call changed by only ×' +
info.ratio.toFixed(2) + '. That is what constant time looks like — no curve fit can beat a flat line here.';
} else { elSep.hidden = true; elAmbig.hidden = true; elAmbig.textContent = ''; }
elVerdict.className = 'verdict' + (!flat && best.r2 < 0.75 ? ' weak' : '') +
(noFit ? ' nofit' : '');
if (expectId) {
if (expectId === best.model.id) {
elMatch.textContent = '✓ matches expected';
elMatch.className = 'match ok';
} else {
var exp = MODELS.filter(function (m) { return m.id === expectId; })[0];
elMatch.textContent = 'expected ' + (exp ? exp.label : expectId);
elMatch.className = 'match';
}
} else {
elMatch.textContent = ' ';
elMatch.className = 'match';
}
cv.setAttribute('aria-label',
'Chart of ' + points.length + ' measurements of time per call against input size n, ' +
(elLog.checked ? 'log-log axes' : 'linear axes') + '. ' +
(noFit ? 'No model in the library fits' : 'Best fit ' + label) +
(flat ? ', times flat across the range.' : ', R squared ' + best.r2.toFixed(4) + '.'));
}
/* ── chart ─────────────────────────────────────────────────────────────── */
function ticksLin(lo, hi, count) {
var span = hi - lo;
if (!(span > 0)) return [lo];
var raw = span / count, mag = Math.pow(10, Math.floor(Math.log10(raw))), norm = raw / mag;
var step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag;
var out = [], v;
for (v = Math.ceil(lo / step) * step; v <= hi + step * 1e-9; v += step) out.push(v);
return out;
}
function ticksLog(lo, hi) {
var d0 = Math.floor(Math.log10(lo)), d1 = Math.ceil(Math.log10(hi)), out = [], d, m;
var decades = d1 - d0;
var mults = decades <= 2 ? [1, 2, 5] : decades <= 5 ? [1, 3] : [1];
for (d = d0; d <= d1; d++) {
for (m = 0; m < mults.length; m++) {
var v = mults[m] * Math.pow(10, d);
if (v >= lo * 0.999 && v <= hi * 1.001) out.push(v);
}
}
return out.length ? out : [lo, hi];
}
var lastX = null;
function draw() {
var w = cv.clientWidth, h = cv.clientHeight;
if (!w || !h) return;
var dpr = Math.min(2, window.devicePixelRatio || 1);
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// draw() runs after every state change, so this is the one place guaranteed
// to see the current point count.
if (selPoint < 0) elReadout.textContent = readoutHint();
var ink = css('--ink'), muted = css('--muted'), line = css('--line'),
accent = css('--accent'), surface = css('--surface'), grid = css('--grid');
var mono = '10px ' + css('--font-mono');
var narrow = w < 380;
var padL = narrow ? 48 : 54, padR = 12, padT = 12, padB = 26;
var pw = Math.max(10, w - padL - padR), ph = Math.max(10, h - padT - padB);
if (!points.length) {
ctx.fillStyle = muted; ctx.font = mono; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(running ? 'measuring…' : 'no measurements yet', w / 2, h / 2);
return;
}
var logMode = elLog.checked;
var ns = points.map(function (p) { return p.n; });
var ts = points.map(function (p) { return Math.max(p.t, 1e-9) * unit.k; });
var nLo = Math.min.apply(null, ns), nHi = Math.max.apply(null, ns);
var tLo = Math.min.apply(null, ts), tHi = Math.max.apply(null, ts);
var xLo, xHi, yLo, yHi, xt, yt;
if (logMode) {
xLo = nLo / 1.25; xHi = nHi * 1.25;
yLo = tLo / 1.6; yHi = tHi * 1.6;
xt = ticksLog(xLo, xHi); yt = ticksLog(yLo, yHi);
} else {
xLo = 0; xHi = nHi * 1.04;
yLo = 0; yHi = tHi * 1.1 || 1;
xt = ticksLin(xLo, xHi, narrow ? 3 : 5); yt = ticksLin(yLo, yHi, 4);
}
var X = lastX = function (v) {
if (logMode) return padL + (Math.log10(Math.max(v, 1e-12)) - Math.log10(xLo)) / (Math.log10(xHi) - Math.log10(xLo)) * pw;
return padL + (v - xLo) / (xHi - xLo) * pw;
};
var Y = function (v) {
if (logMode) return padT + ph - (Math.log10(Math.max(v, 1e-12)) - Math.log10(yLo)) / (Math.log10(yHi) - Math.log10(yLo)) * ph;
return padT + ph - (v - yLo) / (yHi - yLo) * ph;
};
// grid + ticks
ctx.font = mono; ctx.lineWidth = 1;
ctx.strokeStyle = grid;
ctx.beginPath();
yt.forEach(function (v) { var y = Math.round(Y(v)) + 0.5; ctx.moveTo(padL, y); ctx.lineTo(padL + pw, y); });
xt.forEach(function (v) { var x = Math.round(X(v)) + 0.5; ctx.moveTo(x, padT); ctx.lineTo(x, padT + ph); });
ctx.stroke();
ctx.fillStyle = muted; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
yt.forEach(function (v) {
var y = Y(v);
if (y < padT - 2 || y > padT + ph + 2) return;
ctx.fillText(logMode ? fmtNum(v) : trim(v.toPrecision(3)), padL - 6, y);
});
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
xt.forEach(function (v) {
var x = X(v);
if (x < padL - 2 || x > padL + pw + 2) return;
ctx.fillText(fmtN(Math.round(v)), x, padT + ph + 7);
});
// axis frame
ctx.strokeStyle = line;
ctx.beginPath();
ctx.moveTo(padL + 0.5, padT); ctx.lineTo(padL + 0.5, padT + ph + 0.5); ctx.lineTo(padL + pw, padT + ph + 0.5);
ctx.stroke();
// axis titles
ctx.fillStyle = muted; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(unit.u + '/call', padL + 3, padT - 1);
ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
ctx.fillText('n', padL + pw, padT + ph - 2);
// fitted curve
var cur = null, i;
if (fits.length) {
for (i = 0; i < fits.length; i++) if (fits[i].model.id === selModel) cur = fits[i];
if (!cur) cur = best;
}
if (cur && cur.ok && elFit.checked) {
ctx.strokeStyle = muted; ctx.lineWidth = 1.6; ctx.setLineDash([5, 4]);
ctx.beginPath();
var steps = 140, started = false;
for (i = 0; i <= steps; i++) {
var f = i / steps;
var nv = logMode ? Math.pow(10, Math.log10(xLo) + f * (Math.log10(xHi) - Math.log10(xLo)))
: xLo + f * (xHi - xLo);
if (nv < 1) continue;
var tv = cur.at(nv) * unit.k;
if (!isFinite(tv) || tv <= 0) { started = false; continue; }
var px = X(nv), py = Y(tv);
if (py < padT - 400 || py > padT + ph + 400) { started = false; continue; }
if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py);
}
ctx.stroke(); ctx.setLineDash([]);
}
// points
for (i = 0; i < points.length; i++) {
var px2 = X(ns[i]), py2 = Y(ts[i]);
if (i === selPoint) {
ctx.strokeStyle = accent; ctx.lineWidth = 1; ctx.setLineDash([2, 3]);
ctx.beginPath();
ctx.moveTo(padL, py2); ctx.lineTo(px2, py2); ctx.lineTo(px2, padT + ph);
ctx.stroke(); ctx.setLineDash([]);
}
ctx.beginPath(); ctx.arc(px2, py2, i === selPoint ? 5.5 : 3.6, 0, Math.PI * 2);
ctx.fillStyle = accent; ctx.fill();
ctx.lineWidth = 1.5; ctx.strokeStyle = surface; ctx.stroke();
}
ctx.fillStyle = ink;
}
function fmtNum(v) {
if (v >= 1000) return fmtN(Math.round(v));
if (v >= 1) return trim(v.toPrecision(3));
return trim(v.toPrecision(2));
}
/* ── chart interaction ─────────────────────────────────────────────────── */
function readoutHint() {
if (points.length) return 'Hover the chart — or focus it and use ← → — to inspect a measurement.';
return running ? 'Measuring…' : 'No measurements yet — press Run profile.';
}
function setSel(i) {
selPoint = i;
if (i < 0 || !points[i]) {
elReadout.textContent = readoutHint();
} else {
var p = points[i];
var predTxt = '';
var cur = null, j;
for (j = 0; j < fits.length; j++) if (fits[j].model.id === (selModel || (best && best.model.id))) cur = fits[j];
if (cur && cur.ok) {
var pv = cur.at(p.n);
if (isFinite(pv) && pv > 0) {
predTxt = ' · ' + cur.model.label + ' predicts ' + sig(pv * unit.k) + ' ' + unit.u +
' (' + (p.t > 0 ? ((p.t / pv - 1) * 100 >= 0 ? '+' : '') + ((p.t / pv - 1) * 100).toFixed(1) : '0') + '%)';
}
}
elReadout.innerHTML = 'n = <b>' + p.n.toLocaleString('en-US') + '</b> · <b>' +
sig(p.t * unit.k) + ' ' + unit.u + '</b>/call · ' + fmtN(p.reps) + ' reps × ' +
p.trials + ' trials' + predTxt;
}
draw();
}
function nearest(clientX) {
if (!points.length || !lastX) return -1;
var r = cv.getBoundingClientRect();
var x = (clientX - r.left) * (cv.clientWidth / Math.max(1, r.width));
var bestI = 0, bestD = Infinity, i, d;
for (i = 0; i < points.length; i++) {
d = Math.abs(lastX(points[i].n) - x);
if (d < bestD) { bestD = d; bestI = i; }
}
return bestI;
}
var scrubbing = false;
cv.addEventListener('pointerdown', function (e) {
scrubbing = true;
if (e.pointerType !== 'mouse') { try { cv.focus(); } catch (err) {} }
setSel(nearest(e.clientX));
});
cv.addEventListener('pointermove', function (e) {
if (e.pointerType === 'mouse' || scrubbing) setSel(nearest(e.clientX));
});
cv.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'mouse' && !scrubbing) setSel(-1);
});
cv.addEventListener('pointercancel', function () { scrubbing = false; });
window.addEventListener('pointerup', function () { scrubbing = false; });
cv.addEventListener('keydown', function (e) {
if (!points.length) return;
var k = e.key, i = selPoint;
if (k === 'ArrowRight' || k === 'ArrowUp') i = (i < 0 ? -1 : i) + 1;
else if (k === 'ArrowLeft' || k === 'ArrowDown') i = (i < 0 ? points.length : i) - 1;
else if (k === 'Home') i = 0;
else if (k === 'End') i = points.length - 1;
else if (k === 'Escape') i = -1;
else return;
e.preventDefault();
setSel(Math.max(-1, Math.min(points.length - 1, i)));
});
/* ── run control ───────────────────────────────────────────────────────── */
function clampInputs() {
var a = Math.max(1, Math.round(Number(elMin.value) || 1));
var b = Math.max(a + 1, Math.round(Number(elMax.value) || a + 1));
var k = Math.max(4, Math.min(24, Math.round(Number(elPts.value) || 10)));
elMin.value = a; elMax.value = b; elPts.value = k;
return { a: a, b: b, k: k };
}
function finish(kind, text) {
running = false;
clearTimeout(watchdog); watchdog = null;
if (worker) { try { worker.terminate(); } catch (e) {} worker = null; }
elRun.disabled = false; elStop.disabled = true;
setStatus(text, kind);
}
function analyse() {
if (points.length >= 4) {
fits = fitAll(points);
var pick = pickBest(fits, points);
best = pick.fit; lastPick = pick;
// The overlay follows the verdict as it firms up, unless the visitor
// has explicitly picked a model to compare against.
if (!userPicked || !fits.some(function (f) { return f.model.id === selModel; })) selModel = best.model.id;
renderVerdict(pick.flat, pick);
} else {
fits = []; best = null; selModel = null; lastPick = null;
renderVerdict(false);
}
renderTables();
draw();
}
function handle(msg) {
if (msg.type === 'point') {
points.push({ n: msg.n, t: msg.t, reps: msg.reps, trials: msg.trials });
elProg.style.width = Math.round(points.length / Math.max(1, expected) * 100) + '%';
setStatus('profiling · ' + points.length + '/' + expected + ' · n=' + fmtN(msg.n), 'busy');
unit = unitFor(Math.max.apply(null, points.map(function (p) { return p.t; })));
// analyse() handles the <4 case itself, and it is what keeps the
// "points" counter in the verdict in step with the table.
analyse();
} else if (msg.type === 'done') {
// finish() first: it settles `running`, which the verdict reads when it
// has to explain why too few points came back.
finish('ok', '✓ ' + points.length + ' points measured' + (usedInline ? ' (main thread)' : ''));
analyse();
} else if (msg.type === 'error') {
showErr(msg.message);
finish('bad', 'error in your function');
analyse();
}
}
function onOverrun() {
var got = points.length;
finish('bad', 'watchdog · worker terminated at 3.0 s');
showErr('Time budget exceeded: fn() did not return within 3.0 s.\n' +
'The worker was terminated' + (got ? ' after ' + got + ' point(s).' : '.') +
' This page was never blocked.');
analyse();
}
function startInline(spec) {
usedInline = true;
elEngine.textContent = 'main thread';
running = true; elRun.disabled = true; elStop.disabled = false;
setStatus('running on main thread…', 'busy');
// The profile is synchronous and blocks the page, so hand the browser one
// frame to paint that status before it locks up.
setTimeout(function () {
if (!running) return;
var batch = [];
try {
var fnp = new Function(ENGINE + '\n;return __profile;')();
fnp(spec, function (m) { batch.push(m); });
} catch (err) {
batch.push({ type: 'error', message: String((err && err.message) || err) });
}
for (var i = 0; i < batch.length; i++) handle(batch[i]);
if (running) finish('ok', '✓ ' + points.length + ' points measured (main thread)');
}, 30);
}
// Main-thread execution has no watchdog: a runaway loop freezes the tab.
// That is fine for a preset we shipped and know terminates (expectId is set
// only while the editor still holds unmodified preset source); for anything
// the visitor typed, make them ask for it once, explicitly.
function inlineGuard(spec) {
if (curPreset === 'runaway') {
setStatus('needs a worker', 'bad');
showErr('The runaway-loop preset exists to show the watchdog killing a Web Worker, and ' +
'Workers are unavailable on this page. On the page’s own thread it would hang this ' +
'tab with nothing able to stop it, so it is not run. Pick another example.');
return false;
}
if (expectId) { startInline(spec); return true; }
if (inlineAck) { startInline(spec); return true; }
inlineAck = true;
setStatus('no worker here — press Run again', 'bad');
showErr('Web Workers are unavailable on this page, so your function would have to run on ' +
'the page’s own thread.\nPress Run again to do that — but note the 3.0 s watchdog can ' +
'only terminate a worker, so a function that never returns will freeze this tab.');
return false;
}
function run() {
if (running) finish(null, 'restarting…');
showErr('');
var c = clampInputs();
var ns = geom(c.a, c.b, c.k);
points = []; fits = []; best = null; selPoint = -1; selModel = null; lastPick = null;
userPicked = false; expected = ns.length;
elProg.style.width = '0%';
renderVerdict(false); renderTables(); setSel(-1); draw();
var spec = { src: elCode.value, ns: ns, minBatch: 12, maxReps: 1 << 25, trials: 7, perN: 420 };
if (!workerURL || allowInline) { inlineGuard(spec); return; }
try {
worker = new Worker(workerURL);
} catch (err) {
// A platform CSP of default-src 'none' blocks blob: workers outright.
// Falling straight through to the main thread keeps the demo alive
// instead of opening on a dead error state.
allowInline = true;
showErr('');
if (!inlineGuard(spec)) {
setStatus('blob: workers blocked here — press Run again', 'bad');
}
return;
}
usedInline = false;
elEngine.textContent = 'worker';
running = true; elRun.disabled = true; elStop.disabled = false;
setStatus('starting worker…', 'busy');
worker.onmessage = function (e) { arm(); handle(e.data); };
worker.onerror = function (e) {
if (e && e.preventDefault) e.preventDefault();
if (!points.length) {
allowInline = true;
finish(null, 'worker unavailable');
showErr('');
if (!inlineGuard(spec)) setStatus('worker unavailable — press Run again', 'bad');
} else {
finish('bad', 'worker error');
showErr(String((e && e.message) || 'worker error'));
}
};
arm();
worker.postMessage(spec);
}
function arm() {
clearTimeout(watchdog);
watchdog = setTimeout(onOverrun, BUDGET);
}
elRun.addEventListener('click', run);
elStop.addEventListener('click', function () {
if (!running) return;
finish(null, 'stopped · ' + points.length + ' points kept');
analyse();
});
elLog.addEventListener('change', function () {
renderVerdict(lastPick ? lastPick.flat : false, lastPick); draw();
});
elFit.addEventListener('change', draw);
[elMin, elMax, elPts].forEach(function (el) {
el.addEventListener('change', clampInputs);
});
elCode.addEventListener('input', function () {
expectId = null; curPreset = null;
var checked = elChips.querySelector('input:checked');
if (checked) checked.checked = false;
elMatch.textContent = ' '; elMatch.className = 'match';
});
/* ── presets ui ────────────────────────────────────────────────────────── */
PRESETS.forEach(function (p, i) {
var input = document.createElement('input');
input.type = 'radio'; input.name = 'preset'; input.id = 'p-' + p.id; input.value = p.id;
var label = document.createElement('label');
label.setAttribute('for', 'p-' + p.id);
label.textContent = p.name;
input.addEventListener('change', function () { load(p); run(); });
elChips.appendChild(input); elChips.appendChild(label);
});
function load(p) {
curPreset = p.id;
elCode.value = p.code;
elMin.value = p.nMin; elMax.value = p.nMax; elPts.value = p.pts;
expectId = p.expect;
var el = document.getElementById('p-' + p.id);
if (el) el.checked = true;
}
/* ── theme + resize ────────────────────────────────────────────────────── */
window.addEventListener('message', function (e) {
var d = e.data;
if (d && d.type === 'sloprun:theme' && (d.theme === 'light' || d.theme === 'dark')) {
document.documentElement.setAttribute('data-theme', d.theme);
draw();
}
});
try {
var mq = window.matchMedia('(prefers-color-scheme: dark)');
if (mq.addEventListener) mq.addEventListener('change', draw);
else if (mq.addListener) mq.addListener(draw);
} catch (e) {}
if (window.MutationObserver) {
new MutationObserver(function () { draw(); })
.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
}
if (window.ResizeObserver) new ResizeObserver(function () { draw(); }).observe(cv);
else window.addEventListener('resize', draw);
/* ── boot ──────────────────────────────────────────────────────────────── */
renderTables();
load(PRESETS[0]);
draw();
setStatus('warming up…', 'busy');
setTimeout(run, 90);
})();
</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.