$sloprun.dev

Double Pendulum

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

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

▶ 8 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

Two rods, two weights, one pivot — the simplest machine with no closed-form solution, integrated here with RK4 at 240 steps a second and traced as it swings. The point of the demo is the twin in amber: an identical pendulum started exactly 0.001 radians away, running the identical maths, which hides behind the first one for a few seconds and then peels off for good. The energy badge barely moves off zero the whole time, so that climbing line on the log-scale chart is chaos rather than sloppy arithmetic. Drag either weight somewhere new and fork a fresh twin, then compare "Chaotic" with "Barely nudged" to see how the same 0.001 rad can matter in four seconds or never at all.

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/double-pendulum" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Double Pendulum — 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/double-pendulum

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1265 lines 53.8 KB index.html
demos/double-pendulum/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>Double Pendulum — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local additions (scrims need alpha; tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.9); --scrim-soft: rgba(246,246,243,.42); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.9); --scrim-soft: rgba(20,22,26,.5); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.9); --scrim-soft: rgba(20,22,26,.5); --shadow: 0 1px 2px rgba(0,0,0,.35); }

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }

.wrap {
  max-width: 1140px; margin: 0 auto;
  padding: 20px 16px 22px;
  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: 88ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede .amb { color: var(--accent); 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); } }
.left { display: flex; flex-direction: column; gap: 14px; min-width: 0; }

.field {
  position: relative; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius);
  background-color: var(--surface);
  background-image: radial-gradient(var(--line) 1.1px, transparent 1.2px);
  background-size: 26px 26px; background-position: -13px -13px;
  aspect-ratio: 4 / 3; box-shadow: var(--shadow);
}
@media (min-width: 561px) { .field { max-height: 58vh; } }
@media (max-width: 560px) { .field { aspect-ratio: 1 / 1; } }
canvas.stagecanvas { display: block; width: 100%; height: 100%; touch-action: none; }
canvas.stagecanvas:focus { outline: none; }
canvas.stagecanvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

.hint {
  position: absolute; left: 10px; top: 10px; pointer-events: none;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
  color: var(--muted); background: var(--scrim);
  max-width: calc(100% - 20px);
  border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
  transition: opacity .45s ease;
}
.hint[hidden] { display: none; }
.hint.gone { opacity: 0; }

.overlay {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: var(--scrim-soft); padding: 16px; text-align: left;
  align-content: end; justify-items: center;
  pointer-events: none;   /* the paused card must not swallow drags on the canvas */
}
.overlay .box { pointer-events: none; }      /* let a drag reach a bob under the card */
.overlay .box > button { pointer-events: auto; }
.overlay[hidden] { display: none; }
.overlay .box {
  display: flex; flex-wrap: wrap; align-items: center; gap: 11px; max-width: min(100%, 52ch);
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 11px 13px; box-shadow: 0 6px 20px rgba(0,0,0,.10);
}
.overlay .box > button { flex: 0 0 auto; }
.overlay p { margin: 0; flex: 1 1 16ch; font-size: 12.5px; line-height: 1.45; color: var(--muted); }
@media (max-width: 480px) { .overlay .box { justify-content: center; text-align: center; } }

.panel { display: flex; flex-direction: column; gap: 12px; }
@media (max-width: 400px) {
  .stats { gap: 7px; }
  .stat .v { font-size: 16.5px; }
  .stat .v .u { font-size: 10px; }
  .stat .k { font-size: 9px; letter-spacing: .06em; }
}
/* tablet: the stack becomes two columns so the readouts stay grouped, not stretched */
@media (max-width: 900px) and (min-width: 620px) {
  .panel { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); align-items: start; }
  .panel > .card:last-child { grid-column: 1 / -1; }
}
.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;
}
.card p.note { margin: 9px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }

.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: 19px; line-height: 1.05;
  font-variant-numeric: tabular-nums; letter-spacing: -.02em; white-space: nowrap;
}
.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);
}
.stat .v.amb { color: var(--accent); }

.subline {
  margin-top: 10px; font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  font-variant-numeric: tabular-nums; letter-spacing: .02em;
}

.pill {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 9px;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .03em;
  padding: 3px 9px 3px 7px; border-radius: 999px;
  border: 1px solid var(--line); color: var(--muted); background: var(--bg);
  font-variant-numeric: tabular-nums;
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.ok { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.ok .dot { background: var(--run); }
.pill.warn { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.warn .dot { background: var(--accent); }

.sparkbox { margin-top: 11px; position: relative; }
canvas.spark { display: block; width: 100%; height: 86px; }
.sparkcap {
  display: flex; justify-content: space-between; gap: 8px; margin-top: 3px;
  font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .07em;
  text-transform: uppercase; color: var(--muted);
}

.ctl { display: block; margin-bottom: 13px; }
.ctl:last-child { margin-bottom: 0; }
.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: 1px; min-height: 2.7em; }

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; }
.presets { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; }
.presets button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }

/* control strip under the stage — keeps play, reset and the four starts in view */
.controls { padding: 11px 13px 12px; }
.ctlbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.ctlbar .presets { flex: 1 1 300px; grid-template-columns: repeat(4, minmax(0,1fr)); }
.ctlbar .transport { display: flex; gap: 7px; flex: 0 0 auto; padding-left: 11px; border-left: 1px solid var(--line); }
.ctlbar .transport button { min-width: 76px; }
@media (max-width: 640px) {
  .ctlbar .presets { flex-basis: 100%; grid-template-columns: repeat(2, minmax(0,1fr)); }
  .ctlbar .transport { flex: 1 1 100%; padding-left: 0; border-left: 0; }
  .ctlbar .transport button { flex: 1 1 auto; }
}
.controls .keys { margin-top: 10px; }

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

