$sloprun.dev

100k Particles

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 11 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A hundred thousand specks of light, all obeying the same two lines of arithmetic: fall toward the pointer under a softened inverse-square pull, and ride a swirling flow field that keeps drifting through the frame. It is raw WebGL1 — no library, one buffer of points, a nine-line fragment shader — and the FPS counter beside it is measured, not decorative. The drift is a true curl field rather than wobbly noise: a smooth potential is built on a 96×96×24 grid whose x, y and time axes all wrap, and the velocity is its 2D curl, so the divergence cancels on the stencil down to float32 round-off and the swarm folds into filaments instead of quietly draining into a few sinks. Brightness is solved for rather than guessed — every speck deposits light additively on dark and subtracts ink on light while the trail quad decays what is already there, so the gain is set to exposure × fade × canvasArea / (n × size²) and 50k particles in a phone-sized square land on the same exposure as 100k across a 4K display.

Drag Swirl to zero first: the flow switches off and you get a clean radial free-fall, a hundred thousand streaks converging on one burning core, which is the clearest look at the pull term alone. Then push Pull to ×2.00 and click repeatedly to punch expanding rings through the collapse. With no WebGL it drops to a 2,000-particle canvas2d version running the identical physics, and under prefers-reduced-motion it opens as a still constellation with a play button.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/particle-field" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="100k Particles — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/particle-field

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1183 lines 49.3 KB index.html
demos/particle-field/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>100k Particles — 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,.90); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.90); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.90); --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: 1180px; 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 {
  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) 272px; gap: 16px; }
@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(--surface);
  aspect-ratio: 16 / 10; box-shadow: var(--shadow);
}
/* wide layout: the field fills the row the control panel defines, so the
   showpiece is as tall as the instrument column instead of leaving dead space
   under it (an aspect-ratio box does not stretch, hence the explicit height) */
@media (min-width: 901px) { .field { aspect-ratio: auto; height: 100%; min-height: 360px; } }
@media (max-width: 900px) { .field { aspect-ratio: 3 / 2; } }
@media (max-width: 560px) { .field { aspect-ratio: 1 / 1; } }
canvas {
  display: block; width: 100%; height: 100%;
  touch-action: manipulation; cursor: crosshair;
  border-radius: calc(var(--radius) - 1px);
}
canvas[hidden] { display: none; }
canvas:focus { outline: none; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

.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 .5s ease; max-width: calc(100% - 20px);
}
.hint[hidden] { display: none; }
.hint.gone { opacity: 0; }

.overlay {
  position: absolute; inset: 0; display: grid; place-items: center;
  padding: 18px; text-align: center; pointer-events: none;
}
.overlay[hidden] { display: none; }
.overlay .box {
  pointer-events: auto; display: flex; flex-direction: column; align-items: center; gap: 10px;
  max-width: 36ch; background: var(--scrim); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow);
}
.overlay p { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--muted); }

.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 .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.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); }

.meta {
  display: flex; justify-content: space-between; gap: 8px; margin-top: 10px;
  padding-top: 9px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em; color: var(--muted);
}
.meta b { color: var(--ink); font-weight: 500; }

.note {
  display: none; align-items: flex-start; gap: 7px; margin-top: 10px;
  font-size: 11.5px; line-height: 1.4; color: var(--muted);
  background: var(--accent-soft); border: 1px solid var(--line);
  border-radius: 8px; padding: 7px 9px;
}
.note.show { display: flex; }
.note .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; margin-top: 4px; }

.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; }
.ctl .desc { display: block; font-size: 11.5px; color: var(--muted); line-height: 1.35; margin-top: 2px; }

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; }

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); }
button.big { font-size: 14px; padding: 9px 18px; }

.row { display: flex; flex-wrap: wrap; gap: 7px; }
.row > button { flex: 1 1 auto; }
.seg { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 7px; }
.seg button {
  font-family: var(--font-mono); font-size: 12.5px; letter-spacing: .02em;
  font-variant-numeric: tabular-nums; padding: 7px 4px;
}
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.cap { margin: 9px 0 0; font-size: 11.5px; line-height: 1.4; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }

