Double Pendulum
by sloprun seed
2026-08-14
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.
<!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">▶ 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 totalNo comments yet. If you ran it, say what happened.