.keys {
  margin-top: 11px; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.6;
  color: var(--muted); letter-spacing: .02em;
}
.keys kbd {
  font-family: inherit; font-size: 10px; border: 1px solid var(--line);
  border-radius: 4px; padding: 0 4px; background: var(--bg); color: var(--ink);
}

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>Double Pendulum</h1>
    <p class="lede">
      Two rods, two weights, one pivot — and no closed-form answer. This solves the real equations of
      motion step by step (RK4, 240 steps a second) and traces where the lower weight has been.
      A second pendulum runs alongside it in <span class="amb">amber</span>, started just
      <b>0.001 radians</b> away. Same rules, same maths, same clock. Watch how long they stay together.
    </p>
  </header>

  <main class="stage">

    <div class="left">

    <div class="field" id="field">
      <canvas class="stagecanvas" id="c" tabindex="0"
        aria-label="Swinging double pendulum. Drag either weight to reposition it. With the keyboard: arrow keys rotate the selected arm, 1 and 2 pick an arm, space plays or pauses.">
        An animated double pendulum. Your browser does not support canvas.
      </canvas>
      <div class="hint" id="hint">drag either weight</div>
      <div class="overlay" id="overlay" hidden>
        <div class="box">
          <button class="primary big" id="overlayPlay" type="button">▶&nbsp; Play simulation</button>
          <p id="overlayNote">Paused.</p>
        </div>
      </div>
    </div>

    <section class="card controls" aria-label="Setup">
      <div class="ctlbar">
        <div class="presets" id="presets" role="group" aria-label="Starting positions">
          <button type="button" data-p="0" aria-pressed="true">Chaotic</button>
          <button type="button" data-p="1" aria-pressed="false">Both flat</button>
          <button type="button" data-p="2" aria-pressed="false">Barely nudged</button>
          <button type="button" data-p="3" aria-pressed="false">Upside down</button>
        </div>
        <div class="transport">
          <button type="button" id="playBtn">Pause</button>
          <button type="button" id="resetBtn">Reset</button>
        </div>
      </div>
      <p class="keys">
        <kbd>space</kbd> play/pause · <kbd>←</kbd><kbd>→</kbd> rotate arm ·
        <kbd>1</kbd><kbd>2</kbd> pick arm · <kbd>f</kbd> fork · <kbd>r</kbd> reset
      </p>
    </section>

    </div>

    <div class="panel">

      <section class="card" aria-label="Readout">
        <h2>Readout</h2>
        <div class="stats">
          <div class="stat"><span class="v" id="sEnergy">—<span class="u">J</span></span><span class="k">energy</span></div>
          <div class="stat"><span class="v" id="sT1">—<span class="u">°</span></span><span class="k">arm 1</span></div>
          <div class="stat"><span class="v" id="sT2">—<span class="u">°</span></span><span class="k">arm 2</span></div>
        </div>
        <div class="subline" id="sSub">t = 0.00 s · 240 Hz · RK4</div>
        <span class="pill" id="pill"><span class="dot"></span><span id="pillText">starting</span></span>
      </section>

      <section class="card" aria-label="The twin">
        <h2>The twin</h2>
        <div class="stats">
          <div class="stat"><span class="v amb" id="sGap">—</span><span class="k">gap</span></div>
          <div class="stat"><span class="v" id="sGrow">—</span><span class="k">spread</span></div>
          <div class="stat"><span class="v" id="sSplit">—</span><span class="k">split at</span></div>
        </div>

        <div class="sparkbox">
          <canvas class="spark" id="spark" aria-hidden="true"></canvas>
          <div class="sparkcap"><span>tip gap over time · log scale</span><span id="sparkWin">10 s</span></div>
        </div>

        <div class="row" style="margin-top:11px">
          <button type="button" class="primary" id="forkBtn">Fork a twin</button>
          <button type="button" id="unforkBtn">Remove</button>
        </div>
        <p class="note" id="twinNote">
          The twin starts 0.001 rad from this one — about the width of a hair at arm's length.
        </p>
      </section>

      <section class="card" aria-label="Physics">
        <h2>Physics</h2>

        <div class="ctl">
          <div class="ctl-top">
            <label for="grav">Gravity</label><span class="val" id="gravV" aria-hidden="true">9.81 m/s²</span>
          </div>
          <input type="range" id="grav" min="0" max="25" step="0.01" value="9.81" aria-describedby="gravDesc">
          <span class="desc" id="gravDesc">Earth. Slide down for slow, floaty swings; up for a frantic one.</span>
        </div>

        <div class="ctl">
          <div class="ctl-top">
            <label for="damp">Damping</label><span class="val" id="dampV" aria-hidden="true">0.00 /s</span>
          </div>
          <input type="range" id="damp" min="0" max="0.6" step="0.005" value="0" aria-describedby="dampDesc">
          <span class="desc" id="dampDesc">Off. Friction at the joints — leave it at zero and the energy stays put.</span>
        </div>

        <label class="switch" for="trace">
          <input type="checkbox" id="trace" checked>
          <span class="track" aria-hidden="true"></span>
          <span>Fading trace</span>
        </label>
      </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);
      repaint();
    }
  });
  if (mqDark) {
    var onScheme = function () { repaint(); };
    if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
    else if (mqDark.addListener) mqDark.addListener(onScheme);
  }

  /* ── colours from tokens ────────────────────────────────────────────────── */
  var COL = {
    surface: [255,255,255], ink: [26,29,33], muted: [90,96,104],
    accent: [192,86,0], line: [227,226,220], run: [26,127,55], bg: [246,246,243]
  };
  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 null;
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  function tok(name, fb) { return hexToRgb(getComputedStyle(root).getPropertyValue(name)) || fb; }
  function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
  function 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);
    COL.run     = tok("--run",     COL.run);
    COL.bg      = tok("--bg",      COL.bg);
  }

  /* ── dom ────────────────────────────────────────────────────────────────── */
  var field   = document.getElementById("field");
  var canvas  = document.getElementById("c");
  var ctx     = canvas.getContext && canvas.getContext("2d");
  var spark   = document.getElementById("spark");
  var sctx    = spark.getContext && spark.getContext("2d");
  var hint    = document.getElementById("hint");
  var overlay = document.getElementById("overlay");
  var overlayPlay = document.getElementById("overlayPlay");
  var overlayNote = document.getElementById("overlayNote");
  var sEnergy = document.getElementById("sEnergy");
  var sT1 = document.getElementById("sT1");
  var sT2 = document.getElementById("sT2");
  var sSub = document.getElementById("sSub");
  var pill = document.getElementById("pill");
  var pillText = document.getElementById("pillText");
  var sGap = document.getElementById("sGap");
  var sGrow = document.getElementById("sGrow");
  var sSplit = document.getElementById("sSplit");
  var sparkWin = document.getElementById("sparkWin");
  var forkBtn = document.getElementById("forkBtn");
  var unforkBtn = document.getElementById("unforkBtn");
  var twinNote = document.getElementById("twinNote");
  var gravEl = document.getElementById("grav");
  var dampEl = document.getElementById("damp");
  var gravV = document.getElementById("gravV");
  var dampV = document.getElementById("dampV");
  var gravDesc = document.getElementById("gravDesc");
  var dampDesc = document.getElementById("dampDesc");
  var traceEl = document.getElementById("trace");
  var presetsEl = document.getElementById("presets");
  var playBtn = document.getElementById("playBtn");
  var resetBtn = document.getElementById("resetBtn");
  var live = document.getElementById("live");

  /* the one thing that can actually fail here: a browser that refuses a 2D context.
     Say so in the stage instead of throwing and leaving an empty box. */
  if (!ctx || !sctx) {
    var failMsg = "This browser would not give the page a 2D canvas, so the pendulum cannot be drawn.";
    field.innerHTML = "";
    field.style.display = "grid";
    field.style.placeItems = "center";
    field.style.padding = "20px";
    var failEl = document.createElement("p");
    failEl.style.cssText = "margin:0;font-family:var(--font-mono);font-size:12px;line-height:1.5;color:var(--muted);text-align:center";
    failEl.textContent = failMsg;
    field.appendChild(failEl);
    live.textContent = failMsg;
    return;
  }

  /* ── model ──────────────────────────────────────────────────────────────── */
  var L1 = 1, L2 = 1, M1 = 1, M2 = 1;     /* metres, kilograms */
  var STEP = 1 / 240;                      /* fixed integrator step, seconds */
  var EPS = 0.001;                         /* radians between the twins */
  var SPLIT_AT = 0.10;                     /* metres: "visibly split" */

  var g = 9.81, damp = 0;
  var main = [2.0944, 0, 2.6180, 0];       /* th1, w1, th2, w2 */
  var twin = null;
  var t = 0, E0 = 0;

  var PRESETS = [
    { name: "Chaotic",       s: [2.0944, 0, 2.6180, 0] },
    { name: "Both flat",     s: [1.5708, 0, 1.5708, 0] },
    { name: "Barely nudged", s: [0.3491, 0, 0.3142, 0] },
    { name: "Upside down",   s: [3.1400, 0, 3.1400, 0] }
  ];
  var preset = 0;

  var traceMain = { x: [], y: [] };
  var traceTwin = { x: [], y: [] };
  var TRACE_MAX = 1300;

  var samples = [];                 /* {t, gap} for the sparkline */
  var SAMPLE_DT = 1 / 20;
  var sampleAcc = 0;
  var forkT = 0, gap0 = 0, splitT = -1, noteStuck = false;

  var running = false, dragging = 0, wasRunning = false, rafId = 0;
  var selArm = 1, hoverArm = 0;
  var fps = 0, lastTs = 0, acc = 0, uiAcc = 0;

  /* ── physics ────────────────────────────────────────────────────────────── */
  function deriv(s, out) {
    var th1 = s[0], w1 = s[1], th2 = s[2], w2 = s[3];
    var d = th1 - th2;
    var sd = Math.sin(d), cd = Math.cos(d);
    var den = 2 * M1 + M2 - M2 * Math.cos(2 * d);
    var a1 = (-g * (2 * M1 + M2) * Math.sin(th1)
              - M2 * g * Math.sin(th1 - 2 * th2)
              - 2 * sd * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * cd)) / (L1 * den);
    var a2 = (2 * sd * (w1 * w1 * L1 * (M1 + M2)
              + g * (M1 + M2) * Math.cos(th1)
              + w2 * w2 * L2 * M2 * cd)) / (L2 * den);
    out[0] = w1; out[1] = a1 - damp * w1;
    out[2] = w2; out[3] = a2 - damp * w2;
    return out;
  }

  var k1 = [0,0,0,0], k2 = [0,0,0,0], k3 = [0,0,0,0], k4 = [0,0,0,0], tmp = [0,0,0,0];
  function rk4(s, h) {
    var i;
    deriv(s, k1);
    for (i = 0; i < 4; i++) tmp[i] = s[i] + k1[i] * h / 2;
    deriv(tmp, k2);
    for (i = 0; i < 4; i++) tmp[i] = s[i] + k2[i] * h / 2;
    deriv(tmp, k3);
    for (i = 0; i < 4; i++) tmp[i] = s[i] + k3[i] * h;
    deriv(tmp, k4);
    for (i = 0; i < 4; i++) s[i] += h * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]) / 6;
    s[0] = wrap(s[0]); s[2] = wrap(s[2]);
    return s;
  }

  function wrap(a) { return Math.atan2(Math.sin(a), Math.cos(a)); }

  /* energy above the at-rest hanging position, in joules */
  function energy(s) {
    var th1 = s[0], w1 = s[1], th2 = s[2], w2 = s[3];
    var ke = 0.5 * M1 * L1 * L1 * w1 * w1
           + 0.5 * M2 * (L1 * L1 * w1 * w1 + L2 * L2 * w2 * w2
                         + 2 * L1 * L2 * w1 * w2 * Math.cos(th1 - th2));
    var pe = (M1 + M2) * g * L1 * (1 - Math.cos(th1)) + M2 * g * L2 * (1 - Math.cos(th2));
    return ke + pe;
  }

  function joint(s) { return { x: L1 * Math.sin(s[0]), y: -L1 * Math.cos(s[0]) }; }
  function tip(s) {
    return { x: L1 * Math.sin(s[0]) + L2 * Math.sin(s[2]),
             y: -L1 * Math.cos(s[0]) - L2 * Math.cos(s[2]) };
  }
  function tipGap() {
    if (!twin) return 0;
    var a = tip(main), b = tip(twin);
    return Math.hypot(a.x - b.x, a.y - b.y);
  }
  /* angular separation, the phase-independent measure: exactly EPS at the fork */
  function angSep() {
    if (!twin) return 0;
    return Math.hypot(wrap(main[0] - twin[0]), wrap(main[2] - twin[2]));
  }

  /* ── trace bookkeeping ──────────────────────────────────────────────────── */
  function pushTrace(tr, p) {
    tr.x.push(p.x); tr.y.push(p.y);
    if (tr.x.length > TRACE_MAX) { tr.x.shift(); tr.y.shift(); }
  }
  function clearTraces() { traceMain.x.length = 0; traceMain.y.length = 0; traceTwin.x.length = 0; traceTwin.y.length = 0; }

  /* ── simulation stepping ────────────────────────────────────────────────── */
  var traceEvery = 2, traceCount = 0;
  function step(h) {
    rk4(main, h);
    if (twin) rk4(twin, h);
    t += h;
    traceCount++;
    if (traceCount >= traceEvery) {
      traceCount = 0;
      pushTrace(traceMain, tip(main));
      if (twin) pushTrace(traceTwin, tip(twin));
    }
    if (twin) {
      sampleAcc += h;
      if (sampleAcc >= SAMPLE_DT) {
        sampleAcc = 0;
        var gp = tipGap();
        samples.push({ t: t, g: gp });
        if (samples.length > 4900) samples.shift();
        if (splitT < 0 && gp >= SPLIT_AT) {
          splitT = t;
          say("The twins have visibly split, " + (splitT - forkT).toFixed(2) + " seconds after the fork.");
        }
      }
    }
  }

  /* ── setup / state changes ──────────────────────────────────────────────── */
  function applyPreset(i) {
    preset = i;
    var s = PRESETS[i].s;
    main[0] = s[0]; main[1] = s[1]; main[2] = s[2]; main[3] = s[3];
    resetRun();
    var btns = presetsEl.querySelectorAll("button");
    for (var k = 0; k < btns.length; k++) btns[k].setAttribute("aria-pressed", k === i ? "true" : "false");
  }

  function resetRun() {
    t = 0; E0 = energy(main);
    clearTraces();
    if (twin) syncTwin();
    else { samples.length = 0; splitT = -1; }
    pushTrace(traceMain, tip(main));
    if (twin) pushTrace(traceTwin, tip(twin));
  }

  function syncTwin() {
    twin = [wrap(main[0] + EPS), main[1], main[2], main[3]];
    traceTwin.x.length = 0; traceTwin.y.length = 0;
    samples.length = 0; splitT = -1; noteStuck = false;
    forkT = t; gap0 = Math.max(tipGap(), 1e-9);
    sampleAcc = 0;
    samples.push({ t: t, g: gap0 });
  }

  function fork(quiet) {
    syncTwin();
    unforkBtn.disabled = false;
    forkBtn.textContent = "Fork again";
    if (!quiet) {
      twinNote.textContent = "Forked at t = " + t.toFixed(2) + " s. Both pendulums now run the same equations at the same step size — the only difference is 0.001 rad.";
      say("Twin forked, 0.001 radians apart.");
    }
    updateUI(true);
  }

  function unfork() {
    twin = null;
    traceTwin.x.length = 0; traceTwin.y.length = 0;
    samples.length = 0; splitT = -1; noteStuck = false;
    unforkBtn.disabled = true;
    forkBtn.textContent = "Fork a twin";
    twinNote.textContent = "The twin starts 0.001 rad from this one — about the width of a hair at arm's length.";
    say("Twin removed.");
    updateUI(true);
  }

  function markCustom() {
    var b = presetsEl.querySelectorAll("button");
    for (var i = 0; i < b.length; i++) b[i].setAttribute("aria-pressed", "false");
  }

  /* editing the pendulum by hand: freeze it, restart the clock */
  function beginEdit() {
    markCustom();
    main[1] = 0; main[3] = 0;
    t = 0; clearTraces();
    if (twin) syncTwin();
    else { samples.length = 0; splitT = -1; }
    E0 = energy(main);
  }

  /* ── canvas geometry ────────────────────────────────────────────────────── */
  var W = 0, H = 0, scale = 100, ox = 0, oy = 0;
  function resize() {
    /* measure the canvas, not .field — the field's rect includes its 1px border,
       which would size the backing store 2px larger than the box it is painted into
       and stretch the scene (circles turn faintly elliptical). */
    var r = canvas.getBoundingClientRect();
    W = Math.max(120, Math.round(r.width  || field.clientWidth));
    H = Math.max(120, Math.round(r.height || field.clientHeight));
    var dpr = Math.min(2, window.devicePixelRatio || 1);
    canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    scale = Math.min(W, H) / 4.45;
    ox = W / 2; oy = H / 2;
    resizeSpark();
    render();
    drawSpark();
  }
  function resizeSpark() {
    var r = spark.getBoundingClientRect();
    var dpr = Math.min(2, window.devicePixelRatio || 1);
    spark.width = Math.max(2, Math.round(r.width * dpr));
    spark.height = Math.max(2, Math.round(r.height * dpr));
    sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  function sx(x) { return ox + x * scale; }
  function sy(y) { return oy - y * scale; }

  /* ── drawing ────────────────────────────────────────────────────────────── */
  function drawTraceLine(tr, col, maxA) {
    var n = tr.x.length;
    if (n < 2) return;
    var B = 26, b, i0, i1, i;
    ctx.lineCap = "round"; ctx.lineJoin = "round";
    for (b = 0; b < B; b++) {
      i0 = Math.floor(n * b / B);
      i1 = Math.floor(n * (b + 1) / B);
      if (i1 - i0 < 1) continue;
      var f = (b + 1) / B;
      ctx.beginPath();
      ctx.moveTo(sx(tr.x[i0]), sy(tr.y[i0]));
      for (i = i0 + 1; i <= i1 && i < n; i++) ctx.lineTo(sx(tr.x[i]), sy(tr.y[i]));
      ctx.strokeStyle = rgba(col, maxA * Math.pow(f, 1.7));
      ctx.lineWidth = 0.7 + 1.7 * f;
      ctx.stroke();
    }
  }

  function drawArm(s, col, alpha, isMain) {
    var j = joint(s), p = tip(s);
    var jx = sx(j.x), jy = sy(j.y), px = sx(p.x), py = sy(p.y);
    var r1 = isMain ? 8 : 7, r2 = isMain ? 10.5 : 9;

    ctx.lineCap = "round";
    ctx.strokeStyle = rgba(col, alpha);
    ctx.lineWidth = isMain ? 3.2 : 2.4;
    ctx.beginPath();
    ctx.moveTo(ox, oy); ctx.lineTo(jx, jy); ctx.lineTo(px, py);
    ctx.stroke();

    ctx.fillStyle = rgba(col, alpha);
    ctx.beginPath(); ctx.arc(jx, jy, r1, 0, 6.2832); ctx.fill();
    ctx.beginPath(); ctx.arc(px, py, r2, 0, 6.2832); ctx.fill();

    /* inner dot keeps the weights legible against the trace in both themes */
    ctx.fillStyle = rgba(COL.surface, isMain ? 0.9 : 0.75);
    ctx.beginPath(); ctx.arc(px, py, r2 * 0.38, 0, 6.2832); ctx.fill();

    if (isMain) {
      var focused = document.activeElement === canvas;
      var hi = dragging || (focused ? selArm : hoverArm);
      if (hi) {
        var hx = hi === 1 ? jx : px, hy = hi === 1 ? jy : py, hr = (hi === 1 ? r1 : r2) + 6;
        ctx.strokeStyle = rgba(COL.accent, 0.9);
        ctx.lineWidth = 1.6;
        ctx.setLineDash([3, 3]);
        ctx.beginPath(); ctx.arc(hx, hy, hr, 0, 6.2832); ctx.stroke();
        ctx.setLineDash([]);
      }
    }
  }

  function drawFrame() {
    ctx.clearRect(0, 0, W, H);

    /* reach circle + pivot cross */
    ctx.strokeStyle = rgba(COL.line, 1);
    ctx.lineWidth = 1;
    ctx.setLineDash([2, 5]);
    ctx.beginPath(); ctx.arc(ox, oy, (L1 + L2) * scale, 0, 6.2832); ctx.stroke();
    ctx.beginPath(); ctx.arc(ox, oy, L1 * scale, 0, 6.2832); ctx.stroke();
    ctx.setLineDash([]);

    if (traceEl.checked) {
      if (twin) drawTraceLine(traceTwin, COL.accent, 0.85);
      drawTraceLine(traceMain, COL.ink, 0.6);
    }

    if (twin) drawArm(twin, COL.accent, 0.95, false);
    drawArm(main, COL.ink, 1, true);

    /* pivot */
    ctx.fillStyle = rgba(COL.muted, 1);
    ctx.beginPath(); ctx.arc(ox, oy, 4.5, 0, 6.2832); ctx.fill();
    ctx.strokeStyle = rgba(COL.surface, 1); ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.arc(ox, oy, 4.5, 0, 6.2832); ctx.stroke();

    drawScaleBar();
    drawClock();
  }

  function drawScaleBar() {
    if (W < 260) return;
    var x0 = 14, y0 = H - 16, len = scale;
    if (len > W - 90) return;
    ctx.strokeStyle = rgba(COL.muted, 0.75);
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(x0, y0 - 4); ctx.lineTo(x0, y0 + 4);
    ctx.moveTo(x0, y0); ctx.lineTo(x0 + len, y0);
    ctx.moveTo(x0 + len, y0 - 4); ctx.lineTo(x0 + len, y0 + 4);
    ctx.stroke();
    ctx.fillStyle = rgba(COL.muted, 0.95);
    ctx.font = "10px " + MONO;
    ctx.textAlign = "left"; ctx.textBaseline = "bottom";
    ctx.fillText("1 m", x0 + 3, y0 - 5);
  }

  function drawClock() {
    ctx.fillStyle = rgba(COL.muted, 0.95);
    ctx.font = "11px " + MONO;
    ctx.textAlign = "right"; ctx.textBaseline = "top";
    ctx.fillText("t " + t.toFixed(2) + " s", W - 12, 12);
    if (twin) {
      ctx.fillStyle = rgba(COL.accent, 1);
      ctx.fillText("gap " + fmtGap(tipGap()), W - 12, 28);
    }
  }

  var MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';

  /* ── sparkline ──────────────────────────────────────────────────────────── */
  var LO = 1e-4, HI = 4;                     /* metres, log axis */
  function logPos(v) {
    var a = Math.log(Math.max(v, LO)) / Math.LN10, lo = Math.log(LO) / Math.LN10, hi = Math.log(HI) / Math.LN10;
    return Math.min(1, Math.max(0, (a - lo) / (hi - lo)));
  }
  function drawSpark() {
    var w = spark.clientWidth, h = spark.clientHeight;
    if (!w || !h) return;
    sctx.clearRect(0, 0, w, h);
    var padR = 34, padB = 2, padT = 3;
    var gw = Math.max(10, w - padR), gh = h - padB - padT;

    var win = 10;
    var elapsed = twin ? (t - forkT) : 0;
    if (elapsed > win) win = Math.min(240, Math.ceil(elapsed / 10) * 10);
    sparkWin.textContent = win + " s";
    var t0 = forkT + Math.max(0, elapsed - win), t1 = t0 + win;

    /* gridlines — 10cm is deliberately absent: that level is the split threshold
       below, and two lines on the same pixel row read as one meaningless line */
    var lines = [[1e-3, "1mm"], [1e-2, "1cm"], [1, "1m"]];
    sctx.font = "9px " + MONO;
    sctx.textAlign = "left"; sctx.textBaseline = "middle";
    for (var i = 0; i < lines.length; i++) {
      var y = padT + gh * (1 - logPos(lines[i][0]));
      sctx.strokeStyle = rgba(COL.line, 1);
      sctx.lineWidth = 1;
      sctx.beginPath(); sctx.moveTo(0, y + 0.5); sctx.lineTo(gw, y + 0.5); sctx.stroke();
      sctx.fillStyle = rgba(COL.muted, 0.95);
      sctx.fillText(lines[i][1], gw + 5, y);
    }

    /* the split threshold, drawn before the empty state bails out so an idle chart
       still shows the line the gap is climbing towards */
    var ty = padT + gh * (1 - logPos(SPLIT_AT));
    sctx.strokeStyle = rgba(COL.muted, 0.6);
    sctx.setLineDash([3, 3]); sctx.lineWidth = 1;
    sctx.beginPath(); sctx.moveTo(0, ty + 0.5); sctx.lineTo(gw, ty + 0.5); sctx.stroke();
    sctx.setLineDash([]);
    sctx.fillStyle = rgba(COL.muted, 0.95);
    sctx.textAlign = "left"; sctx.textBaseline = "middle";
    sctx.fillText("10cm", gw + 5, ty);
    sctx.textBaseline = "bottom";
    sctx.fillText("split", 2, ty - 2);
    sctx.textBaseline = "middle";

    if (!twin || samples.length < 2) {
      sctx.fillStyle = rgba(COL.muted, 0.9);
      sctx.textAlign = "center"; sctx.textBaseline = "middle";
      sctx.font = "10px " + MONO;
      sctx.fillText(twin ? "measuring…" : "no twin running", gw / 2, h / 2);
      return;
    }

    sctx.strokeStyle = rgba(COL.accent, 1);
    sctx.lineWidth = 1.6;
    sctx.lineJoin = "round"; sctx.lineCap = "round";
    sctx.beginPath();
    var started = false;
    for (var k = 0; k < samples.length; k++) {
      var s = samples[k];
      if (s.t < t0) continue;
      var X = gw * (s.t - t0) / (t1 - t0);
      var Y = padT + gh * (1 - logPos(s.g));
      if (!started) { sctx.moveTo(X, Y); started = true; } else sctx.lineTo(X, Y);
    }
    sctx.stroke();
  }

  /* ── readouts ───────────────────────────────────────────────────────────── */
  function fmtGap(v) {
    if (v < 0.01) return (v * 1000).toFixed(2) + " mm";
    if (v < 1) return (v * 100).toFixed(1) + " cm";
    return v.toFixed(2) + " m";
  }
  function fmtGapHTML(v) {
    if (v < 0.01) return (v * 1000).toFixed(2) + '<span class="u">mm</span>';
    if (v < 1) return (v * 100).toFixed(1) + '<span class="u">cm</span>';
    return v.toFixed(2) + '<span class="u">m</span>';
  }
  function deg(a) { return wrap(a) * 180 / Math.PI; }
  /* a signed percentage that does not claim a direction it cannot measure */
  function signed(v, dp) {
    var txt = Math.abs(v).toFixed(dp);
    if (parseFloat(txt) === 0) return "±" + txt;
    return (v > 0 ? "+" : "−") + txt;
  }

  function updateUI(force) {
    var E = energy(main);
    sEnergy.innerHTML = E.toFixed(2) + '<span class="u">J</span>';
    sT1.innerHTML = deg(main[0]).toFixed(1) + '<span class="u">°</span>';
    sT2.innerHTML = deg(main[2]).toFixed(1) + '<span class="u">°</span>';
    sSub.textContent = "t = " + t.toFixed(2) + " s · 240 Hz · RK4"
      + (fps > 0 ? " · " + Math.round(fps) + " fps" : "");

    var base = Math.max(Math.abs(E0), 1);
    var drift = (E - E0) / base * 100;
    if (damp > 0) {
      pill.className = "pill";
      pillText.textContent = "damped · energy " + signed(drift, 1) + "%";
    } else if (Math.abs(drift) < 0.1) {
      pill.className = "pill ok";
      pillText.textContent = "energy conserved · " + signed(drift, 3) + "%";
    } else {
      pill.className = "pill warn";
      pillText.textContent = "energy drift · " + signed(drift, 2) + "%";
    }

    if (twin) {
      var gp = tipGap();
      sGap.innerHTML = fmtGapHTML(gp);
      var grow = angSep() / EPS;
      sGrow.textContent = grow >= 1000 ? "×" + (grow / 1000).toFixed(1) + "k"
                        : grow >= 10 ? "×" + Math.round(grow)
                        : "×" + grow.toFixed(1);
      if (splitT >= 0) {
        sSplit.innerHTML = (splitT - forkT).toFixed(2) + '<span class="u">s</span>';
        sSplit.className = "v amb";
      } else {
        sSplit.textContent = "—";
        sSplit.className = "v";
        /* no split after 25 s needs explaining, or the em dash reads as broken — but
           only a run with friction off and real gravity is evidence about the START.
           Damping drags both twins to the same hanging rest and near-zero gravity
           never stirs them, so blaming the start there would simply be wrong. */
        if (!noteStuck && (t - forkT) > 25) {
          noteStuck = true;
          if (damp > 0) {
            twinNote.textContent = "25 seconds in and still within 10 cm — but damping is on, so both are winding down to the same hanging stop. Put damping back to zero to find out whether this start is really chaotic.";
            say("Still together after 25 seconds, but damping is doing that, not the start.");
          } else if (g < 0.5) {
            twinNote.textContent = "25 seconds in and still within 10 cm — but gravity is almost off, so nothing swings the arms hard enough to amplify the 0.001 rad. Turn gravity back up.";
            say("Still together after 25 seconds, but gravity is almost off.");
          } else {
            twinNote.textContent = "25 seconds in and the two are still within 10 cm. This start is regular, not chaotic — the 0.001 rad never gets amplified. Try the Chaotic start for the opposite.";
            say("Still together after 25 seconds. This start is regular, not chaotic.");
          }
        }
      }
    } else {
      sGap.textContent = "—"; sGrow.textContent = "—"; sSplit.textContent = "—";
      sSplit.className = "v";
    }
    drawSpark();
  }

  function render() { drawFrame(); }
  /* a full repaint: stage AND sparkline. Both draw with tokens read at theme time,
     so a theme flip while paused must redo the pair or the chart keeps stale colours. */
  function repaint() { readTheme(); drawFrame(); drawSpark(); }

  /* ── loop ───────────────────────────────────────────────────────────────── */
  function frame(ts) {
    rafId = requestAnimationFrame(frame);
    var dt = lastTs ? (ts - lastTs) / 1000 : 0;
    lastTs = ts;
    if (dt > 0) fps = fps ? fps * 0.9 + (1 / dt) * 0.1 : 1 / dt;

    if (running && !dragging) {
      acc += Math.min(0.1, dt);
      var guard = 0;
      while (acc >= STEP && guard < 60) { step(STEP); acc -= STEP; guard++; }
      if (acc > STEP) acc = 0;
    }
    drawFrame();
    uiAcc += dt;
    if (uiAcc >= 0.1) { uiAcc = 0; updateUI(); }
  }

  function start() {
    if (rafId) return;
    lastTs = 0; acc = 0;
    rafId = requestAnimationFrame(frame);
  }
  function stop() {
    if (!rafId) return;
    cancelAnimationFrame(rafId); rafId = 0; lastTs = 0;
  }
  var pauseMsg = "Paused. Drag either weight to set up a new start.";
  function setPlaying(on) {
    running = on;
    playBtn.textContent = on ? "Pause" : "Play";
    overlay.hidden = on;
    if (on) { start(); }
    else {
      overlayNote.textContent = pauseMsg;
      updateUI(true); drawFrame(); if (!dragging) stop();
    }
  }

  /* ── input: pointer ─────────────────────────────────────────────────────── */
  function worldPoint(e) {
    var r = canvas.getBoundingClientRect();
    var px = (e.clientX - r.left) * (W / r.width);
    var py = (e.clientY - r.top) * (H / r.height);
    return { x: (px - ox) / scale, y: (oy - py) / scale };
  }
  function pickArm(p) {
    var j = joint(main), q = tip(main);
    var thr = 26 / scale;
    var d1 = Math.hypot(p.x - j.x, p.y - j.y);
    var d2 = Math.hypot(p.x - q.x, p.y - q.y);
    if (d2 <= thr && d2 <= d1) return 2;
    if (d1 <= thr) return 1;
    return 0;
  }

  canvas.addEventListener("pointerdown", function (e) {
    if (e.button !== undefined && e.button !== 0) return;
    var p = worldPoint(e);
    var arm = pickArm(p);
    canvas.focus();
    if (!arm) return;
    e.preventDefault();
    dragging = arm; selArm = arm;
    wasRunning = running;
    beginEdit();
    if (canvas.setPointerCapture) { try { canvas.setPointerCapture(e.pointerId); } catch (err) {} }
    canvas.style.cursor = "grabbing";
    overlay.hidden = true;   /* setPlaying() restores it when the drag ends */
    dismissHint();
    start();
    dragTo(p);
  });

  canvas.addEventListener("pointermove", function (e) {
    var p = worldPoint(e);
    if (dragging) { e.preventDefault(); dragTo(p); return; }
    var arm = pickArm(p);
    if (arm !== hoverArm) { hoverArm = arm; if (!running) drawFrame(); }
    canvas.style.cursor = arm ? "grab" : "default";
  });

  function endDrag(e) {
    if (!dragging) return;
    if (canvas.releasePointerCapture && e && e.pointerId !== undefined) {
      try { canvas.releasePointerCapture(e.pointerId); } catch (err) {}
    }
    var arm = dragging;
    dragging = 0;
    canvas.style.cursor = "grab";
    if (twin) syncTwin();
    E0 = energy(main);
    say("Arm " + arm + " set to " + deg(arm === 1 ? main[0] : main[2]).toFixed(0) + " degrees, released from rest.");
    updateUI(true);
    if (wasRunning) setPlaying(true); else { setPlaying(false); }
  }
  canvas.addEventListener("pointerup", endDrag);
  canvas.addEventListener("pointercancel", endDrag);
  /* belt and braces: if pointer capture is unavailable the release can land elsewhere */
  window.addEventListener("pointerup", endDrag);
  window.addEventListener("pointercancel", endDrag);
  canvas.addEventListener("pointerleave", function () {
    if (dragging || !hoverArm) return;
    hoverArm = 0; canvas.style.cursor = "default";
    if (!running) drawFrame();
  });

  function dragTo(p) {
    if (dragging === 1) {
      main[0] = Math.atan2(p.x, -p.y);
    } else {
      var j = joint(main);
      main[2] = Math.atan2(p.x - j.x, -(p.y - j.y));
    }
    main[1] = 0; main[3] = 0;
    if (twin) { twin[0] = wrap(main[0] + EPS); twin[1] = 0; twin[2] = main[2]; twin[3] = 0; }
    E0 = energy(main);
    drawFrame();
  }

  /* ── input: keyboard ────────────────────────────────────────────────────── */
  function stageKey(e) {
    var k = e.key;
    if (k === " " || k === "Spacebar" || k === "Enter") {
      e.preventDefault(); setPlaying(!running);
      say(running ? "Playing." : "Paused."); return;
    }
    if (k === "1" || k === "2") {
      selArm = k === "1" ? 1 : 2;
      say("Arm " + selArm + " selected. Use the left and right arrow keys to rotate it.");
      drawFrame(); return;
    }
    if (k === "ArrowUp" || k === "ArrowDown") {
      e.preventDefault();
      selArm = selArm === 1 ? 2 : 1;
      say("Arm " + selArm + " selected.");
      drawFrame(); return;
    }
    if (k === "ArrowLeft" || k === "ArrowRight") {
      e.preventDefault();
      var d = (k === "ArrowRight" ? 1 : -1) * (e.shiftKey ? 0.5 : 3) * Math.PI / 180;
      beginEdit();
      if (selArm === 1) main[0] = wrap(main[0] + d); else main[2] = wrap(main[2] + d);
      if (twin) syncTwin();
      E0 = energy(main);
      dismissHint();
      say("Arm " + selArm + " at " + deg(selArm === 1 ? main[0] : main[2]).toFixed(0) + " degrees.");
      updateUI(true); drawFrame(); return;
    }
    if (k === "f" || k === "F") { e.preventDefault(); fork(); return; }
    if (k === "r" || k === "R") { e.preventDefault(); applyPreset(preset); updateUI(true); drawFrame(); say("Reset."); return; }
  }
  canvas.addEventListener("keydown", stageKey);
  /* the key strip advertises these page-wide, so honour them off-canvas too.
     ArrowUp/Down stay canvas-only: swallowing them everywhere would break scrolling. */
  document.addEventListener("keydown", function (e) {
    if (e.target === canvas || e.metaKey || e.ctrlKey || e.altKey) return;
    var tag = e.target && e.target.tagName;
    if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || tag === "BUTTON") return;
    var k = e.key;
    if (k === " " || k === "Spacebar") {
      e.preventDefault(); setPlaying(!running); say(running ? "Playing." : "Paused.");
    } else if (k === "f" || k === "F") {
      e.preventDefault(); fork(); if (!running) drawFrame();
    } else if (k === "r" || k === "R") {
      e.preventDefault(); applyPreset(preset); updateUI(true); drawFrame(); say("Reset.");
    } else if (k === "ArrowLeft" || k === "ArrowRight" || k === "1" || k === "2") {
      /* focus first: the selected-arm ring only draws while the stage has focus,
         so rotating from off-canvas would otherwise move an arm with no marker */
      canvas.focus();
      stageKey(e);
    }
  });
  canvas.addEventListener("focus", function () { drawFrame(); });
  canvas.addEventListener("blur", function () { hoverArm = 0; drawFrame(); });

  /* ── input: controls ────────────────────────────────────────────────────── */
  /* everyday landmarks, so the slider means something without a physics degree */
  function gravWords(v) {
    if (v < 0.05) return "Weightless. Nothing pulls the arms down, so they just coast.";
    if (v < 1.0)  return "Barely any pull. The arms drift around almost freely.";
    if (v < 2.6)  return "About the Moon (1.6). Slow, floaty, easy to follow.";
    if (v < 5.5)  return "About Mars (3.7). Noticeably lazier than home.";
    if (v < 8.6)  return "Somewhere between Mars and Earth. A gentler swing.";
    if (v < 11.0) return "Earth (9.81). Slide down for floaty, up for frantic.";
    if (v < 16.0) return "Heavier than Earth. Faster swings, sharper corners.";
    return "Far past Earth. Everything happens roughly twice as fast.";
  }
  function dampWords(v) {
    if (v === 0)   return "Off. Friction at the joints — leave it at zero and the energy stays put.";
    if (v < 0.06)  return "A trace of friction. It will take minutes to wind down.";
    if (v < 0.2)   return "Light friction. Watch the energy readout tick downward.";
    if (v < 0.4)   return "Heavy friction. It settles to a hanging stop before long.";
    return "Thick as syrup. It drops, wobbles once and stops.";
  }
  /* a range can only ever hand back a number, but one NaN here would poison every
     future RK4 step and freeze the stage for good — so clamp before it reaches the maths */
  function num(el, fb, lo, hi) {
    var v = parseFloat(el.value);
    if (!isFinite(v)) { v = fb; el.value = String(fb); }
    return Math.min(hi, Math.max(lo, v));
  }
  function showGrav() {
    gravV.textContent = g.toFixed(2) + " m/s²";
    gravDesc.textContent = gravWords(g);
    gravEl.setAttribute("aria-valuetext", g.toFixed(2) + " metres per second squared");
  }
  function showDamp() {
    dampV.textContent = (damp === 0 ? "0" : damp.toFixed(3)) + " /s";
    dampDesc.textContent = dampWords(damp);
    dampEl.setAttribute("aria-valuetext", damp === 0 ? "off" : damp.toFixed(3) + " per second");
  }
  gravEl.addEventListener("input", function () {
    g = num(gravEl, 9.81, 0, 25);
    showGrav();
    E0 = energy(main);
    noteStuck = false;   /* the "why aren't they apart yet" note depends on g */
    updateUI(true); if (!running) drawFrame();
  });
  dampEl.addEventListener("input", function () {
    damp = num(dampEl, 0, 0, 0.6);
    showDamp();
    if (damp === 0) E0 = energy(main);
    noteStuck = false;   /* ...and on damping */
    updateUI(true); if (!running) drawFrame();
  });
  traceEl.addEventListener("change", function () { drawFrame(); });
  forkBtn.addEventListener("click", function () { fork(); if (!running) drawFrame(); });
  unforkBtn.addEventListener("click", function () { unfork(); if (!running) drawFrame(); });
  playBtn.addEventListener("click", function () { setPlaying(!running); });
  overlayPlay.addEventListener("click", function () { setPlaying(true); canvas.focus(); });
  resetBtn.addEventListener("click", function () {
    applyPreset(preset); updateUI(true); drawFrame(); say("Reset to " + PRESETS[preset].name + ".");
  });
  presetsEl.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-p]") : null;
    if (!b) return;
    applyPreset(parseInt(b.getAttribute("data-p"), 10));
    updateUI(true); drawFrame(); dismissHint();
    say(PRESETS[preset].name + " start loaded.");
  });

  /* ── small helpers ──────────────────────────────────────────────────────── */
  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);
  }
  var sayTimer = 0;
  function say(msg) {
    clearTimeout(sayTimer);
    sayTimer = setTimeout(function () { live.textContent = msg; }, 140);
  }

  document.addEventListener("visibilitychange", function () {
    if (document.hidden) stop();
    else if (running) start();
  });
  if (window.ResizeObserver) {
    var ro = new ResizeObserver(function () { resize(); });
    ro.observe(field);
    new ResizeObserver(function () { resizeSpark(); drawSpark(); }).observe(spark);
  }
  window.addEventListener("resize", resize);

  /* ── boot ───────────────────────────────────────────────────────────────── */
  readTheme();
  /* read the sliders rather than trusting the literals: some browsers restore form
     state across a reload, and a slider that disagrees with the physics is a lie */
  g = num(gravEl, 9.81, 0, 25);
  damp = num(dampEl, 0, 0, 0.6);
  showGrav();
  showDamp();
  resize();
  applyPreset(0);
  fork(true);
  canvas.style.cursor = "default";

  if (reduced) {
    /* pre-roll the maths so the still frame is already the interesting part */
    for (var w = 0; w < 8 * 240; w++) step(STEP);
    setPlaying(false);
    overlayNote.textContent = "Reduced motion is on, so this starts paused on eight seconds of maths already worked through. Press play to run it live.";
    hint.hidden = true;
    updateUI(true);
    drawFrame();
  } else {
    setPlaying(true);
    updateUI(true);
    hintTimer = setTimeout(dismissHint, 8000);
  }
})();
</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