0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Two hundred glowing lights hang over a park at dusk and rearrange themselves into a circle, a star, a heart, a spiral, a grid, or any short word you type. The shapes are the easy part; the travel between them is the fun part — lights are paired to spots by angular order so the paths fan out instead of crossing, thousands of swap trials then shave the total distance, and each light eases in on a staggered start with a soft nudge that pushes it aside when a neighbour crowds it. Typed words need no font files: the word is drawn to a hidden canvas, its outline traced pixel by pixel, then thinned to exactly 200 spots by farthest-point sampling, which is why the letters read evenly instead of clumping. It opens already touring — try spelling PARTY or 2026, drag Transition down to 0.4 s to watch the lights scramble, and sweep your pointer across the sky to part them.
Source — the code you see is the code that just ran 1299 lines 50.2 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>Light Show Choreographer — 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. The sky is a stage: dark in both themes, but tuned —
dusk-blue under the light theme, deep night under the dark one. */
:root {
--scrim: rgba(246,246,243,.88);
--shadow: 0 1px 2px rgba(26,29,33,.05);
--sky-top: #222839;
--sky-bot: #444C63;
--dot: #FFC978;
--core: #FFF7EA;
--glow-a: .58;
--trail-a: .34;
--star-a: .42;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--scrim: rgba(20,22,26,.88);
--shadow: 0 1px 2px rgba(0,0,0,.35);
--sky-top: #07090D; --sky-bot: #161B25;
--dot: #FFB454; --core: #FFFFFF;
--glow-a: .84; --trail-a: .52; --star-a: .95;
}
}
:root[data-theme="dark"] {
--scrim: rgba(20,22,26,.88);
--shadow: 0 1px 2px rgba(0,0,0,.35);
--sky-top: #07090D; --sky-bot: #161B25;
--dot: #FFB454; --core: #FFFFFF;
--glow-a: .84; --trail-a: .52; --star-a: .95;
}
* { 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; }
.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); } }
.field {
position: relative; overflow: hidden;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--sky-top);
aspect-ratio: 16 / 10; box-shadow: var(--shadow);
}
@media (max-width: 900px) { .field { aspect-ratio: 4 / 3; } }
@media (max-width: 560px) { .field { aspect-ratio: 5 / 4; } }
canvas { display: block; width: 100%; height: 100%; touch-action: manipulation; }
.hint {
position: absolute; left: 10px; bottom: 10px; pointer-events: none;
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 .45s ease; max-width: calc(100% - 20px);
}
.hint[hidden] { display: none; }
@media (max-width: 560px) { .hint { display: none; } }
.hint.gone { opacity: 0; }
.marquee {
position: absolute; left: 10px; top: 10px; pointer-events: none;
display: flex; align-items: baseline; gap: 8px;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted);
background: var(--scrim); border: 1px solid var(--line);
border-radius: 999px; padding: 5px 11px; max-width: calc(100% - 20px);
}
.marquee b {
color: var(--ink); font-weight: 600; letter-spacing: .04em;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.panel { display: flex; flex-direction: column; gap: 12px; }
.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(3, minmax(0,1fr)); gap: 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
font-family: var(--font-mono); font-size: 20px; line-height: 1.05;
font-variant-numeric: tabular-nums; letter-spacing: -.02em;
}
.stat .k {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted);
}
.meter { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.meter .lbl {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted); flex: none;
}
.meter .bar {
flex: 1 1 auto; height: 6px; border-radius: 3px;
background: var(--line); overflow: hidden;
}
.meter .bar i {
display: block; height: 100%; width: 0%;
background: var(--accent); border-radius: 3px;
transition: width .1s linear;
}
.meter .bar.done i { background: var(--run); }
.meter .num {
font-family: var(--font-mono); font-size: 11px; color: var(--ink);
font-variant-numeric: tabular-nums; min-width: 4ch; text-align: right; flex: none;
}
.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 .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.chips { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 7px; }
@media (max-width: 380px) { .chips { grid-template-columns: repeat(2, minmax(0,1fr)); } }
.chips button { width: 100%; padding: 7px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chips button[aria-pressed="true"] {
background: var(--accent-soft); color: var(--accent); border-color: var(--accent); font-weight: 600;
}
.wordrow { display: flex; gap: 7px; margin-top: 10px; }
input[type=text] {
flex: 1 1 auto; min-width: 0; font: inherit; font-size: 13px;
font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em;
color: var(--ink); background: var(--bg);
border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px;
}
input[type=text]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
.ctl { display: block; }
.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; }
input[type=range]:disabled { cursor: not-allowed; opacity: .45; }
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: .42; 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); }
.row { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 12px; }
.row > button { flex: 1 1 auto; }
.switch { display: flex; align-items: center; gap: 9px; cursor: pointer; font-size: 13px; margin-top: 12px; }
.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; }
.switch input:disabled + .track { opacity: .45; }
.switch.off { cursor: not-allowed; color: var(--muted); }
.ctl.off .ctl-top label, .ctl.off .ctl-top .val { color: var(--muted); }
.note {
margin: 11px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted);
border-left: 2px solid var(--line); padding-left: 9px;
}
.note[hidden] { display: none; }
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>Light Show Choreographer</h1>
<p class="lede">
Two hundred little lights over a park at dusk. Pick a shape and each light works out
<b>its own route</b> to a spot in it — eased on the way in, nudged aside mid-flight
whenever a neighbour crowds it. <b>Type a word</b> and the lights spell it.
<span id="ledeTour">The show is already touring; take it over whenever you like.</span>
</p>
</header>
<main class="stage">
<div class="field" id="field">
<canvas id="c" role="img" aria-label="Night sky with 200 glowing lights arranging themselves into shapes.">
An animation of 200 glowing lights forming shapes. Your browser does not support canvas.
</canvas>
<div class="marquee" aria-hidden="true">now showing <b id="mName">Circle</b></div>
<div class="hint" id="hint">move your pointer over the sky to part the lights</div>
</div>
<div class="panel">
<section class="card" aria-label="Show readout">
<h2>Readout</h2>
<div class="stats">
<div class="stat"><span class="v" id="sLights">200</span><span class="k">lights</span></div>
<div class="stat"><span class="v" id="sCue">1/6</span><span class="k">cue</span></div>
<div class="stat"><span class="v" id="sFps">—</span><span class="k">fps</span></div>
</div>
<div class="meter">
<span class="lbl">arrival</span>
<span class="bar" id="barWrap"><i id="bar"></i></span>
<span class="num" id="sPct">0%</span>
</div>
<span class="pill" id="pill"><span class="dot"></span><span id="pillText">flying</span></span>
<p class="cap">Longest hop this cue: <b class="mono" id="sHop">—</b> · <span class="mono" id="sNext">—</span></p>
</section>
<section class="card" aria-label="Shapes">
<h2>Shapes</h2>
<div class="chips" id="chips" role="group" aria-label="Pick a shape">
<button type="button" data-i="0" aria-pressed="true">Circle</button>
<button type="button" data-i="1" aria-pressed="false">Star</button>
<button type="button" data-i="2" aria-pressed="false">Heart</button>
<button type="button" data-i="3" aria-pressed="false">Spiral</button>
<button type="button" data-i="4" aria-pressed="false">Grid</button>
<button type="button" data-i="5" aria-pressed="false" id="wordChip">HELLO</button>
</div>
<div class="wordrow">
<label class="sr-only" for="word">Word for the lights to spell (up to 9 characters)</label>
<input type="text" id="word" value="HELLO" maxlength="9" spellcheck="false"
autocomplete="off" autocapitalize="characters" aria-describedby="wordHelp">
<button type="button" id="spell">Spell</button>
</div>
<p class="cap" id="wordHelp">The word is drawn to a hidden canvas, its outline sampled pixel by pixel, then thinned to exactly 200 spots — each one as far as it can get from the spots already taken.</p>
</section>
<section class="card" aria-label="Show controls">
<h2>Controls</h2>
<div class="ctl" id="speedCtl">
<div class="ctl-top">
<label for="speed">Transition</label><span class="val" id="speedV" aria-hidden="true">1.60 s</span>
</div>
<input type="range" id="speed" min="0.4" max="4" step="0.1" value="1.6">
</div>
<label class="switch" for="tour" id="tourLabel">
<input type="checkbox" id="tour" checked>
<span class="track" aria-hidden="true"></span>
<span>Auto-play tour</span>
</label>
<label class="switch" for="trails" id="trailsLabel">
<input type="checkbox" id="trails" checked>
<span class="track" aria-hidden="true"></span>
<span>Light trails</span>
</label>
<div class="row">
<button type="button" id="next">Next shape</button>
<button type="button" id="again">Replay cue</button>
</div>
<p class="note" id="rmNote" hidden>
Your system asks for reduced motion, so the show is held as still frames — the lights
snap straight to each shape and the travel controls are off. <b>Next shape</b> steps
through the tour by hand, and typing a word still works.
</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";
/* ── 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();
if (!running) render(performance.now());
}
});
if (mqDark) {
var onScheme = function () { readTheme(); if (!running) render(performance.now()); };
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
/* ── dom ────────────────────────────────────────────────────────────────── */
var field = document.getElementById("field");
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
var hint = document.getElementById("hint");
var mName = document.getElementById("mName");
var sCue = document.getElementById("sCue");
var sFps = document.getElementById("sFps");
var sPct = document.getElementById("sPct");
var sHop = document.getElementById("sHop");
var sNext = document.getElementById("sNext");
var bar = document.getElementById("bar");
var barWrap = document.getElementById("barWrap");
var pill = document.getElementById("pill");
var pillText = document.getElementById("pillText");
var chipsEl = document.getElementById("chips");
var chips = [].slice.call(chipsEl.querySelectorAll("button"));
var wordChip = document.getElementById("wordChip");
var wordEl = document.getElementById("word");
var spellBtn = document.getElementById("spell");
var speedEl = document.getElementById("speed");
var speedV = document.getElementById("speedV");
var tourEl = document.getElementById("tour");
var trailsEl = document.getElementById("trails");
var nextBtn = document.getElementById("next");
var againBtn = document.getElementById("again");
var rmNote = document.getElementById("rmNote");
var live = document.getElementById("live");
/* ── colours ────────────────────────────────────────────────────────────── */
var COL = { dot: "#FFB454", core: "#FFFFFF", top: "#07090D", bot: "#161B25" };
var GLOW_A = 0.84, TRAIL_A = 0.22, STAR_A = 0.55;
var sprite = document.createElement("canvas");
var spriteSize = 96;
function css(name, fb) {
var v = getComputedStyle(root).getPropertyValue(name);
v = v ? v.trim() : "";
return v || fb;
}
function num(name, fb) {
var v = parseFloat(getComputedStyle(root).getPropertyValue(name));
return isNaN(v) ? fb : v;
}
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 (isNaN(n)) return [255, 180, 84];
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgba(hex, a) {
var c = hexToRgb(hex);
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function buildSprite() {
var S = spriteSize;
sprite.width = S; sprite.height = S;
var g = sprite.getContext("2d");
g.clearRect(0, 0, S, S);
var grd = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
grd.addColorStop(0.00, rgba(COL.core, 1));
grd.addColorStop(0.10, rgba(COL.core, 0.92));
grd.addColorStop(0.20, rgba(COL.dot, 0.72));
grd.addColorStop(0.42, rgba(COL.dot, 0.22));
grd.addColorStop(0.70, rgba(COL.dot, 0.06));
grd.addColorStop(1.00, rgba(COL.dot, 0));
g.fillStyle = grd;
g.fillRect(0, 0, S, S);
}
/* The sky + stars are painted once into an offscreen backdrop and blitted
opaquely every frame. Fading the live canvas towards the sky instead (the
usual trails trick) leaves permanent 1-2 level burn-in wherever a light has
ever been, because round(c*(1-a) + sky*a) === c for small c-sky. */
var bg = document.createElement("canvas");
function buildSky() {
if (!W || !H) return;
bg.width = Math.max(1, Math.round(W * dpr));
bg.height = Math.max(1, Math.round(H * dpr));
var g = bg.getContext("2d");
if (!g) return;
g.setTransform(dpr, 0, 0, dpr, 0, 0);
var grd = g.createLinearGradient(0, 0, 0, H);
grd.addColorStop(0, COL.top);
grd.addColorStop(1, COL.bot);
g.globalCompositeOperation = "source-over";
g.globalAlpha = 1;
g.fillStyle = grd;
g.fillRect(0, 0, W, H);
g.globalCompositeOperation = "lighter";
for (var i = 0; i < stars.length; i++) {
var st = stars[i];
g.globalAlpha = Math.min(1, STAR_A * st.a);
var sr = st.s * 3.2;
g.drawImage(sprite, st.x - sr, st.y - sr, sr * 2, sr * 2);
}
g.globalAlpha = 1;
g.globalCompositeOperation = "source-over";
}
function readTheme() {
COL.dot = css("--dot", COL.dot);
COL.core = css("--core", COL.core);
COL.top = css("--sky-top", COL.top);
COL.bot = css("--sky-bot", COL.bot);
GLOW_A = num("--glow-a", GLOW_A);
TRAIL_A = num("--trail-a", TRAIL_A);
STAR_A = num("--star-a", STAR_A);
buildSprite();
buildSky();
}
/* ── geometry helpers ───────────────────────────────────────────────────── */
var TAU = Math.PI * 2;
var N = 200;
/* deterministic little rng so the sky looks the same each load */
function mulberry(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* even arc-length resample of a dense polyline */
function resample(pts, n, closed) {
var i, seg = [], total = 0;
var m = pts.length;
var last = closed ? m : m - 1;
for (i = 0; i < last; i++) {
var a = pts[i], b = pts[(i + 1) % m];
var d = Math.sqrt((b[0]-a[0])*(b[0]-a[0]) + (b[1]-a[1])*(b[1]-a[1]));
seg.push(d); total += d;
}
var out = [], k = 0, acc = 0;
for (i = 0; i < n; i++) {
var want = total * (i / n);
while (k < seg.length - 1 && acc + seg[k] < want) { acc += seg[k]; k++; }
var t = seg[k] > 0 ? (want - acc) / seg[k] : 0;
var p = pts[k], q = pts[(k + 1) % m];
out.push([p[0] + (q[0]-p[0]) * t, p[1] + (q[1]-p[1]) * t]);
}
return out;
}
function circleShape(n) {
var out = [], i;
for (i = 0; i < n; i++) {
var a = (i / n) * TAU - Math.PI / 2;
out.push([Math.cos(a), Math.sin(a)]);
}
return out;
}
function starShape(n) {
var v = [], i;
for (i = 0; i < 10; i++) {
var a = (i / 10) * TAU - Math.PI / 2;
var r = (i % 2 === 0) ? 1 : 0.42;
v.push([Math.cos(a) * r, Math.sin(a) * r]);
}
var dense = [];
for (i = 0; i < 10; i++) {
var p = v[i], q = v[(i + 1) % 10];
for (var s = 0; s < 24; s++) {
var t = s / 24;
dense.push([p[0] + (q[0]-p[0]) * t, p[1] + (q[1]-p[1]) * t]);
}
}
return resample(dense, n, true);
}
function heartShape(n) {
var dense = [], i;
for (i = 0; i < 720; i++) {
var t = (i / 720) * TAU;
var x = 16 * Math.pow(Math.sin(t), 3);
var y = -(13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t));
dense.push([x / 17, y / 17]);
}
return resample(dense, n, true);
}
function spiralShape(n) {
var dense = [], i;
for (i = 0; i < 900; i++) {
var u = i / 899;
var a = u * TAU * 3.15;
var r = 0.10 + 0.92 * u;
dense.push([Math.cos(a) * r, Math.sin(a) * r]);
}
return resample(dense, n, false);
}
function gridShape(n) {
var cols = 20, rows = Math.ceil(n / cols);
var out = [], i;
for (i = 0; i < n; i++) {
var cx = i % cols, cy = (i / cols) | 0;
var x = cols > 1 ? (cx / (cols - 1)) * 2 - 1 : 0;
var y = rows > 1 ? (cy / (rows - 1)) * 2 - 1 : 0;
out.push([x * 1.22, y * 0.56]);
}
return out;
}
/* word → outline sample → exactly n spots */
var lastWordStats = { cand: 0 };
function wordShape(word, n) {
var W0 = 900, H0 = 300;
var off = document.createElement("canvas");
off.width = W0; off.height = H0;
var g = off.getContext("2d");
if (!g) return null;
g.fillStyle = "#000"; g.fillRect(0, 0, W0, H0);
g.fillStyle = "#fff";
g.textAlign = "center"; g.textBaseline = "middle";
var FONT = 'system-ui, -apple-system, "Segoe UI", sans-serif';
var fs = 190;
g.font = "700 " + fs + "px " + FONT;
var wpx = g.measureText(word).width;
if (wpx > W0 - 44 && wpx > 0) {
fs = Math.max(26, fs * (W0 - 44) / wpx);
g.font = "700 " + fs + "px " + FONT;
}
g.fillText(word, W0 / 2, H0 / 2);
var data;
try { data = g.getImageData(0, 0, W0, H0).data; }
catch (err) { return null; }
var step = 3;
function on(x, y) {
if (x < 0 || y < 0 || x >= W0 || y >= H0) return false;
return data[(y * W0 + x) * 4] > 128;
}
var cand = [], x, y;
for (y = 0; y < H0; y += step) {
for (x = 0; x < W0; x += step) {
if (!on(x, y)) continue;
if (on(x - step, y) && on(x + step, y) && on(x, y - step) && on(x, y + step)) continue;
cand.push(x, y);
}
}
var cnt = cand.length / 2;
lastWordStats.cand = cnt;
if (cnt === 0) return null;
/* shuffle candidate order (seeded) */
var idx = new Int32Array(cnt), i;
for (i = 0; i < cnt; i++) idx[i] = i;
var rnd = mulberry(0x5107 + cnt);
for (i = cnt - 1; i > 0; i--) {
var j = (rnd() * (i + 1)) | 0;
var tmp = idx[i]; idx[i] = idx[j]; idx[j] = tmp;
}
/* farthest-point sampling — each spot taken is the candidate furthest from
every spot already taken, so 200 lights land evenly along the outline
however crowded the word is */
var accX = [], accY = [];
var take = Math.min(n, cnt);
var far = new Float64Array(cnt);
var seed0 = idx[0];
accX.push(cand[seed0 * 2]); accY.push(cand[seed0 * 2 + 1]);
for (i = 0; i < cnt; i++) {
var fx = cand[i * 2] - accX[0], fy = cand[i * 2 + 1] - accY[0];
far[i] = fx * fx + fy * fy;
}
while (accX.length < take) {
var pick = -1, pickD = -1;
for (i = 0; i < cnt; i++) { if (far[i] > pickD) { pickD = far[i]; pick = i; } }
if (pick < 0 || pickD <= 0) break;
var nx0 = cand[pick * 2], ny0 = cand[pick * 2 + 1];
accX.push(nx0); accY.push(ny0);
for (i = 0; i < cnt; i++) {
var ex = cand[i * 2] - nx0, ey = cand[i * 2 + 1] - ny0;
var e2 = ex * ex + ey * ey;
if (e2 < far[i]) far[i] = e2;
}
}
/* still short (very thin glyphs)? pad from the candidate pool with jitter */
while (accX.length < n) {
var kk = idx[accX.length % cnt];
accX.push(cand[kk * 2] + (rnd() - 0.5) * 4);
accY.push(cand[kk * 2 + 1] + (rnd() - 0.5) * 4);
}
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (i = 0; i < n; i++) {
if (accX[i] < minX) minX = accX[i];
if (accX[i] > maxX) maxX = accX[i];
if (accY[i] < minY) minY = accY[i];
if (accY[i] > maxY) maxY = accY[i];
}
var bw = Math.max(1, maxX - minX), bh = Math.max(1, maxY - minY);
var sc = Math.min(2.64 / bw, 0.98 / bh);
var ox0 = (minX + maxX) / 2, oy0 = (minY + maxY) / 2;
var out = [];
for (i = 0; i < n; i++) out.push([(accX[i] - ox0) * sc, (accY[i] - oy0) * sc]);
return out;
}
/* ── shape registry ─────────────────────────────────────────────────────── */
var ORDER = ["circle", "star", "heart", "spiral", "grid", "word"];
var LABEL = { circle: "Circle", star: "Star", heart: "Heart", spiral: "Spiral", grid: "Grid", word: "" };
var cache = {};
var word = "HELLO";
function shapeFor(name) {
if (name === "word") {
if (cache.word && cache.wordKey === word) return cache.word;
var pts = wordShape(word, N);
if (!pts) return null;
cache.word = pts; cache.wordKey = word;
return pts;
}
if (cache[name]) return cache[name];
var made =
name === "circle" ? circleShape(N) :
name === "star" ? starShape(N) :
name === "heart" ? heartShape(N) :
name === "spiral" ? spiralShape(N) :
gridShape(N);
cache[name] = made;
return made;
}
/* ── canvas sizing ──────────────────────────────────────────────────────── */
var W = 0, H = 0, dpr = 1, U = 1, CX = 0, CY = 0, stars = [];
function mapx(nx) { return CX + nx * U; }
function mapy(ny) { return CY + ny * U; }
function resize() {
var r = field.getBoundingClientRect();
var w = Math.max(160, Math.round(r.width));
var h = Math.max(140, Math.round(r.height));
dpr = Math.min(2, window.devicePixelRatio || 1);
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
var oldU = U, oldCX = CX, oldCY = CY, had = W > 0;
W = w; H = h;
var pad = Math.max(14, Math.min(w, h) * 0.05);
U = Math.max(24, Math.min((w / 2 - pad) / 1.36, (h / 2 - pad) / 1.04));
CX = w / 2; CY = h / 2;
if (had && oldU > 0) {
var k = U / oldU;
for (var i = 0; i < N; i++) {
px[i] = CX + (px[i] - oldCX) * k; py[i] = CY + (py[i] - oldCY) * k;
sxA[i] = CX + (sxA[i] - oldCX) * k; syA[i] = CY + (syA[i] - oldCY) * k;
bxA[i] = CX + (bxA[i] - oldCX) * k; byA[i] = CY + (byA[i] - oldCY) * k;
for (var t2 = 0; t2 < TAIL; t2++) {
hxA[i * TAIL + t2] = CX + (hxA[i * TAIL + t2] - oldCX) * k;
hyA[i * TAIL + t2] = CY + (hyA[i * TAIL + t2] - oldCY) * k;
}
}
}
remapGoals();
/* seeded stars */
stars.length = 0;
var rnd = mulberry(1337);
var count = Math.round((w * h) / 5200);
for (var s = 0; s < count; s++) {
stars.push({ x: rnd() * w, y: rnd() * h * 0.82, s: 0.5 + rnd() * 1.1, a: 0.25 + rnd() * 0.75 });
}
buildSky();
}
/* ── the lights ─────────────────────────────────────────────────────────── */
var px = new Float32Array(N), py = new Float32Array(N);
var bxA = new Float32Array(N), byA = new Float32Array(N);
var sxA = new Float32Array(N), syA = new Float32Array(N);
var gxA = new Float32Array(N), gyA = new Float32Array(N);
var ox = new Float32Array(N), oy = new Float32Array(N);
var ovx = new Float32Array(N), ovy = new Float32Array(N);
var nfx = new Float32Array(N), nfy = new Float32Array(N);
var pfx = new Float32Array(N), pfy = new Float32Array(N);
var sepS = new Float32Array(N);
var phase = new Float32Array(N), twk = new Float32Array(N);
var amp = new Float32Array(N), sizeF = new Float32Array(N), delay = new Float32Array(N);
var slot = new Int32Array(N); /* slot[i] = index into the current shape */
var curShape = circleShape(N);
/* short ring of past positions per light — the tails are drawn from these,
so the sky itself is never smeared and never burns in */
var TAIL = 6;
var TAILW = [0, 0.52, 0.34, 0.22, 0.13, 0.07];
var hxA = new Float32Array(N * TAIL), hyA = new Float32Array(N * TAIL);
var hHead = 0;
function resetTail() {
for (var i = 0; i < N; i++) {
for (var k = 0; k < TAIL; k++) { hxA[i * TAIL + k] = px[i]; hyA[i * TAIL + k] = py[i]; }
}
}
function pushTail() {
hHead = (hHead + 1) % TAIL;
for (var i = 0; i < N; i++) { hxA[i * TAIL + hHead] = px[i]; hyA[i * TAIL + hHead] = py[i]; }
}
(function seedDots() {
var rnd = mulberry(9001);
for (var i = 0; i < N; i++) {
phase[i] = rnd() * TAU;
twk[i] = 0.55 + rnd() * 0.9;
amp[i] = 0.7 + rnd() * 1.5;
sizeF[i] = 0.82 + rnd() * 0.45;
delay[i] = rnd();
slot[i] = i;
}
})();
function remapGoals() {
for (var i = 0; i < N; i++) {
var p = curShape[slot[i]];
gxA[i] = mapx(p[0]); gyA[i] = mapy(p[1]);
}
}
/* pair each light with a spot: angular order first (paths fan out instead of
crossing), then a few thousand swap trials to shave the total distance. */
var longestHop = 0;
function assign(shape) {
var i;
var c1x = 0, c1y = 0, c2x = 0, c2y = 0;
for (i = 0; i < N; i++) { c1x += px[i]; c1y += py[i]; }
c1x /= N; c1y /= N;
for (i = 0; i < N; i++) { c2x += shape[i][0]; c2y += shape[i][1]; }
c2x /= N; c2y /= N;
var a1 = [], a2 = [];
for (i = 0; i < N; i++) {
a1.push([Math.atan2(py[i] - c1y, px[i] - c1x), i]);
a2.push([Math.atan2(shape[i][1] - c2y, shape[i][0] - c2x), i]);
}
a1.sort(function (a, b) { return a[0] - b[0]; });
a2.sort(function (a, b) { return a[0] - b[0]; });
for (i = 0; i < N; i++) slot[a1[i][1]] = a2[i][1];
curShape = shape;
remapGoals();
/* swap refinement */
var rnd = mulberry(4242);
function cost(i) {
var dx = gxA[i] - px[i], dy = gyA[i] - py[i];
return dx * dx + dy * dy;
}
for (var k = 0; k < 9000; k++) {
var i1 = (rnd() * N) | 0, i2 = (rnd() * N) | 0;
if (i1 === i2) continue;
var before = cost(i1) + cost(i2);
var tx = gxA[i1], ty = gyA[i1];
gxA[i1] = gxA[i2]; gyA[i1] = gyA[i2];
gxA[i2] = tx; gyA[i2] = ty;
var after = cost(i1) + cost(i2);
if (after < before) {
var ts = slot[i1]; slot[i1] = slot[i2]; slot[i2] = ts;
} else {
gxA[i2] = gxA[i1]; gyA[i2] = gyA[i1];
gxA[i1] = tx; gyA[i1] = ty;
}
}
longestHop = 0;
for (i = 0; i < N; i++) {
var d = Math.sqrt(cost(i));
if (d > longestHop) longestHop = d;
}
}
/* ── show state ─────────────────────────────────────────────────────────── */
var cue = 0;
var durMs = 1600;
var curDur = 1600; /* duration locked in when the cue started */
var DF = 0.34; /* share of the transition spent staggering starts */
var HOLD = 2900;
var transiting = false, tStart = 0, holdUntil = 0, progAll = 1;
var trails = true, tour = true;
var running = false, raf = 0, last = 0, fps = 0, lastUi = 0;
var pointer = { on: false, x: 0, y: 0 };
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function goTo(idx, opts) {
var name = ORDER[idx];
var shape = shapeFor(name);
if (!shape) { say("That word left nothing to draw — try letters or digits."); return false; }
cue = idx;
assign(shape);
if (reduced) {
for (var i = 0; i < N; i++) {
px[i] = gxA[i]; py[i] = gyA[i];
bxA[i] = gxA[i]; byA[i] = gyA[i];
ox[i] = oy[i] = ovx[i] = ovy[i] = 0;
}
transiting = false; progAll = 1;
render(performance.now());
} else {
/* start from the eased BASE, not the drawn position: px already carries
ox/oy + bob, and update() re-adds them, so seeding from px made every
light pop by that offset the instant a cue started. */
for (var j = 0; j < N; j++) { sxA[j] = bxA[j]; syA[j] = byA[j]; }
curDur = durMs;
tStart = performance.now();
transiting = true; progAll = 0;
}
syncChips();
syncReadout(performance.now());
if (!opts || !opts.silent) say("Shape: " + shapeLabel(idx));
return true;
}
function shapeLabel(idx) {
var n = ORDER[idx];
return n === "word" ? ('the word "' + word + '"') : LABEL[n];
}
function nextCue(silent) { goTo((cue + 1) % ORDER.length, silent ? { silent: true } : null); }
/* ── update ─────────────────────────────────────────────────────────────── */
function update(now, dt) {
var i, t = now / 1000;
if (transiting) {
var e = now - tStart;
var span = curDur * (1 - DF);
var sum = 0;
for (i = 0; i < N; i++) {
var p = (e - delay[i] * curDur * DF) / span;
p = p < 0 ? 0 : (p > 1 ? 1 : p);
var q = ease(p);
bxA[i] = sxA[i] + (gxA[i] - sxA[i]) * q;
byA[i] = syA[i] + (gyA[i] - syA[i]) * q;
sepS[i] = Math.sin(Math.PI * p);
sum += p;
}
progAll = sum / N;
if (e >= curDur) {
transiting = false; progAll = 1;
for (i = 0; i < N; i++) { bxA[i] = gxA[i]; byA[i] = gyA[i]; sepS[i] = 0; }
holdUntil = now + HOLD;
setPill(true);
}
} else {
for (i = 0; i < N; i++) { bxA[i] = gxA[i]; byA[i] = gyA[i]; sepS[i] = 0; }
/* the tour runs itself — announcing every cue would interrupt a screen
reader forever, so only the visitor's own actions speak */
if (tour && now >= holdUntil) { holdUntil = now + HOLD; nextCue(true); }
}
/* neighbour separation — only bites mid-flight, so shapes stay exact */
var R = Math.max(8, U * 0.055), R2 = R * R;
for (i = 0; i < N; i++) { nfx[i] = 0; nfy[i] = 0; pfx[i] = 0; pfy[i] = 0; }
if (transiting) {
for (i = 0; i < N; i++) {
for (var j = i + 1; j < N; j++) {
var dx = px[i] - px[j], dy = py[i] - py[j];
var d2 = dx * dx + dy * dy;
if (d2 >= R2 || d2 < 1e-6) continue;
var d = Math.sqrt(d2);
var f = (1 - d / R) / d;
nfx[i] += dx * f; nfy[i] += dy * f;
nfx[j] -= dx * f; nfy[j] -= dy * f;
}
}
}
if (pointer.on) {
var PR = Math.max(40, U * 0.36), PR2 = PR * PR;
for (i = 0; i < N; i++) {
var ddx = px[i] - pointer.x, ddy = py[i] - pointer.y;
var dd2 = ddx * ddx + ddy * ddy;
if (dd2 >= PR2 || dd2 < 1e-6) continue;
var dd = Math.sqrt(dd2);
var pf = (1 - dd / PR) / dd;
pfx[i] += ddx * pf; pfy[i] += ddy * pf;
}
}
var NK = R * 30, PK = R * 26;
var damp = Math.exp(-dt * 3.2);
var leak = Math.min(1, dt * 3);
var lim = R * 3;
for (i = 0; i < N; i++) {
var ax = nfx[i] * NK * sepS[i] + pfx[i] * PK;
var ay = nfy[i] * NK * sepS[i] + pfy[i] * PK;
ovx[i] = (ovx[i] + ax * dt) * damp;
ovy[i] = (ovy[i] + ay * dt) * damp;
ox[i] += ovx[i] * dt; ox[i] -= ox[i] * leak;
oy[i] += ovy[i] * dt; oy[i] -= oy[i] * leak;
if (ox[i] > lim) ox[i] = lim; else if (ox[i] < -lim) ox[i] = -lim;
if (oy[i] > lim) oy[i] = lim; else if (oy[i] < -lim) oy[i] = -lim;
var bobx = Math.sin(t * twk[i] * 0.7 + phase[i]) * amp[i];
var boby = Math.cos(t * twk[i] * 0.52 + phase[i] * 1.7) * amp[i] * 0.8;
px[i] = bxA[i] + ox[i] + bobx;
py[i] = byA[i] + oy[i] + boby;
}
}
/* ── render ─────────────────────────────────────────────────────────────── */
function render(now) {
if (!W || !H) return;
var t = now / 1000, i;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
if (bg.width > 1) ctx.drawImage(bg, 0, 0, W, H);
else { ctx.fillStyle = COL.top; ctx.fillRect(0, 0, W, H); }
ctx.globalCompositeOperation = "lighter";
var tails = trails && !reduced;
var base = Math.max(3.4, Math.min(11, U * 0.038));
for (i = 0; i < N; i++) {
var tw = 0.62 + 0.28 * Math.sin(t * twk[i] * 1.35 + phase[i]);
var b = reduced ? 0.85 : Math.min(1.05, tw + 0.3 * sepS[i]);
var r = base * sizeF[i] * (0.86 + 0.34 * b);
if (tails) {
for (var k = TAIL - 1; k >= 1; k--) {
var s = (hHead - k + TAIL) % TAIL;
var hx = hxA[i * TAIL + s], hy = hyA[i * TAIL + s];
ctx.globalAlpha = Math.min(1, GLOW_A * b * TRAIL_A * TAILW[k]);
var rr = r * (1 - 0.1 * k);
ctx.drawImage(sprite, hx - rr, hy - rr, rr * 2, rr * 2);
}
}
ctx.globalAlpha = Math.min(1, GLOW_A * b * 1.32);
ctx.drawImage(sprite, px[i] - r, py[i] - r, r * 2, r * 2);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
}
/* ── loop ───────────────────────────────────────────────────────────────── */
function step(now) {
raf = requestAnimationFrame(step);
var dt = (now - last) / 1000;
last = now;
if (!(dt > 0) || dt > 0.05) dt = 0.0166;
fps = fps ? fps * 0.9 + (1 / dt) * 0.1 : 1 / dt;
update(now, dt);
pushTail();
render(now);
if (now - lastUi > 100) { lastUi = now; syncReadout(now); }
}
function start() {
if (running || reduced) return;
running = true; last = performance.now(); raf = requestAnimationFrame(step);
}
function stop() {
running = false;
if (raf) cancelAnimationFrame(raf);
raf = 0;
}
/* ── readout ────────────────────────────────────────────────────────────── */
function setPill(ok) {
if (ok) { pill.classList.add("on"); pillText.textContent = "in place"; }
else { pill.classList.remove("on"); pillText.textContent = "flying"; }
}
function syncReadout(now) {
var pct = Math.round(progAll * 100);
bar.style.width = pct + "%";
sPct.textContent = pct + "%";
if (pct >= 100) barWrap.classList.add("done"); else barWrap.classList.remove("done");
sCue.textContent = (cue + 1) + "/" + ORDER.length;
sFps.textContent = reduced ? "—" : String(Math.round(fps));
sHop.textContent = reduced ? "—" : (Math.round(longestHop) + " px");
var label = ORDER[cue] === "word" ? '"' + word + '"' : LABEL[ORDER[cue]];
mName.textContent = label;
setPill(!transiting);
if (reduced) {
sNext.textContent = "held · step by hand";
} else if (transiting) {
sNext.textContent = "arriving";
} else if (tour) {
var left = Math.max(0, (holdUntil - now) / 1000);
sNext.textContent = "next in " + left.toFixed(1) + "s";
} else {
sNext.textContent = "tour off";
}
}
function syncChips() {
for (var i = 0; i < chips.length; i++) {
chips[i].setAttribute("aria-pressed", (i === cue) ? "true" : "false");
}
}
var sayTimer = 0;
function say(msg) {
clearTimeout(sayTimer);
sayTimer = setTimeout(function () { live.textContent = msg; }, 120);
}
/* ── controls ───────────────────────────────────────────────────────────── */
chipsEl.addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest("button[data-i]") : null;
if (!b) return;
goTo(parseInt(b.getAttribute("data-i"), 10));
holdUntil = performance.now() + durMs + HOLD;
});
function applyWord() {
var v = wordEl.value.replace(/\s+/g, " ").trim().toUpperCase();
if (!v) { say("Type a word first."); return; }
wordEl.value = v;
word = v;
wordChip.textContent = v;
cache.word = null; cache.wordKey = null;
var ok = goTo(5);
if (ok) {
say('The lights are spelling "' + v + '" — ' + lastWordStats.cand + " outline samples thinned to 200 spots.");
holdUntil = performance.now() + durMs + HOLD;
}
}
spellBtn.addEventListener("click", applyWord);
wordEl.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); applyWord(); }
});
wordEl.addEventListener("input", function () {
spellBtn.disabled = !wordEl.value.trim();
});
speedEl.addEventListener("input", function () {
durMs = parseFloat(speedEl.value) * 1000;
speedV.textContent = (durMs / 1000).toFixed(2) + " s";
});
tourEl.addEventListener("change", function () {
tour = tourEl.checked;
if (tour) holdUntil = performance.now() + HOLD;
syncReadout(performance.now());
say(tour ? "Auto tour on." : "Auto tour off — pick shapes yourself.");
});
trailsEl.addEventListener("change", function () {
trails = trailsEl.checked;
say(trails ? "Light trails on." : "Light trails off.");
});
nextBtn.addEventListener("click", function () {
nextCue();
holdUntil = performance.now() + durMs + HOLD;
});
againBtn.addEventListener("click", function () {
/* scatter, then fly back into the same shape */
var rnd = mulberry((performance.now() | 0) & 0xffff);
for (var i = 0; i < N; i++) {
var a = rnd() * TAU, r = U * (0.15 + rnd() * 1.05);
px[i] = CX + Math.cos(a) * r;
py[i] = CY + Math.sin(a) * r * 0.72;
bxA[i] = px[i]; byA[i] = py[i];
ox[i] = oy[i] = ovx[i] = ovy[i] = 0;
}
resetTail();
goTo(cue, { silent: true });
say("Replaying " + shapeLabel(cue) + ".");
holdUntil = performance.now() + durMs + HOLD;
});
/* pointer parting */
function ptr(e) {
var r = canvas.getBoundingClientRect();
pointer.x = e.clientX - r.left;
pointer.y = e.clientY - r.top;
pointer.on = true;
dismissHint();
}
if (!reduced) {
canvas.addEventListener("pointermove", function (e) {
if (e.pointerType === "touch") return;
ptr(e);
});
canvas.addEventListener("pointerleave", function () { pointer.on = false; });
}
/* only a definitively touch-only pointer loses the "part the lights" hint */
var touchOnly = !!(window.matchMedia && matchMedia("(hover: none) and (pointer: coarse)").matches);
var hintTimer = 0;
function dismissHint() {
if (hint.hidden || hint.classList.contains("gone")) return;
hint.classList.add("gone");
clearTimeout(hintTimer);
setTimeout(function () { hint.hidden = true; }, 500);
}
/* ── lifecycle ──────────────────────────────────────────────────────────── */
document.addEventListener("visibilitychange", function () {
if (document.hidden) { stop(); }
else if (!reduced) {
/* don't let the hold timer fire a pile of cues while hidden */
holdUntil = performance.now() + HOLD;
if (transiting) tStart = performance.now() - curDur;
resetTail(); /* no phantom streak from wherever the lights were when we paused */
start();
}
});
if (window.ResizeObserver) {
new ResizeObserver(function () { resize(); if (!running) render(performance.now()); }).observe(field);
} else {
window.addEventListener("resize", function () { resize(); if (!running) render(performance.now()); });
}
/* ── motion mode ────────────────────────────────────────────────────────── */
var tourLabel = document.getElementById("tourLabel");
var trailsLabel = document.getElementById("trailsLabel");
var speedCtl = document.getElementById("speedCtl");
var ledeTour = document.getElementById("ledeTour");
var LEDE_MOVE = ledeTour ? ledeTour.textContent : "";
var LEDE_STILL = "Your system asks for reduced motion, so the show is held as still frames \u2014 step it with Next shape.";
function applyMotionUI() {
rmNote.hidden = !reduced;
tourEl.disabled = trailsEl.disabled = speedEl.disabled = reduced;
tourLabel.classList.toggle("off", reduced);
trailsLabel.classList.toggle("off", reduced);
speedCtl.classList.toggle("off", reduced);
nextBtn.classList.toggle("primary", reduced);
againBtn.hidden = reduced;
if (reduced) {
tourEl.checked = false; trailsEl.checked = false;
tour = false; trails = false;
pointer.on = false;
clearTimeout(hintTimer);
hint.hidden = true;
} else {
tourEl.checked = true; trailsEl.checked = true;
tour = true; trails = true;
}
if (ledeTour) ledeTour.textContent = reduced ? LEDE_STILL : LEDE_MOVE;
}
/* ── boot ───────────────────────────────────────────────────────────────── */
readTheme();
resize();
/* opening: lights lift off from the grass and rise into the first shape */
(function liftOff() {
var rnd = mulberry(77);
for (var i = 0; i < N; i++) {
px[i] = CX + (rnd() - 0.5) * W * 0.88;
py[i] = H * 0.84 + rnd() * H * 0.13;
bxA[i] = px[i]; byA[i] = py[i];
}
resetTail();
})();
durMs = parseFloat(speedEl.value) * 1000;
speedV.textContent = (durMs / 1000).toFixed(2) + " s";
trails = trailsEl.checked;
tour = tourEl.checked;
if (reduced) {
applyMotionUI();
goTo(0, { silent: true });
syncReadout(performance.now());
} else {
applyMotionUI();
var opening = durMs;
durMs = Math.max(2200, durMs * 1.6);
goTo(0, { silent: true });
durMs = opening;
holdUntil = performance.now() + 2600 + HOLD;
syncReadout(performance.now());
if (touchOnly) hint.hidden = true;
render(performance.now()); /* paint frame 0 now, don't wait on rAF */
start();
hintTimer = setTimeout(dismissHint, 9000);
}
/* the OS motion preference can flip while the page is open — honour it live,
the same way the colour scheme already is */
if (mqRM) {
var onMotion = function (e) {
var want = !!(e && "matches" in e ? e.matches : mqRM.matches);
if (want === reduced) return;
reduced = want;
applyMotionUI();
if (reduced) {
stop();
goTo(cue, { silent: true });
} else {
holdUntil = performance.now() + HOLD;
last = performance.now();
goTo(cue, { silent: true });
start();
}
syncReadout(performance.now());
};
if (mqRM.addEventListener) mqRM.addEventListener("change", onMotion);
else if (mqRM.addListener) mqRM.addListener(onMotion);
}
})();
</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.