.legend { display: flex; align-items: center; gap: 8px; margin-top: 11px; }
.legend .bar { flex: 1 1 auto; height: 6px; border-radius: 3px; background: var(--line); }
.legend .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }

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>100k Particles</h1>
    <p class="lede">
      A hundred thousand specks of light, each one following the same two rules:
      <b>fall toward the pointer</b>, and <b>ride a swirling flow</b> that keeps drifting
      through the field. It runs on your graphics card through <code>WebGL</code>, so the
      frame counter in the readout is real. <b>Move the pointer</b> to drag the swarm around;
      <b>click</b> to set off a shockwave.
    </p>
  </header>

  <main class="stage">

    <div class="field" id="field">
      <canvas id="glc" tabindex="0" aria-label="Particle field. Move the pointer to attract the particles, or use the arrow keys. Press Enter to set off a shockwave.">
        An animated field of particles. Your browser does not support canvas.
      </canvas>
      <canvas id="c2d" hidden tabindex="0" aria-label="Particle field. Move the pointer to attract the particles, or use the arrow keys. Press Enter to set off a shockwave."></canvas>
      <div class="hint" id="hint">move the pointer to pull · click to burst</div>
      <div class="overlay" id="overlay" hidden>
        <div class="box">
          <button class="primary big" id="overlayPlay" type="button">&#9654;&nbsp; Play the simulation</button>
          <p id="overlayNote">Paused.</p>
        </div>
      </div>
    </div>

    <div class="panel">

      <section class="card" aria-label="Live readout">
        <h2>Readout</h2>
        <div class="stats">
          <div class="stat"><span class="v" id="sFps">&mdash;</span><span class="k">fps</span></div>
          <div class="stat"><span class="v" id="sMs">&mdash;</span><span class="k">sim ms</span></div>
          <div class="stat"><span class="v" id="sN">100k</span><span class="k">particles</span></div>
        </div>
        <span class="pill" id="pill"><span class="dot"></span><span id="pillText">starting</span></span>
        <div class="meta">
          <span>renderer</span><span><b id="sRend">webgl</b></span>
        </div>
        <div class="meta">
          <span>sim ms = js time to move every particle</span>
        </div>
        <div class="note" id="note"><span class="dot" aria-hidden="true"></span><span id="noteText"></span></div>
      </section>

      <section class="card" aria-label="Particle count">
        <h2>Count</h2>
        <div class="seg" id="seg" role="group" aria-label="Number of particles">
          <button type="button" data-n="10000" aria-pressed="false" aria-label="10 thousand particles">10k</button>
          <button type="button" data-n="50000" aria-pressed="false" aria-label="50 thousand particles">50k</button>
          <button type="button" data-n="100000" aria-pressed="true" aria-label="100 thousand particles">100k</button>
        </div>
        <div class="legend">
          <span class="lbl">slow</span>
          <span class="bar" id="legendBar" aria-hidden="true"></span>
          <span class="lbl">fast</span>
        </div>
        <p class="cap">Colour is speed: dim specks are drifting, <b>bright</b> ones are falling fast.</p>
      </section>

      <section class="card" aria-label="Forces">
        <h2>Forces</h2>
        <div class="ctl">
          <div class="ctl-top"><label for="pull">Pull</label><span class="val" id="pullV" aria-hidden="true">&times;1.00</span></div>
          <input type="range" id="pull" min="0" max="2" step="0.05" value="1">
          <span class="desc">How hard the pointer drags everything toward it.</span>
        </div>
        <div class="ctl">
          <div class="ctl-top"><label for="swirl">Swirl</label><span class="val" id="swirlV" aria-hidden="true">&times;1.00</span></div>
          <input type="range" id="swirl" min="0" max="2" step="0.05" value="1">
          <span class="desc">Strength of the drifting flow field. Set it to zero for a clean free-fall.</span>
        </div>
      </section>

      <section class="card" aria-label="Controls">
        <h2>Controls</h2>
        <div class="row" style="margin-bottom:7px">
          <button type="button" class="primary" id="playBtn">Pause</button>
          <button type="button" id="burstBtn">Burst</button>
        </div>
        <div class="row">
          <button type="button" id="resetBtn">Reset swarm</button>
        </div>
      </section>

    </div>
  </main>

  <footer>demo &middot; 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);
      onThemeChange();
    }
  });
  if (mqDark) {
    var onScheme = function () { onThemeChange(); };
    if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
    else if (mqDark.addListener) mqDark.addListener(onScheme);
  }

  /* ── dom ────────────────────────────────────────────────────────────────── */
  var field   = document.getElementById("field");
  var glc     = document.getElementById("glc");
  var c2d     = document.getElementById("c2d");
  var hint    = document.getElementById("hint");
  var overlay = document.getElementById("overlay");
  var overlayPlay = document.getElementById("overlayPlay");
  var overlayNote = document.getElementById("overlayNote");
  var noteBox = document.getElementById("note");
  var noteText= document.getElementById("noteText");
  var sFps  = document.getElementById("sFps");
  var sMs   = document.getElementById("sMs");
  var sN    = document.getElementById("sN");
  var sRend = document.getElementById("sRend");
  var pill  = document.getElementById("pill");
  var pillText = document.getElementById("pillText");
  var legendBar= document.getElementById("legendBar");
  var seg   = document.getElementById("seg");
  var pullEl = document.getElementById("pull");
  var swirlEl= document.getElementById("swirl");
  var pullV  = document.getElementById("pullV");
  var swirlV = document.getElementById("swirlV");
  var playBtn = document.getElementById("playBtn");
  var burstBtn= document.getElementById("burstBtn");
  var resetBtn= document.getElementById("resetBtn");
  var live    = document.getElementById("live");

  /* ── colour reading ─────────────────────────────────────────────────────── */
  var COL = {
    surface:[255,255,255], ink:[26,29,33], muted:[90,96,104],
    accent:[192,86,0], line:[227,226,220]
  };
  var isLight = true;
  var uLow = new Float32Array(3), uHigh = new Float32Array(3);
  var pal2d = ["#000","#000","#000","#000","#000"];
  var lineCol = "rgba(0,0,0,.3)";

  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);
    if (h.length !== 6) return null;
    var n = parseInt(h, 16);
    if (isNaN(n)) return null;
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  function tok(name, fb) {
    var v = getComputedStyle(root).getPropertyValue(name);
    return hexToRgb(v) || fb;
  }
  function mix(a, b, t) { return [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t]; }
  function rgba(c, a) { return "rgba(" + Math.round(c[0]) + "," + Math.round(c[1]) + "," + Math.round(c[2]) + "," + a + ")"; }
  function lum(c) { return (0.2126*c[0] + 0.7152*c[1] + 0.0722*c[2]) / 255; }

  function readTheme() {
    COL.surface = tok("--surface", COL.surface);
    COL.ink     = tok("--ink",     COL.ink);
    COL.muted   = tok("--muted",   COL.muted);
    COL.accent  = tok("--accent",  COL.accent);
    COL.line    = tok("--line",    COL.line);
    isLight = lum(COL.surface) > 0.5;

    var lo, hi, i;
    if (isLight) {
      /* light: particles SUBTRACT from the page, so ink piles up amber then deep */
      lo = mix(COL.surface, COL.accent, 0.92);
      hi = mix(COL.accent, COL.ink, 0.72);
      for (i = 0; i < 3; i++) {
        uLow[i]  = Math.max(0, (COL.surface[i] - lo[i]) / 255);
        uHigh[i] = Math.max(0, (COL.surface[i] - hi[i]) / 255);
      }
    } else {
      /* dark: additive glow, cores burn toward the warm paper colour */
      lo = mix(COL.muted, COL.accent, 0.72);
      hi = mix(COL.accent, COL.ink, 0.30);
      for (i = 0; i < 3; i++) {
        uLow[i]  = (lo[i] / 255) * 0.45;
        uHigh[i] = (hi[i] / 255) * 1.0;
      }
    }

    /* canvas2d palette + constellation lines */
    for (i = 0; i < 5; i++) {
      var t = i / 4;
      if (isLight) pal2d[i] = rgba(mix(mix(COL.surface, COL.accent, 0.85), mix(COL.accent, COL.ink, 0.6), t), (0.42 + 0.50 * t).toFixed(3));
      else         pal2d[i] = rgba(mix(mix(COL.muted, COL.accent, 0.6), mix(COL.accent, COL.ink, 0.6), t), (0.30 + 0.55 * t).toFixed(3));
    }
    lineCol = rgba(COL.accent, isLight ? 0.28 : 0.34);

    legendBar.style.background = "linear-gradient(90deg," +
      rgba(isLight ? mix(COL.surface, COL.accent, 0.55) : mix(COL.muted, COL.accent, 0.5), 1) + "," +
      rgba(isLight ? mix(COL.accent, COL.ink, 0.6) : mix(COL.accent, COL.ink, 0.35), 1) + ")";
  }

  /* ── curl-noise flow field ──────────────────────────────────────────────── */
  /* A divergence-free drift field: build a smooth periodic scalar potential psi
     on a 96x96x24 grid (x, y, and time all wrap), then take its 2D curl,
     v = (dpsi/dy, -dpsi/dx). Curl of a scalar field has zero divergence, so the
     flow swirls without ever piling up or draining anywhere. */
  var NG = 96, NZ = 24, NGG = NG * NG, NTOT = NGG * NZ;
  var Fx = new Float32Array(NTOT), Fy = new Float32Array(NTOT);
  var Wx = new Float32Array(NGG),  Wy = new Float32Array(NGG);

  function blurLine(src, dst, base, stride, len, r) {
    var d = 2 * r + 1, inv = 1 / d, sum = 0, k, i;
    for (k = -r; k <= r; k++) sum += src[base + (((k % len) + len) % len) * stride];
    for (i = 0; i < len; i++) {
      dst[base + i * stride] = sum * inv;
      var ia = i + r + 1; if (ia >= len) ia -= len;   /* r < len, so one wrap is enough */
      var ib = i - r;     if (ib < 0)    ib += len;
      sum += src[base + ia * stride] - src[base + ib * stride];
    }
  }
  function blur3(A, T, r) {
    var z, y, x;
    for (z = 0; z < NZ; z++) for (y = 0; y < NG; y++) blurLine(A, T, (z * NG + y) * NG, 1, NG, r);
    for (z = 0; z < NZ; z++) for (x = 0; x < NG; x++) blurLine(T, A, z * NGG + x, NG, NG, r);
    for (y = 0; y < NG; y++) for (x = 0; x < NG; x++) blurLine(A, T, y * NG + x, NGG, NZ, Math.min(r, 8));
    A.set(T);
  }
  function rms(A) {
    var s = 0, i;
    for (i = 0; i < A.length; i++) s += A[i] * A[i];
    return Math.sqrt(s / A.length) || 1;
  }
  function buildField() {
    var A = new Float32Array(NTOT), B = new Float32Array(NTOT), T = new Float32Array(NTOT), i;
    for (i = 0; i < NTOT; i++) { A[i] = Math.random() * 2 - 1; B[i] = Math.random() * 2 - 1; }
    blur3(A, T, 7); blur3(A, T, 5);            /* broad swirls */
    blur3(B, T, 3); blur3(B, T, 2);            /* finer eddies */
    var ra = 1 / rms(A), rb = 1 / rms(B);
    for (i = 0; i < NTOT; i++) A[i] = A[i] * ra + B[i] * rb * 0.34;

    /* curl by central differences, wrapping on x and y */
    var z, y, x, o, xp, xm, yp, ym;
    for (z = 0; z < NZ; z++) {
      o = z * NGG;
      for (y = 0; y < NG; y++) {
        yp = ((y + 1) % NG) * NG; ym = ((y - 1 + NG) % NG) * NG;
        for (x = 0; x < NG; x++) {
          xp = (x + 1) % NG; xm = (x - 1 + NG) % NG;
          Fx[o + y * NG + x] =  (A[o + yp + x] - A[o + ym + x]) * 0.5;
          Fy[o + y * NG + x] = -(A[o + y * NG + xp] - A[o + y * NG + xm]) * 0.5;
        }
      }
    }
    var s = 1 / Math.max(rms(Fx), rms(Fy));
    for (i = 0; i < NTOT; i++) { Fx[i] *= s; Fy[i] *= s; }
    Wx.set(Fx.subarray(0, NGG)); Wy.set(Fy.subarray(0, NGG));
  }

  /* ── simulation state ───────────────────────────────────────────────────── */
  var MAXN = 100000;
  var P = new Float32Array(MAXN * 2);   /* position, world units */
  var V = new Float32Array(MAXN * 2);   /* velocity */
  var R = new Float32Array(MAXN * 3);   /* render feed: x, y, heat */
  var N = 100000;

  var MARGIN = 0.07;                    /* world overscan so wrapping is off-screen */
  var PULL_K = 0.090, SOFT = 0.0050, SWIRL_K = 1.90, DAMP_K = 1.60, HREF2 = 0.90;
  var ZRATE = 0.85;                     /* flow-field layers per second */
  var PSPD = 0.95, PW = 0.075, PWINV = 1 / 0.075, PAMP = 3.1, PLIFE = 1.15, PMAX = 3;

  var aspect = 1.6;                     /* world is aspect x 1 */
  var pull = 1, swirl = 1;
  var simT = 0;
  var atX = 0.8, atY = 0.5, tgX = 0.8, tgY = 0.5;
  var pointerMode = false, pointerTimer = 0;
  var pulses = [];
  var pX = new Float64Array(PMAX), pY = new Float64Array(PMAX), pR = new Float64Array(PMAX),
      pA = new Float64Array(PMAX), pIn = new Float64Array(PMAX), pOut = new Float64Array(PMAX);

  function seedRing(i0, i1) {
    var cx = aspect * 0.5, cy = 0.5;
    for (var i = i0; i < i1; i++) {
      var ang = Math.random() * Math.PI * 2;
      var rad = 0.09 + 0.34 * Math.sqrt(Math.random());
      var x = cx + Math.cos(ang) * rad * 1.25, y = cy + Math.sin(ang) * rad;
      var sp = 0.20 + Math.random() * 0.22;
      P[i * 2] = x; P[i * 2 + 1] = y;
      V[i * 2] = -Math.sin(ang) * sp * 1.1; V[i * 2 + 1] = Math.cos(ang) * sp;
      R[i * 3] = x; R[i * 3 + 1] = y; R[i * 3 + 2] = 0.45;
    }
  }
  function seedScatter(i0, i1) {
    for (var i = i0; i < i1; i++) {
      var x = -MARGIN + Math.random() * (aspect + 2 * MARGIN);
      var y = -MARGIN + Math.random() * (1 + 2 * MARGIN);
      var a = Math.random() * Math.PI * 2, sp = 0.25 + Math.random() * 0.25;
      P[i * 2] = x; P[i * 2 + 1] = y;
      V[i * 2] = Math.cos(a) * sp; V[i * 2 + 1] = Math.sin(a) * sp;
      R[i * 3] = x; R[i * 3 + 1] = y; R[i * 3 + 2] = 0.3;
    }
  }

  function addPulse(x, y) {
    if (pulses.length >= PMAX) pulses.shift();
    pulses.push({ x: x, y: y, t: 0 });
  }
  /* A burst fired while paused still gets to show itself: play the ring out
     over real frames (never a blocking loop) and then settle back to paused. */
  var burstRaf = 0, burstLeft = 0;
  function burstPlay() {
    burstRaf = 0;
    if (running) return;
    simT += 1 / 60; step(1 / 60); draw();
    if (--burstLeft > 0) burstRaf = requestAnimationFrame(burstPlay);
  }
  function burstAt(x, y) {
    if (!started) return false;
    addPulse(x, y);
    if (!running) {
      burstLeft = 48;                   /* ~0.8s: long enough for the ring to cross */
      if (!burstRaf) burstRaf = requestAnimationFrame(burstPlay);
    }
    return true;
  }

  function step(dt) {
    var i, gg = NGG, G = NG;

    /* blend the two nearest flow-field layers once per frame, not per particle */
    var zf = (simT * ZRATE) % NZ; if (zf < 0) zf += NZ;
    var z0 = zf | 0, z1 = (z0 + 1) % NZ, tz = zf - z0;
    tz = tz * tz * (3 - 2 * tz);
    var o0 = z0 * gg, o1 = z1 * gg;
    for (i = 0; i < gg; i++) {
      var ax0 = Fx[o0 + i]; Wx[i] = ax0 + (Fx[o1 + i] - ax0) * tz;
      var ay0 = Fy[o0 + i]; Wy[i] = ay0 + (Fy[o1 + i] - ay0) * tz;
    }

    /* pulse rings: age them and pre-compute their annulus bounds */
    var pn = 0;
    for (i = 0; i < pulses.length; i++) {
      var pu = pulses[i]; pu.t += dt;
      if (pu.t >= PLIFE) { pulses.splice(i, 1); i--; continue; }
      var r = PSPD * pu.t, rin = r - PW; if (rin < 0) rin = 0;
      pX[pn] = pu.x; pY[pn] = pu.y; pR[pn] = r;
      pA[pn] = PAMP * (1 - pu.t / PLIFE);
      pIn[pn] = rin * rin; pOut[pn] = (r + PW) * (r + PW);
      pn++;
    }

    var sw = SWIRL_K * swirl, pl = PULL_K * pull;
    var damp = Math.exp(-DAMP_K * dt);
    var tax = atX, tay = atY;
    var xmin = -MARGIN, xspan = aspect + 2 * MARGIN;
    var ymin = -MARGIN, yspan = 1 + 2 * MARGIN;
    var n = N, k;

    for (i = 0; i < n; i++) {
      var i2 = i << 1;
      var x = P[i2], y = P[i2 + 1], vx = V[i2], vy = V[i2 + 1];

      /* bilinear sample of the curl field (the field tiles every 1 world unit) */
      var u = x - Math.floor(x), v = y - Math.floor(y);
      var fx = u * G, fy = v * G;
      var x0 = fx | 0; if (x0 >= G) x0 = G - 1;
      var y0 = fy | 0; if (y0 >= G) y0 = G - 1;
      var sx = fx - x0, sy = fy - y0;
      var x1 = x0 + 1; if (x1 === G) x1 = 0;
      var y1 = y0 + 1; if (y1 === G) y1 = 0;
      var r0 = y0 * G, r1 = y1 * G;
      var q00 = Wx[r0 + x0], q10 = Wx[r0 + x1], q01 = Wx[r1 + x0], q11 = Wx[r1 + x1];
      var e0 = q00 + (q10 - q00) * sx, e1 = q01 + (q11 - q01) * sx;
      var cvx = e0 + (e1 - e0) * sy;
      q00 = Wy[r0 + x0]; q10 = Wy[r0 + x1]; q01 = Wy[r1 + x0]; q11 = Wy[r1 + x1];
      e0 = q00 + (q10 - q00) * sx; e1 = q01 + (q11 - q01) * sx;
      var cvy = e0 + (e1 - e0) * sy;
      vx += cvx * sw * dt; vy += cvy * sw * dt;

      /* softened inverse-square pull toward the attractor */
      var dx = tax - x, dy = tay - y;
      var d2 = dx * dx + dy * dy + SOFT;
      var g = pl / (d2 * Math.sqrt(d2)) * dt;
      vx += dx * g; vy += dy * g;

      /* expanding shockwave rings */
      for (k = 0; k < pn; k++) {
        var bx = x - pX[k], by = y - pY[k], b2 = bx * bx + by * by;
        if (b2 > pOut[k] || b2 < pIn[k]) continue;
        var bd = Math.sqrt(b2) + 1e-5;
        var q = (bd - pR[k]) * PWINV; q = 1 - q * q;
        if (q <= 0) continue;
        var imp = pA[k] * q * q / (0.10 + bd) * dt;
        vx += bx / bd * imp; vy += by / bd * imp;
      }

      vx *= damp; vy *= damp;
      x += vx * dt; y += vy * dt;
      if (x < xmin) x += xspan; else if (x >= xmin + xspan) x -= xspan;
      if (y < ymin) y += yspan; else if (y >= ymin + yspan) y -= yspan;

      P[i2] = x; P[i2 + 1] = y; V[i2] = vx; V[i2 + 1] = vy;
      var sp2 = vx * vx + vy * vy;
      var i3 = i * 3;
      R[i3] = x; R[i3 + 1] = y; R[i3 + 2] = sp2 / (sp2 + HREF2);
    }
  }

  /* ── renderers ──────────────────────────────────────────────────────────── */
  var mode = "gl", gl = null, ctx2 = null, cv = glc;
  var cssW = 800, cssH = 500, dpr = 1;
  var ptProg = null, flProg = null, ptU = {}, flU = {};
  var ptBuf = null, quadBuf = null, conPtBuf = null, conLnBuf = null;
  var conPts = null, conLns = null;
  var psMax = 64;

  var VS_PT =
    "attribute vec3 aPos;\n" +
    "uniform vec2 uScale;\nuniform vec2 uOff;\nuniform float uSize;\n" +
    "varying float vHeat;\n" +
    "void main(){\n" +
    "  vHeat = aPos.z;\n" +
    "  gl_Position = vec4(aPos.xy * uScale + uOff, 0.0, 1.0);\n" +
    "  gl_PointSize = uSize * (0.72 + 0.85 * vHeat);\n" +
    "}\n";
  var FS_PT =
    "precision mediump float;\n" +
    "uniform vec3 uLow;\nuniform vec3 uHigh;\nuniform float uGain;\nuniform float uSharp;\n" +
    "varying float vHeat;\n" +
    "void main(){\n" +
    "  vec2 d = gl_PointCoord - 0.5;\n" +
    "  float r2 = dot(d, d);\n" +
    "  if (r2 > 0.25) discard;\n" +
    "  float f = pow(1.0 - r2 * 4.0, uSharp);\n" +
    "  vec3 c = mix(uLow, uHigh, vHeat) * (f * uGain);\n" +
    "  gl_FragColor = vec4(c, 1.0);\n" +
    "}\n";
  var VS_FL =
    "attribute vec2 aXY;\nuniform vec2 uScale;\nuniform vec2 uOff;\n" +
    "void main(){ gl_Position = vec4(aXY * uScale + uOff, 0.0, 1.0); }\n";
  var FS_FL =
    "precision mediump float;\nuniform vec4 uColor;\n" +
    "void main(){ gl_FragColor = uColor; }\n";

  function makeProg(g, vs, fs, attrName) {
    var v = g.createShader(g.VERTEX_SHADER), f = g.createShader(g.FRAGMENT_SHADER);
    g.shaderSource(v, vs); g.compileShader(v);
    if (!g.getShaderParameter(v, g.COMPILE_STATUS)) return null;
    g.shaderSource(f, fs); g.compileShader(f);
    if (!g.getShaderParameter(f, g.COMPILE_STATUS)) return null;
    var p = g.createProgram();
    g.attachShader(p, v); g.attachShader(p, f);
    g.bindAttribLocation(p, 0, attrName);
    g.linkProgram(p);
    if (!g.getProgramParameter(p, g.LINK_STATUS)) return null;
    g.deleteShader(v); g.deleteShader(f);
    return p;
  }

  function initGL() {
    if (location.search.indexOf("renderer=2d") >= 0) return false;
    var opts = { alpha: false, antialias: false, depth: false, stencil: false,
                 preserveDrawingBuffer: true, powerPreference: "high-performance" };
    var g = null;
    try { g = glc.getContext("webgl", opts) || glc.getContext("experimental-webgl", opts); } catch (e) { g = null; }
    if (!g) return false;
    ptProg = makeProg(g, VS_PT, FS_PT, "aPos");
    flProg = makeProg(g, VS_FL, FS_FL, "aXY");
    if (!ptProg || !flProg) return false;

    ptU.scale = g.getUniformLocation(ptProg, "uScale");
    ptU.off   = g.getUniformLocation(ptProg, "uOff");
    ptU.size  = g.getUniformLocation(ptProg, "uSize");
    ptU.low   = g.getUniformLocation(ptProg, "uLow");
    ptU.high  = g.getUniformLocation(ptProg, "uHigh");
    ptU.gain  = g.getUniformLocation(ptProg, "uGain");
    ptU.sharp = g.getUniformLocation(ptProg, "uSharp");
    flU.scale = g.getUniformLocation(flProg, "uScale");
    flU.off   = g.getUniformLocation(flProg, "uOff");
    flU.color = g.getUniformLocation(flProg, "uColor");

    ptBuf = g.createBuffer();
    g.bindBuffer(g.ARRAY_BUFFER, ptBuf);
    g.bufferData(g.ARRAY_BUFFER, MAXN * 3 * 4, g.DYNAMIC_DRAW);
    quadBuf = g.createBuffer();
    g.bindBuffer(g.ARRAY_BUFFER, quadBuf);
    g.bufferData(g.ARRAY_BUFFER, new Float32Array([0,0, 1,0, 0,1, 1,1]), g.STATIC_DRAW);
    conPtBuf = g.createBuffer();
    conLnBuf = g.createBuffer();

    g.enableVertexAttribArray(0);
    g.disable(g.DEPTH_TEST);
    g.enable(g.BLEND);
    var pr = g.getParameter(g.ALIASED_POINT_SIZE_RANGE);
    if (pr && pr.length === 2 && pr[1] > 1) psMax = pr[1];

    glc.addEventListener("webglcontextlost", function (ev) {
      ev.preventDefault();
      if (mode !== "gl") return;
      stop();
      switchTo2D("The graphics context dropped out, so this switched to the 2,000-particle canvas fallback.");
      if (wantRun) start(); else drawStill();
    }, false);

    gl = g;
    return true;
  }

  function switchTo2D(msg) {
    mode = "2d"; gl = null;
    glc.hidden = true; c2d.hidden = false; cv = c2d;
    ctx2 = c2d.getContext("2d");
    N = 2000;
    var bs = seg.getElementsByTagName("button"), i;
    for (i = 0; i < bs.length; i++) { bs[i].disabled = true; bs[i].setAttribute("aria-pressed", "false"); }
    seg.setAttribute("aria-label", "Number of particles \u2014 locked to 2,000 in the canvas fallback");
    sRend.textContent = "canvas 2d";
    sN.textContent = "2k";
    noteText.textContent = msg;
    noteBox.className = "note show";
    seedRing(0, N);
    resize();
  }

  /* Exposure. Each frame every particle deposits light (dark: additive) or ink
     (light: subtractive), and the fade quad decays what is already there, so the
     steady-state brightness of a pixel is deposit/fade. Deposit scales with
     n * pointArea / canvasArea, which means a fixed gain would blow out on a
     small canvas and vanish on a big one. Solve for gain instead:
        gain = EXPO * fade * canvasArea / (n * size^2)
     Device pixel ratio cancels (both areas scale with dpr^2), so the picture
     looks the same on a phone and on a 4K monitor. */
  var EXPO_DARK = 3.60, EXPO_LIGHT = 1.95;
  function sizeFor(n) { return n >= 100000 ? 2.0 : (n >= 50000 ? 2.5 : 3.6); }
  /* Fade only sets how long the trail lives: because gain carries a factor of
     fade, the steady-state exposure is fade-invariant. Light needs the longer
     trail - loose specks of ink on white paper read as dust, a streak reads as
     a stroke - and a slightly softer nib for the same reason. */
  function fadeFor() { return isLight ? 0.17 : 0.11; }
  function sharpFor() { return isLight ? 2.8 : 2.0; }
  function gainFor(n) {
    var s = sizeFor(n);
    var e = (cssW * cssH) / (n * s * s);
    var g = (isLight ? EXPO_LIGHT : EXPO_DARK) * fadeFor() * e;
    return Math.max(0.03, Math.min(2.4, g));
  }

  function resize() {
    /* the canvas fills the field's CONTENT box - the 1px border is not ours */
    var w0 = field.clientWidth, h0 = field.clientHeight;
    if (w0 < 2 || h0 < 2) return;
    cssW = w0;
    cssH = h0;
    aspect = cssW / cssH;
    var want = Math.min(window.devicePixelRatio || 1, mode === "2d" ? 2 : (N >= 100000 ? 1.5 : 2));
    dpr = Math.max(1, want);
    var w = Math.round(cssW * dpr), h = Math.round(cssH * dpr);
    if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
    if (mode === "gl" && gl) { gl.viewport(0, 0, w, h); clearGL(); }
    buildConstellation();
  }

  function clearGL() {
    if (!gl) return;
    gl.clearColor(COL.surface[0] / 255, COL.surface[1] / 255, COL.surface[2] / 255, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);
  }

  function drawGL() {
    var w = glc.width, h = glc.height;
    gl.viewport(0, 0, w, h);

    /* fade the previous frame toward the panel colour: that is the trail */
    gl.blendEquation(gl.FUNC_ADD);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
    gl.useProgram(flProg);
    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
    gl.uniform2f(flU.scale, 2, -2);
    gl.uniform2f(flU.off, -1, 1);
    gl.uniform4f(flU.color, COL.surface[0]/255, COL.surface[1]/255, COL.surface[2]/255, fadeFor());
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

    /* particles: additive glow on dark, subtractive ink on light */
    gl.blendEquation(isLight ? gl.FUNC_REVERSE_SUBTRACT : gl.FUNC_ADD);
    gl.blendFunc(gl.ONE, gl.ONE);
    gl.useProgram(ptProg);
    gl.bindBuffer(gl.ARRAY_BUFFER, ptBuf);
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, R.subarray(0, N * 3));
    gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 12, 0);
    gl.uniform2f(ptU.scale, 2 / aspect, -2);
    gl.uniform2f(ptU.off, -1, 1);
    gl.uniform1f(ptU.size, Math.min(psMax, sizeFor(N) * dpr));
    gl.uniform1f(ptU.gain, gainFor(N));
    gl.uniform1f(ptU.sharp, sharpFor());
    gl.uniform3fv(ptU.low, uLow);
    gl.uniform3fv(ptU.high, uHigh);
    gl.drawArrays(gl.POINTS, 0, N);
  }

  function draw2D() {
    var w = c2d.width, h = c2d.height, s = h;
    ctx2.globalCompositeOperation = "source-over";
    ctx2.fillStyle = rgba(COL.surface, fadeFor() + 0.06);
    ctx2.fillRect(0, 0, w, h);
    ctx2.globalCompositeOperation = isLight ? "multiply" : "lighter";
    var q = Math.max(2, Math.round((isLight ? 2.9 : 2.2) * dpr));
    for (var b = 0; b < 5; b++) {
      ctx2.fillStyle = pal2d[b];
      for (var i = 0; i < N; i++) {
        var i3 = i * 3, hb = R[i3 + 2] * 5 | 0; if (hb > 4) hb = 4;
        if (hb !== b) continue;
        ctx2.fillRect(R[i3] * s, R[i3 + 1] * s, q, q);
      }
    }
    ctx2.globalCompositeOperation = "source-over";
  }

  function draw() { if (mode === "gl") drawGL(); else draw2D(); }

  /* ── still constellation (reduced motion / paused first paint) ───────────── */
  function buildConstellation() {
    var n = Math.max(220, Math.min(560, Math.round(300 * aspect + 120)));
    var xs = new Float64Array(n), ys = new Float64Array(n), deg = new Int32Array(n);
    var i, j;
    for (i = 0; i < n; i++) { xs[i] = Math.random() * aspect; ys[i] = Math.random(); }
    var segs = [], rr = 0.10 * 0.10;
    for (i = 0; i < n; i++) {
      for (j = i + 1; j < n; j++) {
        if (deg[i] >= 3) break;
        if (deg[j] >= 3) continue;
        var dx = xs[i] - xs[j], dy = ys[i] - ys[j];
        if (dx * dx + dy * dy < rr) { segs.push(i, j); deg[i]++; deg[j]++; }
      }
    }
    conPts = new Float32Array(n * 3);
    for (i = 0; i < n; i++) { conPts[i*3] = xs[i]; conPts[i*3+1] = ys[i]; conPts[i*3+2] = 0.25 + Math.random() * 0.5; }
    conLns = new Float32Array(segs.length * 2);
    for (i = 0; i < segs.length; i++) { conLns[i*2] = xs[segs[i]]; conLns[i*2+1] = ys[segs[i]]; }
    if (mode === "gl" && gl) {
      gl.bindBuffer(gl.ARRAY_BUFFER, conPtBuf); gl.bufferData(gl.ARRAY_BUFFER, conPts, gl.STATIC_DRAW);
      gl.bindBuffer(gl.ARRAY_BUFFER, conLnBuf); gl.bufferData(gl.ARRAY_BUFFER, conLns, gl.STATIC_DRAW);
    }
  }

  function drawStill() {
    if (!conPts || !conLns) buildConstellation();   /* resize() bails on a 0-size field */
    if (mode === "gl" && gl) {
      clearGL();
      gl.viewport(0, 0, glc.width, glc.height);
      gl.blendEquation(gl.FUNC_ADD);
      gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
      gl.useProgram(flProg);
      gl.bindBuffer(gl.ARRAY_BUFFER, conLnBuf);
      gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
      gl.uniform2f(flU.scale, 2 / aspect, -2);
      gl.uniform2f(flU.off, -1, 1);
      gl.uniform4f(flU.color, COL.accent[0]/255, COL.accent[1]/255, COL.accent[2]/255, isLight ? 0.30 : 0.36);
      gl.drawArrays(gl.LINES, 0, conLns.length / 2);

      gl.blendEquation(isLight ? gl.FUNC_REVERSE_SUBTRACT : gl.FUNC_ADD);
      gl.blendFunc(gl.ONE, gl.ONE);
      gl.useProgram(ptProg);
      gl.bindBuffer(gl.ARRAY_BUFFER, conPtBuf);
      gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 12, 0);
      gl.uniform2f(ptU.scale, 2 / aspect, -2);
      gl.uniform2f(ptU.off, -1, 1);
      gl.uniform1f(ptU.size, Math.min(psMax, 5.5 * dpr));
      gl.uniform1f(ptU.gain, isLight ? 1.3 : 1.6);
      gl.uniform1f(ptU.sharp, 2.0);
      gl.uniform3fv(ptU.low, uLow);
      gl.uniform3fv(ptU.high, uHigh);
      gl.drawArrays(gl.POINTS, 0, conPts.length / 3);
    } else if (ctx2) {
      var w = c2d.width, h = c2d.height, s = h, i;
      ctx2.globalCompositeOperation = "source-over";
      ctx2.fillStyle = rgba(COL.surface, 1);
      ctx2.fillRect(0, 0, w, h);
      ctx2.strokeStyle = lineCol;
      ctx2.lineWidth = Math.max(1, dpr * 0.9);
      ctx2.beginPath();
      for (i = 0; i < conLns.length; i += 4) {
        ctx2.moveTo(conLns[i] * s, conLns[i+1] * s);
        ctx2.lineTo(conLns[i+2] * s, conLns[i+3] * s);
      }
      ctx2.stroke();
      ctx2.fillStyle = pal2d[4];
      var q = Math.max(3, Math.round(3.2 * dpr));
      for (i = 0; i < conPts.length; i += 3) {
        ctx2.fillRect(conPts[i] * s - q / 2, conPts[i+1] * s - q / 2, q, q);
      }
    }
  }

  /* ── loop ───────────────────────────────────────────────────────────────── */
  var raf = 0, running = false, wantRun = false, started = false;
  var lastT = 0, fpsFrames = 0, fpsT0 = 0, msAvg = 0;

  function frame(ts) {
    raf = requestAnimationFrame(frame);
    var dt = (ts - lastT) / 1000;
    lastT = ts;
    if (!(dt > 0)) dt = 1 / 60;
    if (dt > 0.05) dt = 0.05;
    simT += dt;

    if (!pointerMode) {
      tgX = aspect * 0.5 + Math.cos(simT * 0.33) * aspect * 0.29;
      tgY = 0.5 + Math.sin(simT * 0.51) * 0.30;
    }
    var s = 1 - Math.exp(-9 * dt);
    atX += (tgX - atX) * s; atY += (tgY - atY) * s;

    var t0 = performance.now();
    step(dt);
    var stepMs = performance.now() - t0;   /* sim only - draw() is the GPU's problem */
    draw();
    msAvg = msAvg ? msAvg + (stepMs - msAvg) * 0.12 : stepMs;

    fpsFrames++;
    if (ts - fpsT0 >= 500) {
      var f = fpsFrames * 1000 / (ts - fpsT0);
      sFps.textContent = String(Math.round(f));
      sMs.textContent = msAvg.toFixed(1);
      fpsFrames = 0; fpsT0 = ts;
    }
  }

  function start() {
    if (running) return;
    if (!started) {
      started = true;
      seedRing(0, N);
      if (mode === "gl") clearGL();
      pulses.length = 0;
    }
    running = true;
    if (burstRaf) cancelAnimationFrame(burstRaf);
    burstRaf = 0; burstLeft = 0;
    lastT = performance.now(); fpsT0 = lastT; fpsFrames = 0;
    raf = requestAnimationFrame(frame);
    overlay.hidden = true;
    armHint();
    playBtn.textContent = "Pause";
    setPill(true, "running");
  }
  function stop() {
    running = false;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    if (burstRaf) cancelAnimationFrame(burstRaf);
    burstRaf = 0; burstLeft = 0;
  }
  function pauseByUser() {
    wantRun = false;
    stop();
    playBtn.textContent = "Play";
    overlayNote.textContent = "Paused. The swarm is holding exactly where you left it.";
    overlay.hidden = false;
    setPill(false, "paused");
    sFps.textContent = "0";
  }
  function setPill(on, txt) {
    pill.className = on ? "pill on" : "pill";
    pillText.textContent = txt;
  }

  var sayTimer = 0;
  function say(msg) {
    clearTimeout(sayTimer);
    sayTimer = setTimeout(function () { live.textContent = msg; }, 140);
  }

  /* ── pointer + keyboard ─────────────────────────────────────────────────── */
  function toWorld(clientX, clientY) {
    var r = field.getBoundingClientRect();
    var h = Math.max(1, field.clientHeight || r.height);
    return [(clientX - r.left - field.clientLeft) / h, (clientY - r.top - field.clientTop) / h];
  }
  function markPointer() {
    pointerMode = true;
    clearTimeout(pointerTimer);
    pointerTimer = setTimeout(function () { pointerMode = false; }, 2600);
  }
  var hintArmed = false;
  function armHint() {                  /* first play reveals it, wherever we started */
    if (hintArmed) return;
    hintArmed = true;
    hint.hidden = false; hint.className = "hint";
    setTimeout(dismissHint, 8000);
  }
  function dismissHint() {
    if (hint.hidden || hint.className === "hint gone") return;
    hint.className = "hint gone";
    setTimeout(function () { hint.hidden = true; }, 600);
  }
  field.addEventListener("pointermove", function (e) {
    var w = toWorld(e.clientX, e.clientY);
    tgX = w[0]; tgY = w[1];
    markPointer(); dismissHint();
  });
  field.addEventListener("pointerdown", function (e) {
    var w = toWorld(e.clientX, e.clientY);
    tgX = w[0]; tgY = w[1];
    markPointer(); dismissHint();
    burstAt(w[0], w[1]);
  });
  field.addEventListener("pointerleave", function () {
    clearTimeout(pointerTimer);
    pointerTimer = setTimeout(function () { pointerMode = false; }, 600);
  });

  function onKey(e) {
    var k = e.key, d = 0.055, hit = true;
    if (k === "ArrowLeft") tgX -= d;
    else if (k === "ArrowRight") tgX += d;
    else if (k === "ArrowUp") tgY -= d;
    else if (k === "ArrowDown") tgY += d;
    else if (k === "Enter" || k === " " || k === "Spacebar") {
      if (burstAt(atX, atY)) say("Shockwave released.");
    } else hit = false;
    if (!hit) return;
    e.preventDefault();
    tgX = Math.max(0, Math.min(aspect, tgX));
    tgY = Math.max(0, Math.min(1, tgY));
    markPointer(); dismissHint();
  }
  glc.addEventListener("keydown", onKey);
  c2d.addEventListener("keydown", onKey);

  /* ── controls ───────────────────────────────────────────────────────────── */
  function setCount(n) {
    if (mode !== "gl" || n === N) return;
    if (n > N) seedScatter(N, n);
    N = n;
    sN.textContent = Math.round(n / 1000) + "k";
    var bs = seg.getElementsByTagName("button"), i;
    for (i = 0; i < bs.length; i++) bs[i].setAttribute("aria-pressed", (+bs[i].getAttribute("data-n") === n) ? "true" : "false");
    resize();
    if (!running) { if (started) draw(); else drawStill(); }
    say(n.toLocaleString() + " particles.");
  }
  seg.addEventListener("click", function (e) {
    var b = e.target;
    while (b && b !== seg && b.tagName !== "BUTTON") b = b.parentNode;
    if (!b || b === seg || b.disabled) return;
    setCount(+b.getAttribute("data-n"));
  });

  function syncPull() { pull = +pullEl.value; pullV.textContent = "×" + pull.toFixed(2); }
  function syncSwirl() { swirl = +swirlEl.value; swirlV.textContent = "×" + swirl.toFixed(2); }
  pullEl.addEventListener("input", function () { syncPull(); if (!running && started) { step(0.0001); draw(); } });
  swirlEl.addEventListener("input", function () { syncSwirl(); if (!running && started) { step(0.0001); draw(); } });

  playBtn.addEventListener("click", function () {
    if (running) { pauseByUser(); say("Paused."); }
    else { wantRun = true; start(); say("Running."); }
  });
  overlayPlay.addEventListener("click", function () {
    wantRun = true; start(); say("Running.");
    (glc.hidden ? c2d : glc).focus();
  });
  burstBtn.addEventListener("click", function () {
    if (burstAt(atX, atY)) say("Shockwave released.");
    else say("Press play first, then burst.");
  });
  resetBtn.addEventListener("click", function () {
    if (!started) {                     /* still showing the constellation */
      buildConstellation();
      drawStill();
      say("New constellation.");
      return;
    }
    pulses.length = 0; burstLeft = 0;
    seedRing(0, N);
    atX = tgX = aspect * 0.5; atY = tgY = 0.5;
    if (mode === "gl") clearGL();
    if (!running) draw();
    say("Swarm reset.");
  });

  /* ── theme + lifecycle ──────────────────────────────────────────────────── */
  function onThemeChange() {
    readTheme();
    if (mode === "gl" && gl) clearGL();
    if (!running) { if (started) draw(); else drawStill(); }
  }

  document.addEventListener("visibilitychange", function () {
    if (document.hidden) stop();
    else if (wantRun && !running) { lastT = performance.now(); fpsT0 = lastT; fpsFrames = 0; running = true; raf = requestAnimationFrame(frame); }
  });

  var rzTimer = 0;
  function onResize() {
    clearTimeout(rzTimer);
    rzTimer = setTimeout(function () {
      resize();
      if (!running) { if (started) draw(); else drawStill(); }
    }, 90);
  }
  if (window.ResizeObserver) new ResizeObserver(onResize).observe(field);
  window.addEventListener("resize", onResize);

  /* ── boot ───────────────────────────────────────────────────────────────── */
  readTheme();
  buildField();

  if (!initGL()) {
    switchTo2D("WebGL could not start here, so this is the 2,000-particle canvas fallback — same physics, fewer specks.");
  } else {
    sRend.textContent = "webgl";
    resize();
    var startN = (cssW < 430 || (window.devicePixelRatio || 1) > 2.4) ? 50000 : 100000;
    N = startN;
    sN.textContent = Math.round(N / 1000) + "k";
    var bs0 = seg.getElementsByTagName("button");
    for (var bi = 0; bi < bs0.length; bi++) bs0[bi].setAttribute("aria-pressed", (+bs0[bi].getAttribute("data-n") === N) ? "true" : "false");
    resize();
    seedRing(0, N);
  }

  syncPull(); syncSwirl();
  atX = tgX = aspect * 0.5; atY = tgY = 0.5;

  if (reduced) {
    wantRun = false;
    started = false;
    hint.hidden = true;
    overlayNote.textContent = "Your system asks for reduced motion, so this opens as a still constellation. Press play for the live " + N.toLocaleString() + "-particle field.";
    overlay.hidden = false;
    playBtn.textContent = "Play";
    setPill(false, "still");
    sFps.textContent = "0";
    sMs.textContent = "0.0";
    drawStill();
  } else {
    wantRun = true;
    start();
  }
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong