1 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A sunflower head is built by one rule repeated fifteen hundred times: drop a seed, turn a fixed angle, drop another — and at 137.508°, the golden angle, every seed lands in the widest gap the earlier ones left. Both readouts are measured off the dots on screen rather than looked up: packing fires probes into the head to find its widest hole and scores that against the tightest spacing round seeds can manage, and arms finds each seed's true nearest neighbours and counts how far apart in planting order they were. Press +0.1° first — a tenth of a degree tears the pack into 34 curved spokes with daylight between them and drops packing from 95% to 63%, and Snap to golden puts it back. Then drag seeds across its range and watch the arm counts climb the Fibonacci ladder — 13 & 21, then 21 & 34, 34 & 55, 55 & 89 — numbers that fall out of measuring the picture rather than out of a table, while Lucas at 99.502° packs about as tightly but counts 47 & 76 instead and Quarter at 90° collapses the head into a bare cross.
Source — the code you see is the code that just ran 852 lines 38.8 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">
<title>Sunflower Mathematics — 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 .num { font-family: var(--font-mono); font-size: .9em; 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: 920px) { .stage { grid-template-columns: minmax(0,1fr); } }
.left { 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;
}
/* the field */
.field {
position: relative; overflow: hidden;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); box-shadow: var(--shadow);
width: 100%;
height: clamp(300px, calc(100vh - 336px), 640px);
}
@media (max-width: 920px) { .field { aspect-ratio: 1 / 1; height: auto; } }
canvas { display: block; width: 100%; height: 100%; }
.chip {
position: absolute; left: 10px; bottom: 10px; pointer-events: none;
display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
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 11px;
max-width: calc(100% - 20px);
}
.chip .sw-wrap { display: inline-flex; align-items: center; gap: 9px; }
.chip .sw { display: inline-flex; align-items: center; gap: 5px; }
.chip .dot { width: 10px; height: 3px; border-radius: 2px; background: var(--muted); flex: none; }
.chip b { color: var(--ink); font-weight: 600; }
/* hero angle strip */
.hero { display: flex; flex-direction: column; gap: 10px; }
.hero-top { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.bigangle {
font-family: var(--font-mono); font-size: clamp(26px, 5.4vw, 34px); line-height: 1;
font-variant-numeric: tabular-nums; letter-spacing: -.03em; color: var(--ink);
}
.bigangle .deg { color: var(--muted); font-size: .62em; margin-left: 1px; }
.hero-meta { display: flex; gap: 16px; flex-wrap: wrap; }
.hero-meta .k {
display: block; font-family: var(--font-mono); font-size: 9.5px;
letter-spacing: .09em; text-transform: uppercase; color: var(--muted);
}
.hero-meta .v {
font-family: var(--font-mono); font-size: 13px; font-variant-numeric: tabular-nums; color: var(--ink);
}
.hero-meta .v.off { color: var(--accent); }
.hero-meta .v.on { color: var(--run); }
#snapBtn { margin-left: auto; }
@media (max-width: 560px) { #snapBtn { margin-left: 0; width: 100%; } }
.nudge { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.nudge .nl {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted); margin-right: 2px;
}
.nudge button { font-family: var(--font-mono); font-size: 11.5px; padding: 5px 8px; min-width: 52px; }
@media (max-width: 560px) {
.nudge .nl { flex: 1 0 100%; margin: 0 0 1px; } /* label on its own row so the six */
.nudge button { padding: 8px 4px; flex: 1 1 28%; } /* buttons wrap an even 3 + 3 */
}
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.tbsep { width: 1px; align-self: stretch; background: var(--line); margin: 0 2px; }
@media (max-width: 560px) { .tbsep { display: none; } }
/* panel */
.panel { display: flex; flex-direction: column; gap: 12px; }
.stats { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat.wide { grid-column: 1 / -1; }
.stat .v {
font-family: var(--font-mono); font-size: 20px; 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.good { color: var(--run); }
.stat .v.warn { 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: 12px;
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); }
.pill.warn { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.warn .dot { background: var(--accent); }
.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 .mono { font-family: var(--font-mono); font-size: .93em; }
/* controls */
.ctl { display: block; margin-bottom: 13px; }
.ctl:last-child { margin-bottom: 2px; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val { font-family: var(--font-mono); font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
input[type=range] {
-webkit-appearance: none; appearance: none; display: block;
width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb {
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
.hero-slider { height: 26px; }
.hero-slider::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; }
.hero-slider::-webkit-slider-thumb { width: 20px; height: 20px; margin-top: -7px; }
.hero-slider::-moz-range-track { height: 6px; border-radius: 3px; }
.hero-slider::-moz-range-thumb { width: 18px; height: 18px; }
.scale {
display: flex; justify-content: space-between; margin-top: -4px;
font-family: var(--font-mono); font-size: 9.5px; color: var(--muted); letter-spacing: .06em;
}
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: .5; cursor: not-allowed; }
button.primary:disabled { opacity: 1; background: var(--bg); color: var(--muted); border-color: var(--line); font-weight: 400; }
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); }
.presets { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; }
.presets button { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; text-align: left; padding: 7px 9px; }
.presets button .pn { font-size: 12.5px; }
.presets button .pa { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
.presets button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.presets button[aria-pressed="true"] .pa { color: var(--accent); }
.presets button:hover:not(:disabled) .pa { color: inherit; }
.switch { display: flex; align-items: center; gap: 9px; cursor: pointer; font-size: 13px; }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.track {
width: 34px; height: 19px; border-radius: 999px; background: var(--line);
border: 1px solid var(--line); position: relative; flex: none; transition: background .15s ease;
}
.track::after {
content: ""; position: absolute; top: 1.5px; left: 2px; width: 14px; height: 14px;
border-radius: 50%; background: var(--surface); transition: transform .15s ease;
box-shadow: 0 1px 2px rgba(0,0,0,.25);
}
.switch input:checked + .track { background: var(--accent); border-color: var(--accent); }
.switch input:checked + .track::after { transform: translateX(15px); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
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;
}
[hidden] { display: none !important; }
.toolbar .cap { flex: 1 0 100%; margin: 1px 0 0; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Sunflower Mathematics</h1>
<p class="lede">
A sunflower head grows one seed at a time, turning the same angle before each new one.
At <span class="num">137.508°</span> — the <b>golden angle</b> — every seed drops into
the largest gap the others left behind, and the head fills with no wasted space and no seams.
Nudge the dial by a tenth of a degree and the packing tears open into spokes.
</p>
</header>
<main class="stage">
<div class="left">
<div class="field" id="field">
<canvas id="c" role="img" aria-label="Seed pattern: 1500 seeds placed at 137.508 degrees apart."></canvas>
<div class="chip" id="chip" aria-hidden="true"></div>
</div>
<div class="card hero">
<div class="hero-top">
<div class="bigangle"><span id="angBig">137.508</span><span class="deg">°</span></div>
<div class="hero-meta">
<div><span class="k">of a turn</span><span class="v" id="turnV">0.381966</span></div>
<div><span class="k">off golden</span><span class="v on" id="deltaV">0.000°</span></div>
</div>
<button type="button" class="primary" id="snapBtn" disabled>Snap to golden</button>
</div>
<input type="range" class="hero-slider" id="ang" min="0" max="180" step="0.01" value="137.51"
aria-label="Angle between seeds, in degrees">
<div class="scale" aria-hidden="true"><span>0°</span><span>45°</span><span>90°</span><span>135°</span><span>180°</span></div>
<div class="nudge" role="group" aria-label="Nudge the angle">
<span class="nl">nudge</span>
<button type="button" data-d="-1">−1°</button>
<button type="button" data-d="-0.1">−0.1°</button>
<button type="button" data-d="-0.01">−0.01°</button>
<button type="button" data-d="0.01">+0.01°</button>
<button type="button" data-d="0.1">+0.1°</button>
<button type="button" data-d="1">+1°</button>
</div>
</div>
<div class="card toolbar" role="group" aria-label="Animation">
<button type="button" id="growBtn">Regrow from seed 1</button>
<button type="button" id="sweepBtn">Sweep the angle</button>
<span class="tbsep" aria-hidden="true"></span>
<label class="switch" for="trace">
<input type="checkbox" id="trace">
<span class="track" aria-hidden="true"></span>
<span>Trace the spirals</span>
</label>
<p class="cap" id="rmNote" hidden>Your system asks for reduced motion, so the head arrives
fully grown. Both buttons above still animate when you press them.</p>
</div>
</div>
<div class="panel">
<section class="card" aria-label="Pattern readout">
<h2>Readout</h2>
<div class="stats">
<div class="stat"><span class="v" id="sSeeds">1500</span><span class="k">seeds</span></div>
<div class="stat"><span class="v" id="sPack">—</span><span class="k">packing</span></div>
<div class="stat wide"><span class="v" id="sArms">—</span><span class="k">arms, each way</span></div>
</div>
<span class="pill" id="pill"><span class="dot"></span><span id="pillText">measuring</span></span>
<p class="cap">
Both numbers are measured off the dots on screen, not looked up.
<b>Packing</b> is the widest hole in the head against the tightest possible spacing —
<span class="mono">100%</span> is the best any pile of round seeds can do.
<b>Arms</b> counts the two families of curves your eye picks out — spirals when the packing works, spokes when it doesn't.
</p>
</section>
<section class="card" aria-label="Pattern size">
<h2>Head</h2>
<div class="ctl">
<div class="ctl-top"><label for="seeds">Seeds</label><span class="val" id="seedsV" aria-hidden="true">1500</span></div>
<input type="range" id="seeds" min="150" max="4000" step="10" value="1500">
</div>
<div class="ctl">
<div class="ctl-top"><label for="dot">Seed size</label><span class="val" id="dotV" aria-hidden="true">0.95×</span></div>
<input type="range" id="dot" min="0.4" max="1.4" step="0.01" value="0.95">
</div>
</section>
<section class="card" aria-label="Angle presets">
<h2>Try an angle</h2>
<div class="presets" id="presets" role="group" aria-label="Angle presets">
<button type="button" data-a="137.50776405" aria-pressed="true"><span class="pn">Golden</span><span class="pa">137.508°</span></button>
<button type="button" data-a="137.60776405" aria-pressed="false"><span class="pn">A hair off</span><span class="pa">137.608°</span></button>
<button type="button" data-a="99.50178" aria-pressed="false"><span class="pn">Lucas</span><span class="pa">99.502°</span></button>
<button type="button" data-a="137.14285714" aria-pressed="false"><span class="pn">8 of 21</span><span class="pa">137.143°</span></button>
<button type="button" data-a="90" aria-pressed="false"><span class="pn">Quarter</span><span class="pa">90.000°</span></button>
<button type="button" data-a="120" aria-pressed="false"><span class="pn">Third</span><span class="pa">120.000°</span></button>
</div>
<p class="cap" id="presetCap">The golden angle is <span class="mono">360° ÷ φ²</span>, the turn that no simple fraction can approximate. Every other choice here is a near miss, and the head shows it.</p>
</section>
</div>
</main>
<footer>demo · sloprun.dev</footer>
</div>
<div class="sr-only" role="status" aria-live="polite" id="live"></div>
<script>
(function () {
"use strict";
/* ── constants ──────────────────────────────────────────────────────────── */
var GOLDEN = 360 / (((1 + Math.sqrt(5)) / 2) * ((1 + Math.sqrt(5)) / 2)); /* 137.50776405… */
var IDEAL = 1.0746 * Math.sqrt(Math.PI); /* hex-lattice spacing when area per seed is π */
var OPT = 1 / Math.sqrt(3); /* deepest hole in a perfect hex pack, in spacings */
var FIB = [1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,1597];
var LUCAS = [1,3,4,7,11,18,29,47,76,123,199,322,521,843,1364];
var DEG = Math.PI / 180;
/* ── 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;
var reduced = !!(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);
readTheme(); renderChip(); draw();
}
});
if (mqDark) {
var onScheme = function () { readTheme(); renderChip(); draw(); };
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
var COL = {
surface: [255,255,255], soft: [255,243,230], ink: [26,29,33],
muted: [90,96,104], accent: [192,86,0], line: [227,226,220]
};
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 mix(a, b, t) {
return [Math.round(a[0]+(b[0]-a[0])*t), Math.round(a[1]+(b[1]-a[1])*t), Math.round(a[2]+(b[2]-a[2])*t)];
}
var RAMP_N = 24, ramp = [];
function readTheme() {
COL.surface = tok("--surface", COL.surface);
COL.soft = tok("--accent-soft", COL.soft);
COL.ink = tok("--ink", COL.ink);
COL.muted = tok("--muted", COL.muted);
COL.accent = tok("--accent", COL.accent);
COL.line = tok("--line", COL.line);
ramp.length = 0;
for (var k = 0; k < RAMP_N; k++) {
var t = k / (RAMP_N - 1);
ramp.push(rgba(mix(COL.accent, mix(COL.accent, COL.ink, 0.62), t), 1 - 0.12 * t));
}
}
/* ── dom ────────────────────────────────────────────────────────────────── */
var field = document.getElementById("field");
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
var chip = document.getElementById("chip");
var angEl = document.getElementById("ang");
var angBig = document.getElementById("angBig");
var turnV = document.getElementById("turnV");
var deltaV = document.getElementById("deltaV");
var snapBtn = document.getElementById("snapBtn");
var seedsEl = document.getElementById("seeds"), seedsV = document.getElementById("seedsV");
var dotEl = document.getElementById("dot"), dotV = document.getElementById("dotV");
var traceEl = document.getElementById("trace");
var growBtn = document.getElementById("growBtn");
var sweepBtn = document.getElementById("sweepBtn");
var presets = document.getElementById("presets");
var sSeeds = document.getElementById("sSeeds");
var sPack = document.getElementById("sPack");
var sArms = document.getElementById("sArms");
var pill = document.getElementById("pill");
var pillText = document.getElementById("pillText");
var rmNote = document.getElementById("rmNote");
var live = document.getElementById("live");
/* ── state ──────────────────────────────────────────────────────────────── */
var angle = GOLDEN; /* degrees — authoritative, finer than the slider step */
var N = 1500;
var dotScale = 0.95;
var sx = new Float64Array(4000), sy = new Float64Array(4000); /* seed coords, r = sqrt(i+.5) */
var built = { n: -1, a: -1 };
/* seeds fade up over the last `softWin()` of the count, so the growth front has a
soft tip. That means "fully grown" is N + softWin(), not N — stopping at N left
the outer rim of every head permanently half-size. */
function softWin() { return Math.max(6, 0.035 * N); }
function fullGrown() { return N + softWin(); }
var grown = fullGrown(); /* how many seeds have sprouted (float, for the growth animation) */
var growing = false, growT0 = 0, GROW_MS = 1700;
var sweeping = false, sweepT0 = 0, sweepBase = GOLDEN, lastStats = 0;
var SWEEP_MS = 11000, SWEEP_AMP = 0.5;
var rafId = 0;
var stats = { pack: 0, hole: 1, p: 0, q: 0, kind: "" };
var W = 0, H = 0, cx = 0, cy = 0, fitR = 1;
/* ── the pattern ────────────────────────────────────────────────────────── */
function buildSeeds() {
if (built.n === N && built.a === angle) return;
var a = angle * DEG;
for (var i = 0; i < N; i++) {
var r = Math.sqrt(i + 0.5), t = i * a;
sx[i] = r * Math.cos(t); sy[i] = r * Math.sin(t);
}
built.n = N; built.a = angle;
}
/* widest hole in the head, measured with probe points on a fine spiral.
Probe density has to track the seed count, or the answer is mostly luck:
at a fixed 1400 probes the same golden angle scored anywhere from 92 to 100
as the head grew. Four probes per seed settles it at a stable 95–96. */
function measurePacking() {
var PROBES = Math.max(2000, 4 * N);
var R = Math.sqrt(N + 0.5);
var cell = 2.4 * IDEAL;
var side = Math.ceil(2 * R / cell) + 3;
var grid = new Array(side * side), i, k, b;
for (i = 0; i < N; i++) {
k = (Math.floor((sy[i] + R) / cell) + 1) * side + Math.floor((sx[i] + R) / cell) + 1;
b = grid[k]; if (!b) { b = grid[k] = []; }
b.push(i);
}
var ga = Math.PI * (3 - Math.sqrt(5)), lim = 0.93 * R, ds = [];
for (var p = 0; p < PROBES; p++) {
var rr = lim * Math.sqrt((p + 0.5) / PROBES), th = p * ga + 0.7;
var x = rr * Math.cos(th), y = rr * Math.sin(th);
var gx = Math.floor((x + R) / cell) + 1, gy = Math.floor((y + R) / cell) + 1;
var best = Infinity, span = 1;
while (span <= 3) {
for (var dy = -span; dy <= span; dy++) {
var ry = gy + dy; if (ry < 0 || ry >= side) continue;
var row = ry * side;
for (var dx = -span; dx <= span; dx++) {
var rx = gx + dx; if (rx < 0 || rx >= side) continue;
b = grid[row + rx]; if (!b) continue;
for (var z = 0; z < b.length; z++) {
var j = b[z], ddx = sx[j] - x, ddy = sy[j] - y, d = ddx * ddx + ddy * ddy;
if (d < best) best = d;
}
}
}
if (best < Infinity) break;
span += 2;
}
ds.push(best < Infinity ? Math.sqrt(best) : 3.5 * cell);
}
ds.sort(function (m, n) { return m - n; });
var lo = Math.floor(0.99 * ds.length), s = 0, c = 0;
for (i = lo; i < ds.length; i++) { s += ds[i]; c++; }
var hole = (s / c) / IDEAL;
return { hole: hole, pack: Math.max(0, Math.min(100, Math.round(100 * OPT / hole))) };
}
/* the two spiral families: how far apart in planting order neighbouring seeds are */
function measureArms() {
var win = Math.min(N - 1, Math.max(60, Math.ceil(8 * Math.sqrt(N))));
var lo = Math.floor(0.20 * N), hi = Math.floor(0.95 * N);
var S = Math.max(8, Math.min(40, hi - lo));
var tally = {}, bd = new Float64Array(6), bi = new Int32Array(6), s, k, j;
for (s = 0; s < S; s++) {
var i = lo + Math.floor((hi - lo) * (s + 0.5) / S);
for (k = 0; k < 6; k++) { bd[k] = Infinity; bi[k] = 0; }
var jl = Math.max(0, i - win), jh = Math.min(N - 1, i + win);
for (j = jl; j <= jh; j++) {
if (j === i) continue;
var dx = sx[j] - sx[i], dy = sy[j] - sy[i], d = dx * dx + dy * dy;
if (d < bd[5]) {
k = 5;
while (k > 0 && bd[k - 1] > d) { bd[k] = bd[k - 1]; bi[k] = bi[k - 1]; k--; }
bd[k] = d; bi[k] = j > i ? j - i : i - j;
}
}
for (k = 0; k < 6; k++) if (bd[k] < Infinity) tally[bi[k]] = (tally[bi[k]] || 0) + 1;
}
var keys = Object.keys(tally);
keys.sort(function (m, n) { return tally[n] - tally[m] || (+m) - (+n); });
var top = keys.slice(0, 3).map(Number).sort(function (m, n) { return m - n; });
var p = top[0] || 0, q = top[1] || 0;
var kind = "spiral";
if (!p || !q) kind = "none";
else if (q % p === 0) kind = "rows";
else if (adjacent(FIB, p, q)) kind = "fib";
else if (adjacent(LUCAS, p, q)) kind = "lucas";
return { p: p, q: q, kind: kind };
}
function adjacent(seq, p, q) {
for (var i = 0; i < seq.length - 1; i++) if (seq[i] === p && seq[i + 1] === q) return true;
return false;
}
/* ── readouts ───────────────────────────────────────────────────────────── */
function fmt(n, d) {
var s = n.toFixed(d);
return s === "-" + (0).toFixed(d) ? (0).toFixed(d) : s;
}
function refreshStats() {
buildSeeds();
var pk = measurePacking(), ar = measureArms();
stats.pack = pk.pack; stats.hole = pk.hole;
stats.p = ar.p; stats.q = ar.q; stats.kind = ar.kind;
sSeeds.textContent = String(N);
sPack.innerHTML = stats.pack + '<span class="u">%</span>';
sPack.className = "v" + (stats.pack >= 88 ? " good" : stats.pack < 55 ? " warn" : "");
sArms.textContent = ar.p && ar.q ? ar.p + " & " + ar.q : "—";
var on = ar.kind === "fib" && stats.pack >= 88;
pill.className = "pill" + (on ? " on" : (stats.pack < 70 ? " warn" : ""));
pillText.textContent =
on ? "sunflower packing · Fibonacci pair" :
ar.kind === "fib" ? "Fibonacci pair" :
ar.kind === "lucas" ? "Lucas pair — the other family" :
ar.kind === "rows" ? ar.p + (ar.p === 1 ? " arm" : " arms") + ", gaps between them" :
stats.pack < 55 ? "gaps wide open" : "spirals, loosely packed";
renderChip();
scheduleLabel();
}
function renderChip() {
var swatch = (traceEl.checked && stats.p > 1 && stats.q > 1) /* same test draw() uses */
? '<span class="sw"><i class="dot" style="background:' + rgba(COL.accent, 0.9) + '"></i>' + stats.p + '</span>' +
'<span class="sw"><i class="dot" style="background:' + rgba(COL.ink, 0.4) + '"></i>' + stats.q + '</span>'
: '<b>' + stats.p + '</b> & <b>' + stats.q + '</b> arms';
chip.innerHTML = '<span><b>' + fmt(angle, 3) + '°</b></span><span>' + N + ' seeds</span>' +
(stats.p ? '<span class="sw-wrap">' + swatch + '</span>' : '');
}
function refreshAngleUI() {
var d = angle - GOLDEN;
angBig.textContent = fmt(angle, 3);
turnV.textContent = (angle / 360).toFixed(6);
deltaV.textContent = (d >= 0.0005 ? "+" : d <= -0.0005 ? "−" : "") + fmt(Math.abs(d), 3) + "°";
deltaV.className = "v " + (Math.abs(d) < 0.0005 ? "on" : "off");
snapBtn.disabled = Math.abs(d) < 0.0005;
angEl.setAttribute("aria-valuetext", fmt(angle, 3) + " degrees");
var btns = presets.querySelectorAll("button");
for (var i = 0; i < btns.length; i++) {
btns[i].setAttribute("aria-pressed", Math.abs(parseFloat(btns[i].getAttribute("data-a")) - angle) < 0.0005 ? "true" : "false");
}
}
var labelTimer = 0;
function scheduleLabel() {
if (labelTimer) clearTimeout(labelTimer);
labelTimer = setTimeout(function () {
labelTimer = 0;
canvas.setAttribute("aria-label",
"Seed pattern: " + N + " seeds placed " + fmt(angle, 3) + " degrees apart. " +
(stats.p ? stats.p + " spiral arms one way and " + stats.q + " the other. " : "") +
"Packing " + stats.pack + " percent.");
}, 500);
}
function setAngle(a, fromSlider) {
a = Math.max(0, Math.min(180, a));
if (a === angle) return;
angle = a;
if (!fromSlider) angEl.value = String(Math.round(a * 100) / 100);
refreshAngleUI();
refreshStats();
draw();
}
/* ── sizing ─────────────────────────────────────────────────────────────── */
function resize() {
var rect = field.getBoundingClientRect();
var cw = Math.max(120, Math.round(rect.width));
var ch = Math.max(120, Math.round(rect.height));
var dpr = Math.min(2, window.devicePixelRatio || 1);
if (cw === W && ch === H && canvas.width === Math.round(cw * dpr)) return;
W = cw; H = ch;
canvas.width = Math.round(cw * dpr);
canvas.height = Math.round(ch * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cx = W / 2; cy = H / 2;
draw();
}
/* ── drawing ────────────────────────────────────────────────────────────── */
function draw() {
if (!W) return;
buildSeeds();
var R = Math.sqrt(N + 0.5);
var pad = 10;
var half = Math.min(W, H) / 2 - pad;
/* leave room for the outer seeds' own radius */
var scale = half / (R + 0.5 * dotScale * IDEAL);
fitR = R * scale;
var spacing = IDEAL * scale;
var tracing = traceEl.checked && stats.p > 1 && stats.q > 1;
var rad = Math.max(0.6, dotScale * spacing / 2 * (tracing ? 0.5 : 1));
ctx.clearRect(0, 0, W, H);
/* warm bloom behind the head */
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, fitR * 1.15);
g.addColorStop(0, rgba(COL.soft, 1));
g.addColorStop(0.72, rgba(COL.soft, 0.55));
g.addColorStop(1, rgba(COL.surface, 0));
ctx.fillStyle = rgba(COL.surface, 1);
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(cx, cy, fitR * 1.15, 0, 6.2832); ctx.fill();
ctx.strokeStyle = rgba(COL.line, 0.9);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cx, cy, fitR + rad + 3, 0, 6.2832); ctx.stroke();
var vis = Math.max(1, Math.min(N, Math.floor(grown)));
var soft = softWin();
var i, x, y, s;
if (tracing) {
ctx.lineWidth = Math.max(1, spacing * 0.13);
ctx.lineCap = "round"; ctx.lineJoin = "round";
strand(stats.q, rgba(COL.ink, 0.42), vis, scale);
strand(stats.p, rgba(COL.accent, 0.85), vis, scale);
}
for (i = 0; i < vis; i++) {
s = (grown - i) / soft;
if (s > 1) s = 1; else if (s <= 0) continue;
s = s * s * (3 - 2 * s);
x = cx + sx[i] * scale; y = cy + sy[i] * scale;
ctx.fillStyle = ramp[Math.min(RAMP_N - 1, (i / N * RAMP_N) | 0)];
ctx.beginPath(); ctx.arc(x, y, rad * s, 0, 6.2832); ctx.fill();
}
}
function strand(step, style, vis, scale) {
if (step < 1 || step >= vis) return;
ctx.strokeStyle = style;
ctx.beginPath();
for (var s0 = 0; s0 < step; s0++) {
var started = false;
for (var i = s0; i < vis; i += step) {
var x = cx + sx[i] * scale, y = cy + sy[i] * scale;
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
}
}
ctx.stroke();
}
/* ── animation ──────────────────────────────────────────────────────────── */
function tick(now) {
rafId = 0;
var more = false;
if (growing) {
var t = (now - growT0) / GROW_MS;
if (t >= 1) { t = 1; growing = false; growBtn.textContent = "Regrow from seed 1"; }
else more = true;
var e = 1 - Math.pow(1 - t, 2.4);
grown = e * fullGrown();
}
if (sweeping) {
var u = (now - sweepT0) / SWEEP_MS;
var a = sweepBase + SWEEP_AMP * Math.sin(u * 6.2832);
angle = Math.max(0, Math.min(180, a));
angEl.value = String(Math.round(angle * 100) / 100);
refreshAngleUI();
/* measuring is the expensive half — 20 Hz is plenty for a readout that is
sliding anyway, and it keeps 4000 seeds sweeping at frame rate */
if (now - lastStats > 50) { lastStats = now; refreshStats(); }
more = true;
}
draw();
if (more) rafId = requestAnimationFrame(tick);
}
function kick() { if (!rafId) rafId = requestAnimationFrame(tick); }
/* Reduced motion suppresses the growth that would otherwise play on its own at
load; a visitor who presses Regrow or Sweep has asked for the animation, so
those still run. */
function startGrow() {
growing = true; growT0 = performance.now(); grown = 0;
growBtn.textContent = "Growing…";
kick();
}
function stopSweep() {
if (!sweeping) return;
sweeping = false;
sweepBtn.textContent = "Sweep the angle";
sweepBtn.classList.remove("primary");
refreshStats();
draw();
say("Sweep stopped at " + fmt(angle, 3) + " degrees.");
}
function say(msg) { live.textContent = msg; }
/* ── wiring ─────────────────────────────────────────────────────────────── */
angEl.addEventListener("input", function () {
stopSweep();
setAngle(parseFloat(angEl.value), true);
});
var nudgeBtns = document.querySelectorAll(".nudge button");
for (var b = 0; b < nudgeBtns.length; b++) {
nudgeBtns[b].addEventListener("click", function () {
stopSweep();
setAngle(Math.round((angle + parseFloat(this.getAttribute("data-d"))) * 1000) / 1000, false);
say(fmt(angle, 3) + " degrees. Packing " + stats.pack + " percent.");
});
}
snapBtn.addEventListener("click", function () {
stopSweep();
setAngle(GOLDEN, false);
say("Snapped to the golden angle: 137.508 degrees. Packing " + stats.pack + " percent, " +
stats.p + " and " + stats.q + " spiral arms.");
snapBtn.disabled = true;
});
seedsEl.addEventListener("input", function () {
N = parseInt(seedsEl.value, 10);
seedsV.textContent = String(N);
seedsEl.setAttribute("aria-valuetext", N + " seeds");
if (!growing) grown = fullGrown();
built.n = -1;
refreshStats();
draw();
});
dotEl.addEventListener("input", function () {
dotScale = parseFloat(dotEl.value);
dotV.textContent = dotScale.toFixed(2) + "×";
dotEl.setAttribute("aria-valuetext", dotScale.toFixed(2) + " times natural spacing");
draw();
});
traceEl.addEventListener("change", function () {
refreshStats();
draw();
say(traceEl.checked ? "Spiral traces on: " + stats.p + " arms one way, " + stats.q + " the other." : "Spiral traces off.");
});
growBtn.addEventListener("click", function () {
startGrow();
say("Growing " + N + " seeds from the middle out.");
});
sweepBtn.addEventListener("click", function () {
if (sweeping) { stopSweep(); return; }
sweeping = true; sweepBase = GOLDEN; sweepT0 = performance.now();
sweepBtn.textContent = "Stop sweep";
sweepBtn.classList.add("primary");
say("Sweeping the angle half a degree each side of golden.");
kick();
});
presets.addEventListener("click", function (e) {
var btn = e.target.closest ? e.target.closest("button[data-a]") : null;
if (!btn) return;
stopSweep();
setAngle(parseFloat(btn.getAttribute("data-a")), false);
say(btn.querySelector(".pn").textContent + ": " + fmt(angle, 3) + " degrees. Packing " +
stats.pack + " percent, " + (stats.p ? stats.p + " and " + stats.q + " spiral arms." : ""));
});
window.addEventListener("resize", resize);
if (window.ResizeObserver) new ResizeObserver(resize).observe(field);
if (mqRM) {
var onRM = function () { reduced = mqRM.matches; rmNote.hidden = !reduced; };
if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
else if (mqRM.addListener) mqRM.addListener(onRM);
}
/* ── boot ───────────────────────────────────────────────────────────────── */
readTheme();
seedsV.textContent = String(N);
dotV.textContent = dotScale.toFixed(2) + "×";
seedsEl.setAttribute("aria-valuetext", N + " seeds");
dotEl.setAttribute("aria-valuetext", dotScale.toFixed(2) + " times natural spacing");
rmNote.hidden = !reduced;
refreshAngleUI();
refreshStats();
resize();
grown = reduced ? fullGrown() : 0;
draw();
if (!reduced) startGrow();
})();
</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.