Prime Spiral
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Write the whole numbers in a square spiral — 1 in the middle, then round and round — and light up only the primes. They are supposed to be unpredictable, so the picture should be static; instead it comes out streaked with diagonals, which is what Stanisław Ulam saw when he doodled one through a dull talk in 1963. Every diagonal is a quadratic, so pick Euler's n² + n + 41 in the trace a formula panel, hit Start the spiral at 41, and its values collapse from a pair of lazy arcs onto one perfectly straight amber line — then switch to n² + n + 9 and watch the same line go dark. Zoom in far enough to read the actual integers, or click any cell for its factorisation, its rank among the primes, and the gaps to its neighbours.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Prime Spiral — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local additions (scrims need alpha; tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.9); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap { max-width: 1140px; 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: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede code, code.m {
font-family: var(--font-mono); font-size: .87em; color: var(--accent);
background: var(--accent-soft); border-radius: 4px; padding: 1px 5px;
}
.stage { display: grid; grid-template-columns: minmax(0,1fr) 300px; gap: 16px; align-items: start; }
@media (max-width: 900px) { .stage { grid-template-columns: minmax(0,1fr); } }
.left { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 9px 11px; }
.tbsep { width: 1px; align-self: stretch; background: var(--line); margin: 0 3px; }
@media (max-width: 520px) { .tbsep { display: none; } }
.zoomgrp { display: inline-flex; align-items: center; gap: 6px; }
.zoomgrp .lv {
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
letter-spacing: .04em; min-width: 9ch; text-align: center; font-variant-numeric: tabular-nums;
}
.tbnote { font-size: 12px; color: var(--muted); margin-left: auto; }
@media (max-width: 640px) { .tbnote { display: none; } }
.field {
position: relative; overflow: hidden;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); box-shadow: var(--shadow);
height: clamp(360px, calc(100vh - 296px), 700px);
}
@media (max-width: 900px) { .field { aspect-ratio: 4 / 3; height: auto; } }
@media (max-width: 560px) { .field { aspect-ratio: 1 / 1; } }
canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; }
#trace, #cursor { pointer-events: none; }
#base { touch-action: manipulation; cursor: crosshair; outline: none; }
#base:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
#base.ptr-focus:focus-visible { outline: none; }
.hint {
position: absolute; left: 10px; bottom: 10px; pointer-events: none; z-index: 2;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
color: var(--muted); background: var(--scrim);
border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
transition: opacity .5s ease; max-width: calc(100% - 20px);
}
.hint.gone { opacity: 0; }
.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;
}
.stats { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 11px 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v { font-family: var(--font-mono); font-size: 19px; line-height: 1.05; font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
.stat .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .v.hi { color: var(--accent); }
.stat .k { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
.pill {
display: inline-flex; align-items: center; gap: 6px; margin-top: 11px;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
padding: 3px 9px 3px 7px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--bg);
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.on { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.on .dot { background: var(--run); }
.cap { margin: 9px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
.cap.tight { margin-top: 7px; }
/* inspector */
.insp-top { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; }
.insp-n { font-family: var(--font-mono); font-size: 27px; line-height: 1; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
.badge {
font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); background: var(--bg);
}
.badge.prime { color: var(--accent); background: var(--accent-soft); border-color: transparent; font-weight: 600; }
.facts { margin: 11px 0 0; display: grid; grid-template-columns: auto minmax(0,1fr); gap: 5px 10px; }
.facts dt { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); padding-top: 2px; }
.facts dd { margin: 0; font-family: var(--font-mono); font-size: 12px; line-height: 1.35; word-break: break-word; font-variant-numeric: tabular-nums; }
.facts dd .dim { color: var(--muted); }
/* buttons */
button {
font: inherit; font-size: 13px; font-family: var(--font-sans);
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px;
padding: 7px 11px; cursor: pointer; line-height: 1.2;
transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .4; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
button.icon { font-family: var(--font-mono); padding: 6px 10px; font-size: 14px; line-height: 1; }
button.wide { width: 100%; margin-top: 10px; }
.formulas { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; }
.formulas button { font-family: var(--font-mono); font-size: 12px; padding: 7px 4px; text-align: center; }
.formulas button.off { grid-column: 1 / -1; font-family: var(--font-sans); font-size: 12.5px; }
.formulas button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); font-weight: 600; }
.legend { display: flex; flex-wrap: wrap; align-items: center; gap: 5px 12px; margin-top: 11px; }
.legend.off { opacity: .38; }
.lgk { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
.lg { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: .01em; }
.sw { width: 11px; height: 11px; border-radius: 50%; flex: none; box-sizing: border-box; }
.sw.hit { border: 1.6px solid var(--accent); background: var(--accent); }
.sw.miss { border: 1.6px dashed var(--muted); opacity: .75; }
.fline { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-top: 11px; }
.fline .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
.fline .val { font-family: var(--font-mono); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; }
.fline .val.hi { color: var(--accent); }
.prose { margin: 0; font-size: 12.5px; line-height: 1.6; color: var(--muted); }
.prose + .prose { margin-top: 9px; }
.prose b { color: var(--ink); font-weight: 600; }
.prose code { font-family: var(--font-mono); font-size: .9em; color: var(--accent); }
footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em; padding-top: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Prime Spiral</h1>
<p class="lede">
Write the whole numbers in a square spiral — 1 in the middle, then round and round —
and light up only the <b>primes</b>. They should be scattered. Instead they fall into
<b>diagonal streaks</b>. Every streak is a formula like <code>n² + n + 41</code>;
pick one on the right, restart the spiral at its constant, and watch it snap onto a
single straight line.
</p>
</header>
<main class="stage">
<div class="left">
<div class="field" id="field">
<canvas id="trace" aria-hidden="true"></canvas>
<canvas id="base" tabindex="0" aria-label="Prime spiral grid. Arrow keys move the cursor between cells, Home returns to the centre, plus and minus change zoom.">
A square spiral of the whole numbers with the primes highlighted. Your browser does not support canvas.
</canvas>
<canvas id="cursor" aria-hidden="true"></canvas>
<div class="hint" id="hint">click any cell for its number</div>
</div>
<div class="card toolbar" role="group" aria-label="View controls">
<button type="button" class="primary" id="replayBtn">Replay render</button>
<span class="tbsep" aria-hidden="true"></span>
<span class="zoomgrp">
<button type="button" class="icon" id="zoomOut" aria-label="Zoom out, show more numbers">−</button>
<span class="lv" id="zoomLv">zoom 4/6</span>
<button type="button" class="icon" id="zoomIn" aria-label="Zoom in, show fewer and larger numbers">+</button>
</span>
<span class="tbnote">Grid is focusable: <b>Tab</b> to it, then arrow keys.</span>
</div>
</div>
<div class="panel">
<section class="card" aria-label="Render readout">
<h2>Readout</h2>
<div class="stats">
<div class="stat"><span class="v" id="sPlaced">0</span><span class="k">numbers placed</span></div>
<div class="stat"><span class="v hi" id="sPrimes">0</span><span class="k">primes lit</span></div>
<div class="stat"><span class="v" id="sDens">—<span class="u">%</span></span><span class="k">prime density</span></div>
<div class="stat"><span class="v" id="sPred">—<span class="u">%</span></span><span class="k">1 / ln N</span></div>
</div>
<span class="pill" id="pill"><span class="dot"></span><span id="pillText">idle</span></span>
<p class="cap">The spiral is a square grid, so on a wide window its top and bottom run past the frame; the counts include them. <b>1 / ln N</b> is the prime number theorem's estimate. The measured density runs about a point high — <b>1 / ln N</b> always undershoots at sizes this small — but it tracks the same slow decline, and then the diagonals break it locally.</p>
</section>
<section class="card" aria-label="Cell inspector">
<h2>Inspect</h2>
<div class="insp-top">
<span class="insp-n" id="iNum">41</span>
<span class="badge" id="iBadge">—</span>
</div>
<dl class="facts">
<dt>Breakdown</dt><dd id="iFac">—</dd>
<dt>Position</dt><dd id="iPos">—</dd>
<dt>Neighbours</dt><dd id="iNbr">—</dd>
</dl>
<p class="cap tight">Click a cell, or focus the grid and walk it with the arrow keys.</p>
</section>
<section class="card" aria-label="Formula overlay">
<h2>Trace a formula</h2>
<div class="formulas" role="group" aria-label="Formula to trace">
<button type="button" data-c="41" aria-pressed="true">n²+n+41</button>
<button type="button" data-c="17" aria-pressed="false">n²+n+17</button>
<button type="button" data-c="11" aria-pressed="false">n²+n+11</button>
<button type="button" data-c="9" aria-pressed="false">n²+n+9</button>
<button type="button" class="off" data-c="0" aria-pressed="false">Hide overlay</button>
</div>
<div class="legend" id="legend">
<span class="lgk">Key</span>
<span class="lg"><span class="sw hit" aria-hidden="true"></span>value is prime</span>
<span class="lg"><span class="sw miss" aria-hidden="true"></span>value is composite</span>
</div>
<div class="fline"><span class="lbl">Primes in a row</span><span class="val hi" id="fStreak">—</span></div>
<div class="fline"><span class="lbl">Prime among these</span><span class="val" id="fHit">—</span></div>
<div class="fline"><span class="lbl">Straight line</span><span class="val" id="fStraight">—</span></div>
<button type="button" class="wide" id="centreBtn">Start the spiral at 41</button>
<p class="cap" id="fNote">—</p>
</section>
<section class="card" aria-label="Why this is surprising">
<h2>Why it's odd</h2>
<p class="prose">
Primes have no formula and no pattern anyone can predict. Yet Stanisław Ulam, doodling
through a dull talk in 1963, spiralled the integers onto graph paper and the diagonals
jumped out at him.
</p>
<p class="prose">
The reason isn't magic: <b>each diagonal of the spiral is a quadratic</b>, and a quadratic
whose constant term is chosen well can dodge division by 2, 3, 5, 7 and more, so it hits
primes far above its fair share. Euler's <code>n² + n + 41</code> is the champion — it turns
out prime for <b>forty</b> values in a row. Start the spiral at its own constant and any of
these four traces the <i>same</i> cells, the middle anti-diagonal; only the constant changes,
and the difference in how much of that one line lights up is the whole show.
</p>
</section>
</div>
</main>
<footer>demo · sloprun.dev</footer>
</div>
<p class="sr-only" id="live" aria-live="polite"></p>
<script>
(function () {
"use strict";
/* ── theme plumbing ─────────────────────────────────────────────────────── */
var root = document.documentElement;
var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
var mqRM = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
function reduced() { return !!(mqRM && mqRM.matches); }
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);
readColors(); startRender(true);
}
});
if (mqDark) {
var onScheme = function () { readColors(); startRender(true); };
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
/* ── elements ───────────────────────────────────────────────────────────── */
var $ = function (id) { return document.getElementById(id); };
var field = $("field"), base = $("base"), trace = $("trace"), cursor2 = $("cursor");
var bctx = null, tctx = null, cctx = null;
try { bctx = base.getContext("2d"); tctx = trace.getContext("2d"); cctx = cursor2.getContext("2d"); } catch (e0) {}
var hint = $("hint"), live = $("live");
if (!bctx || !tctx || !cctx) { /* no 2d canvas: say so instead of dying silently */
hint.textContent = "This browser could not open a 2D canvas, so the spiral cannot be drawn.";
hint.style.position = "static";
hint.style.margin = "16px";
hint.style.maxWidth = "none";
field.style.display = "flex";
field.style.alignItems = "center";
field.style.justifyContent = "center";
$("pillText").textContent = "canvas unavailable";
Array.prototype.forEach.call(document.querySelectorAll("button"), function (b) { b.disabled = true; });
return;
}
var sPlaced = $("sPlaced"), sPrimes = $("sPrimes"), sDens = $("sDens"), sPred = $("sPred");
var pill = $("pill"), pillText = $("pillText");
var iNum = $("iNum"), iBadge = $("iBadge"), iFac = $("iFac"), iPos = $("iPos"), iNbr = $("iNbr");
var fStreak = $("fStreak"), fHit = $("fHit"), fStraight = $("fStraight"), fNote = $("fNote"), legend = $("legend");
var centreBtn = $("centreBtn"), zoomLv = $("zoomLv"), zoomIn = $("zoomIn"), zoomOut = $("zoomOut");
var replayBtn = $("replayBtn");
var fBtns = Array.prototype.slice.call(document.querySelectorAll(".formulas button"));
var MONO = 'ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace';
/* ── colours read from the tokens ───────────────────────────────────────── */
var COL = { accent: "#C05600", ink: "#1A1D21", line: "#E3E2DC", muted: "#5A6068", surface: "#fff", accentSoft: "#FFF3E6" };
var inkRGB = [26, 29, 33];
function cssVar(n, fb) {
var v = getComputedStyle(root).getPropertyValue(n);
v = v ? v.trim() : "";
return v || fb;
}
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 (!isFinite(n)) return [26,29,33];
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function readColors() {
COL.accent = cssVar("--accent", "#C05600");
COL.ink = cssVar("--ink", "#1A1D21");
COL.line = cssVar("--line", "#E3E2DC");
COL.muted = cssVar("--muted", "#5A6068");
COL.surface = cssVar("--surface", "#FFFFFF");
COL.accentSoft = cssVar("--accent-soft", "#FFF3E6");
inkRGB = hexToRgb(COL.ink);
}
function ink(a) { return "rgba(" + inkRGB[0] + "," + inkRGB[1] + "," + inkRGB[2] + "," + a + ")"; }
/* ── maths helpers ──────────────────────────────────────────────────────── */
var sieve = new Uint8Array(2), sieveMax = 0;
function buildSieve(max) {
if (max <= sieveMax) return;
max = Math.max(max, 1024);
var s = new Uint8Array(max + 1);
s.fill(1); s[0] = 0; if (max >= 1) s[1] = 0;
for (var p = 2; p * p <= max; p++) {
if (!s[p]) continue;
for (var q = p * p; q <= max; q += p) s[q] = 0;
}
sieve = s; sieveMax = max;
}
function isPrime(v) { return v >= 2 && v <= sieveMax ? !!sieve[v] : slowPrime(v); }
function slowPrime(v) {
if (v < 2) return false;
if (v % 2 === 0) return v === 2;
for (var d = 3; d * d <= v; d += 2) if (v % d === 0) return false;
return true;
}
var SUP = { 2: "²", 3: "³", 4: "⁴", 5: "⁵", 6: "⁶", 7: "⁷", 8: "⁸", 9: "⁹" };
function factorText(n) {
if (n < 2) return null;
var parts = [], d = 2, m = n;
while (d * d <= m) {
var k = 0;
while (m % d === 0) { m /= d; k++; }
if (k) parts.push(k > 1 ? d + (SUP[k] || ("^" + k)) : String(d));
d += (d === 2) ? 1 : 2;
}
if (m > 1) parts.push(String(m));
return parts.join(" × ");
}
function fmt(n) { return n.toLocaleString("en-US"); }
/* ── state ──────────────────────────────────────────────────────────────── */
var LEVELS = [26, 16, 10, 6, 3.5, 2];
var FORMULAS = {
41: { c: 41, label: "n² + n + 41", streak: 40, note: "Euler's polynomial. Prime for n = 0 … 39 without a single miss — the longest unbroken run any formula of this shape manages. It first fails at n = 40, where the value is 1,681 = 41²." },
17: { c: 17, label: "n² + n + 17", streak: 16, note: "Same shape, smaller constant: sixteen primes in a row, then n = 16 gives 16² + 16 + 17 = 289 = 17²." },
11: { c: 11, label: "n² + n + 11", streak: 10, note: "Ten in a row, then n = 10 gives 121 = 11². That is the pattern: the run always breaks at n = c − 1, where the value is exactly c²." },
9: { c: 9, label: "n² + n + 9", streak: 0, note: "The control. Identical line, ordinary constant — 9 is already 3 × 3, and the diagonal stays mostly dark." }
};
var level = 3, centre = 1, formulaC = 41;
var sel = null; /* {gx, gy, n} */
var hintGone = false;
/* geometry */
var W = 0, H = 0, dpr = 1, cell = 6, S = 1, half = 0, N = 1, ox = 0, oy = 0;
var idxXY = new Int32Array(1); /* index i-1 -> packed (gx+half) | (gy+half)<<16 */
var numIdx = new Int32Array(1); /* cell (gy+half)*S + (gx+half) -> spiral index i */
/* render loop */
var raf = 0, cursor = 0, chunk = 1, primeCount = 0;
var overRaf = 0, overT = 1, overT0 = 0;
function snap(v) { return Math.round(v * dpr) / dpr; }
/* ── layout: size canvases, walk the spiral, size the sieve ─────────────── */
function layout() {
var r = field.getBoundingClientRect();
W = Math.max(120, Math.round(r.width));
H = Math.max(120, Math.round(r.height));
dpr = Math.min(2, window.devicePixelRatio || 1);
[trace, base, cursor2].forEach(function (cv) {
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
var cx = cv.getContext("2d");
cx.setTransform(dpr, 0, 0, dpr, 0, 0);
});
cell = LEVELS[level];
var cols = Math.max(3, Math.floor(W / cell));
var rows = Math.max(3, Math.floor(H / cell));
S = Math.max(cols, rows); if (S % 2 === 0) S++;
half = (S - 1) / 2;
N = S * S;
ox = W / 2; oy = H / 2;
if (idxXY.length < N) idxXY = new Int32Array(N);
if (numIdx.length < N) numIdx = new Int32Array(N);
/* walk the square spiral once: 1 in the middle, right, up, left, down … */
var x = 0, y = 0, dx = 1, dy = 0, segLen = 1, segRem = 1, turns = 0;
for (var i = 1; i <= N; i++) {
var gxp = x + half, gyp = y + half;
idxXY[i - 1] = gxp | (gyp << 16);
numIdx[gyp * S + gxp] = i;
x += dx; y += dy; segRem--;
if (segRem === 0) {
var ndx = dy, ndy = -dx; dx = ndx; dy = ndy;
turns++; if (turns % 2 === 0) segLen++;
segRem = segLen;
}
}
buildSieve(centre + N + 8);
zoomLv.textContent = "zoom " + (LEVELS.length - level) + "/" + LEVELS.length;
zoomIn.disabled = (level === 0);
zoomOut.disabled = (level === LEVELS.length - 1);
}
/* ── cell helpers ───────────────────────────────────────────────────────── */
function cellOf(n) { /* number -> {gx,gy} or null */
var i = n - centre + 1;
if (i < 1 || i > N) return null;
var p = idxXY[i - 1];
return { gx: (p & 0xFFFF) - half, gy: (p >>> 16) - half };
}
function numAt(gx, gy) {
if (gx < -half || gx > half || gy < -half || gy > half) return null;
return centre + numIdx[(gy + half) * S + (gx + half)] - 1;
}
function px(gx) { return ox + gx * cell; }
function py(gy) { return oy + gy * cell; }
/* ── base render, chunked so the tab never locks up ─────────────────────── */
function startRender(fast) {
if (!bctx) return; /* canvas unavailable — the boot guard already said so */
if (raf) { cancelAnimationFrame(raf); raf = 0; }
bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
bctx.clearRect(0, 0, W, H);
cursor = 0; primeCount = 0;
var frames = (fast || reduced()) ? 3 : 46;
chunk = Math.max(64, Math.ceil(N / frames));
setPill(false, "rendering · 0%");
sPlaced.textContent = "0";
sPrimes.textContent = "0";
sDens.innerHTML = "—<span class=\"u\">%</span>";
var lnN = Math.log(Math.max(3, centre + N - 1));
sPred.innerHTML = (100 / lnN).toFixed(2) + "<span class=\"u\">%</span>";
replayBtn.disabled = true;
raf = requestAnimationFrame(step);
}
function step() {
raf = 0;
var end = Math.min(N, cursor + chunk);
drawRange(cursor, end);
cursor = end;
sPlaced.textContent = fmt(cursor);
sPrimes.textContent = fmt(primeCount);
if (cursor < N) {
setPill(false, "rendering · " + Math.round(cursor / N * 100) + "%");
raf = requestAnimationFrame(step);
} else {
replayBtn.disabled = false;
var dens = primeCount / N * 100;
sDens.innerHTML = dens.toFixed(2) + "<span class=\"u\">%</span>";
setPill(true, "complete · " + fmt(N) + " cells");
updateFormulaStats();
drawOverlay();
}
}
/* draw spiral indices [a, b) */
function roundRect(cx, x, y, w, h, r) {
if (cx.roundRect) { cx.beginPath(); cx.roundRect(x, y, w, h, r); return; }
cx.beginPath();
cx.moveTo(x + r, y);
cx.arcTo(x + w, y, x + w, y + h, r);
cx.arcTo(x + w, y + h, x, y + h, r);
cx.arcTo(x, y + h, x, y, r);
cx.arcTo(x, y, x + w, y, r);
cx.closePath();
}
function drawRange(a, b) {
var labels = cell >= 20, big = cell >= 10;
var pad = cell + 2;
var i, v, p, gx, gy, cx, cy;
/* most zoomed in: print the actual integers, primes on an amber chip */
if (labels) {
var fs = Math.max(8, Math.min(12, Math.round(cell * 0.36)));
bctx.font = "600 " + fs + "px " + MONO;
bctx.textAlign = "center";
bctx.textBaseline = "middle";
var cw = cell * 0.86, ch = cell * 0.62;
for (i = a; i < b; i++) {
v = centre + i;
p = idxXY[i]; gx = (p & 0xFFFF) - half; gy = (p >>> 16) - half;
cx = px(gx); cy = py(gy);
if (cx < -pad || cx > W + pad || cy < -pad || cy > H + pad) { if (isPrime(v)) primeCount++; continue; }
if (isPrime(v)) {
primeCount++;
bctx.fillStyle = COL.accentSoft;
roundRect(bctx, cx - cw / 2, cy - ch / 2, cw, ch, 4);
bctx.fill();
bctx.fillStyle = COL.accent;
bctx.fillText(String(v), cx, cy + 0.5);
} else {
bctx.globalAlpha = 0.38;
bctx.fillStyle = COL.muted;
bctx.fillText(String(v), cx, cy + 0.5);
bctx.globalAlpha = 1;
}
}
return;
}
/* faint lattice for the composites, only when cells are roomy */
if (big) {
bctx.beginPath();
for (i = a; i < b; i++) {
if (isPrime(centre + i)) continue;
p = idxXY[i]; gx = (p & 0xFFFF) - half; gy = (p >>> 16) - half;
cx = px(gx); cy = py(gy);
if (cx < -pad || cx > W + pad || cy < -pad || cy > H + pad) continue;
bctx.moveTo(cx + 1.3, cy);
bctx.arc(cx, cy, 1.3, 0, 6.283185307179586);
}
bctx.fillStyle = COL.line;
bctx.fill();
}
/* the primes */
var rad = Math.max(1.5, cell * 0.32);
var size = Math.max(1, Math.min(cell - 0.7, cell * 0.72));
var ss = snap(size);
bctx.beginPath();
for (i = a; i < b; i++) {
v = centre + i;
if (!isPrime(v)) continue;
primeCount++;
p = idxXY[i]; gx = (p & 0xFFFF) - half; gy = (p >>> 16) - half;
cx = px(gx); cy = py(gy);
if (cx < -pad || cx > W + pad || cy < -pad || cy > H + pad) continue;
if (big) {
bctx.moveTo(cx + rad, cy);
bctx.arc(cx, cy, rad, 0, 6.283185307179586);
} else {
bctx.rect(snap(cx - size / 2), snap(cy - size / 2), ss, ss);
}
}
bctx.fillStyle = COL.accent;
bctx.fill();
}
/* ── overlay: formula trace + cursor ────────────────────────────────────── */
function formulaValues() {
if (!formulaC) return [];
var out = [], maxV = centre + N - 1;
for (var n = 0; n < 4000; n++) {
var v = n * n + n + formulaC;
if (v > maxV) break;
if (v < centre) continue;
out.push(v);
if (out.length >= 90) break;
}
return out;
}
function drawOverlay() { drawTrace(); drawCursor(); }
function drawTrace() {
tctx.setTransform(dpr, 0, 0, dpr, 0, 0);
tctx.clearRect(0, 0, W, H);
if (!formulaC) return;
var vals = formulaValues(), pts = [];
for (var i = 0; i < vals.length; i++) {
var c = cellOf(vals[i]);
if (c) pts.push({ x: px(c.gx), y: py(c.gy), p: isPrime(vals[i]) });
}
if (!pts.length) return;
var shown = Math.min(pts.length, Math.max(1, Math.ceil(overT * pts.length)));
var r = Math.max(2.9, cell * 0.62);
var lw = Math.max(1.1, Math.min(2.1, cell * 0.18));
/* the diagonal itself, only when the spiral is started so it is one straight line */
if (pts.length > 1 && centre === formulaC) {
tctx.beginPath();
tctx.moveTo(pts[0].x, pts[0].y);
for (var j = 1; j < shown; j++) tctx.lineTo(pts[j].x, pts[j].y);
tctx.strokeStyle = ink(0.26);
tctx.lineWidth = 1.25;
tctx.stroke();
}
/* composite values: faint hollow rings — the misses */
tctx.beginPath();
var any = false, k;
for (k = 0; k < shown; k++) {
if (pts[k].p) continue;
any = true;
tctx.moveTo(pts[k].x + r, pts[k].y);
tctx.arc(pts[k].x, pts[k].y, r, 0, 6.283185307179586);
}
if (any) { tctx.strokeStyle = ink(0.3); tctx.lineWidth = lw; tctx.stroke(); }
/* prime values: amber beads — the hits. The chain of them IS the point. */
tctx.beginPath(); any = false;
for (k = 0; k < shown; k++) {
if (!pts[k].p) continue;
any = true;
tctx.moveTo(pts[k].x + r, pts[k].y);
tctx.arc(pts[k].x, pts[k].y, r, 0, 6.283185307179586);
}
if (any) {
tctx.fillStyle = COL.accentSoft;
tctx.globalAlpha = 0.85; tctx.fill(); tctx.globalAlpha = 1;
tctx.strokeStyle = COL.accent; tctx.lineWidth = lw + 0.35; tctx.stroke();
tctx.beginPath();
var dr = Math.max(1.15, r * 0.42);
for (k = 0; k < shown; k++) {
if (!pts[k].p) continue;
tctx.moveTo(pts[k].x + dr, pts[k].y);
tctx.arc(pts[k].x, pts[k].y, dr, 0, 6.283185307179586);
}
tctx.fillStyle = COL.accent; tctx.fill();
}
}
function drawCursor() {
cctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cctx.clearRect(0, 0, W, H);
if (!sel) return;
var sx = px(sel.gx), sy = py(sel.gy);
var h2 = Math.max(4.5, cell * 0.72);
cctx.strokeStyle = ink(0.14);
cctx.lineWidth = 1;
cctx.beginPath();
cctx.moveTo(0, snap(sy) + 0.5); cctx.lineTo(W, snap(sy) + 0.5);
cctx.moveTo(snap(sx) + 0.5, 0); cctx.lineTo(snap(sx) + 0.5, H);
cctx.stroke();
cctx.strokeStyle = COL.surface;
cctx.lineWidth = 3.5;
cctx.strokeRect(sx - h2, sy - h2, h2 * 2, h2 * 2);
cctx.strokeStyle = ink(0.95);
cctx.lineWidth = 1.6;
cctx.strokeRect(sx - h2, sy - h2, h2 * 2, h2 * 2);
}
function animateOverlay() {
if (overRaf) { cancelAnimationFrame(overRaf); overRaf = 0; }
if (reduced() || !formulaC) { overT = 1; drawOverlay(); return; }
overT = 0; overT0 = 0;
drawCursor();
var tick = function (ts) {
if (!overT0) overT0 = ts;
overT = Math.min(1, (ts - overT0) / 480);
drawTrace();
if (overT < 1) overRaf = requestAnimationFrame(tick); else overRaf = 0;
};
overRaf = requestAnimationFrame(tick);
}
/* ── panels ─────────────────────────────────────────────────────────────── */
function setPill(ok, text) {
pillText.textContent = text;
if (ok) pill.classList.add("on"); else pill.classList.remove("on");
}
function setSelection(n, announce) {
var c = n == null ? null : cellOf(n);
if (!c) { sel = null; renderInspector(null); drawCursor(); return; }
sel = { gx: c.gx, gy: c.gy, n: n };
renderInspector(n);
drawCursor();
if (announce) live.textContent = n + (isPrime(n) ? " — prime" : " — not prime, " + factorText(n));
}
function renderInspector(n) {
if (n == null) {
iNum.textContent = "—"; iBadge.textContent = "—"; iBadge.className = "badge";
iFac.textContent = "—"; iPos.textContent = "—"; iNbr.textContent = "—";
return;
}
var prime = isPrime(n);
iNum.textContent = fmt(n);
iBadge.textContent = prime ? "prime" : (n < 2 ? "neither" : "composite");
iBadge.className = "badge" + (prime ? " prime" : "");
if (prime) {
var rank = 0;
for (var v = 2; v <= n && v <= sieveMax; v++) if (sieve[v]) rank++;
iFac.innerHTML = "no divisors <span class=\"dim\">· the " + fmt(rank) + ordinal(rank) + " prime</span>";
} else if (n < 2) {
iFac.innerHTML = "<span class=\"dim\">" + (n === 1 ? "1 is a unit, neither prime nor composite" : "below the primes") + "</span>";
} else {
iFac.textContent = fmt(n) + " = " + factorText(n);
}
var ring = Math.max(Math.abs(sel.gx), Math.abs(sel.gy));
var yy = -sel.gy;
iPos.innerHTML = "ring " + ring + " <span class=\"dim\">· x " + (sel.gx >= 0 ? "+" : "") + sel.gx +
", y " + (yy >= 0 ? "+" : "") + yy + "</span>";
var prev = null, next = null, t;
for (t = n - 1; t >= 2; t--) { if (isPrime(t)) { prev = t; break; } }
for (t = n + 1; t <= centre + N - 1 + 400; t++) { if (isPrime(t)) { next = t; break; } }
var nbr = (prev == null ? "—" : fmt(prev)) + " ‹ " + (prime ? "•" : "?") + " › " + (next == null ? "—" : fmt(next));
if (prime && prev != null && next != null) {
var gapL = n - prev, gapR = next - n;
nbr += "<span class=\"dim\"> · gaps " + gapL + " / " + gapR + (gapL === 2 || gapR === 2 ? " · twin" : "") + "</span>";
}
iNbr.innerHTML = nbr;
}
function ordinal(k) {
var r10 = k % 10, r100 = k % 100;
if (r10 === 1 && r100 !== 11) return "st";
if (r10 === 2 && r100 !== 12) return "nd";
if (r10 === 3 && r100 !== 13) return "rd";
return "th";
}
function updateFormulaStats() {
var straight = false, f = FORMULAS[formulaC];
if (!formulaC) {
fStreak.textContent = "—"; fHit.textContent = "—";
fStraight.textContent = "off"; fStraight.className = "val";
legend.className = "legend off";
fNote.innerHTML = "Overlay hidden — just the raw spiral" +
(centre === 1 ? "" : ", still counting up from <b>" + fmt(centre) + "</b>") +
". Pick a formula to trace its values across it.";
centreBtn.disabled = (centre === 1);
centreBtn.textContent = centre === 1 ? "Spiral already starts at 1" : "Back to the classic spiral (1)";
return;
}
centreBtn.disabled = false;
legend.className = "legend";
straight = (centre === formulaC);
var vals = formulaValues(), hit = 0;
for (var i = 0; i < vals.length; i++) if (isPrime(vals[i])) hit++;
fStreak.textContent = f.streak ? f.streak + " from n = 0" : "none · misses at n = 0";
fHit.textContent = vals.length ? hit + " / " + vals.length : "0 / 0";
fStraight.textContent = straight ? "yes · one diagonal" : "no · off the line";
fStraight.className = "val" + (straight ? " hi" : "");
centreBtn.textContent = straight ? "Back to the classic spiral (1)" : "Start the spiral at " + f.c;
fNote.innerHTML = f.note + (straight
? " <b>The spiral starts at " + f.c + ", so every value lands on one straight diagonal.</b>"
: " Right now the spiral starts at " + fmt(centre) + ", so the values bow away from the diagonal — start it at " + f.c + " and they straighten.");
}
/* ── interaction ────────────────────────────────────────────────────────── */
function dropHint() {
if (hintGone) return;
hintGone = true; hint.classList.add("gone");
setTimeout(function () { hint.style.display = "none"; }, 600);
}
function pickFromEvent(e) {
var r = base.getBoundingClientRect();
var mx = e.clientX - r.left, my = e.clientY - r.top;
var gx = Math.round((mx - ox) / cell), gy = Math.round((my - oy) / cell);
var n = numAt(gx, gy);
if (n != null) setSelection(n, true);
dropHint();
}
base.addEventListener("pointerdown", function (e) {
base.classList.add("ptr-focus");
pickFromEvent(e);
base.focus();
});
base.addEventListener("keydown", function (e) {
var k = e.key, d = null;
if (k === "ArrowLeft") d = [-1, 0];
else if (k === "ArrowRight") d = [1, 0];
else if (k === "ArrowUp") d = [0, -1];
else if (k === "ArrowDown") d = [0, 1];
base.classList.remove("ptr-focus");
if (d) {
e.preventDefault();
var gx = sel ? sel.gx : 0, gy = sel ? sel.gy : 0;
var stp = e.shiftKey ? 10 : 1;
gx = Math.max(-half, Math.min(half, gx + d[0] * stp));
gy = Math.max(-half, Math.min(half, gy + d[1] * stp));
var n = numAt(gx, gy);
if (n != null) setSelection(n, true);
dropHint();
return;
}
if (k === "Home") { e.preventDefault(); setSelection(centre, true); dropHint(); return; }
if (k === "+" || k === "=") { e.preventDefault(); setLevel(level - 1); return; }
if (k === "-" || k === "_") { e.preventDefault(); setLevel(level + 1); return; }
if (k === "Enter" || k === " ") {
e.preventDefault();
if (sel) live.textContent = sel.n + (isPrime(sel.n) ? " — prime" : " — not prime, " + factorText(sel.n));
}
});
base.addEventListener("blur", function () { base.classList.remove("ptr-focus"); });
function setLevel(v) {
v = Math.max(0, Math.min(LEVELS.length - 1, v));
if (v === level) return;
level = v;
var keep = sel ? sel.n : null;
layout();
startRender(false);
reselect(keep);
animateOverlay();
live.textContent = "zoom " + (LEVELS.length - level) + " of " + LEVELS.length + ", " + fmt(N) + " numbers";
}
function reselect(keep) {
if (keep != null && cellOf(keep)) setSelection(keep, false);
else setSelection(centre, false);
}
zoomIn.addEventListener("click", function () { setLevel(level - 1); });
zoomOut.addEventListener("click", function () { setLevel(level + 1); });
replayBtn.addEventListener("click", function () { startRender(false); animateOverlay(); });
fBtns.forEach(function (b) {
b.addEventListener("click", function () {
formulaC = parseInt(b.getAttribute("data-c"), 10) || 0;
fBtns.forEach(function (o) { o.setAttribute("aria-pressed", o === b ? "true" : "false"); });
updateFormulaStats();
animateOverlay();
live.textContent = formulaC ? "tracing " + FORMULAS[formulaC].label : "overlay hidden";
});
});
centreBtn.addEventListener("click", function () {
var f = FORMULAS[formulaC];
var target = f ? f.c : 1;
centre = (centre === target) ? 1 : target;
var keep = null;
layout();
startRender(false);
reselect(keep);
updateFormulaStats();
animateOverlay();
live.textContent = "spiral now starts at " + centre;
});
/* resize */
var rt = 0;
function onResize() {
clearTimeout(rt);
rt = setTimeout(function () {
var r = field.getBoundingClientRect();
var nd = Math.min(2, window.devicePixelRatio || 1);
if (Math.abs(Math.round(r.width) - W) < 2 && Math.abs(Math.round(r.height) - H) < 2 && nd === dpr) return;
var keep = sel ? sel.n : null;
layout();
startRender(true);
reselect(keep);
drawOverlay();
}, 160);
}
window.addEventListener("resize", onResize);
if (window.ResizeObserver) { try { new ResizeObserver(onResize).observe(field); } catch (err) {} }
/* ── boot: pick a zoom that shows ~18k numbers, preload Euler + cell 41 ─── */
readColors();
(function chooseLevel() {
/* aim for roughly one number per 12 px² of canvas: dense enough for the
diagonals to show up, coarse enough that the dots stay visible on a phone */
var r = field.getBoundingClientRect();
var w = Math.max(120, r.width), h = Math.max(120, r.height);
var want = Math.max(4000, Math.min(60000, (w * h) / 12));
var best = 3, bestD = Infinity;
for (var i = 0; i < LEVELS.length; i++) {
var s = Math.max(Math.floor(w / LEVELS[i]), Math.floor(h / LEVELS[i]));
var d = Math.abs(Math.log(s * s) - Math.log(want));
if (d < bestD) { bestD = d; best = i; }
}
level = best;
})();
layout();
updateFormulaStats();
startRender(false);
setSelection(41, false);
animateOverlay();
})();
</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.