/ listening-for-chaos / double-pendulum-chaos
Sign in
listening-for-chaos / double-pendulum-chaos
Double pendulum with audio, damping, ghost, and Lyapunov exponent
Interactive double pendulum (SI units, Gauss-Legendre integrator with calibrated step) with live traces and spectrogram, a sped-up audio copy streamed from a Web Worker to an AudioWorklet with its own spectrogram, linear bob drag (strike-and-ring-down), a ghost pendulum for watching nearby motions separate, and a two-trajectory Lyapunov exponent estimate.
Jeremy Magland <jeremy.magland@gmail.com> committed commit 954de9b7b4cf Browse files
17 changed files+3229−0
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules
2+dist
README.mdadded+24−0View file
@@ -0,0 +1,24 @@
1+# double-pendulum
2+
3+Interactive double pendulum, step 1 of "Listening for chaos". Work in progress.
4+
5+Current state: the pendulum hangs at rest until Strike, which starts it from hanging rest with the energy on the slider (default 50 J); Pause freezes the animation. The Ghost toggle adds a second pendulum (translucent orange) started from the same state with the outer arm's angle offset by 10^-12 to 10^-1 rad (default 10^-10), restarted on Strike or Reset, with a readout of the angular separation and the time since the ghost started: it grows exponentially when the motion is chaotic and stays tiny when it is regular. Charts "Separation" (ghost separation, log scale, last 30 s) and "Lyapunov" (largest Lyapunov exponent over a 10 s window and since the strike, last 30 s). Parameters (masses, lengths, g, decay time, presets) fold out underneath. Toggle chips choose the plots: a spectrogram of the selected signal (frequency axis auto-scaled, dashed lines at the normal modes) and live traces of energies, energy balance error, angles, momenta and bob positions. The sound card at the bottom has a Sound on/off toggle (with sound on, every Strike plays a note: a separate, sped-up copy of the motion from the same strike; a damped note ends once it has decayed by 60 dB), "Sound of" (the signal, also used by the animation spectrogram), volume, pitch (where the small-amplitude lower mode lands, 30 to 1000 Hz, default 100 Hz), and a spectrogram of the sound.
6+
7+```bash
8+npm install
9+npm run dev
10+```
11+
12+## Model
13+
14+- Point masses on massless rods, absolute angles from the downward vertical, canonical momenta.
15+- SI units. m1, m2, l1, l2, g are set in the page (Apply restarts at the same energy relative to the both-inverted energy). Default 1 kg, 1 kg, 1 m, 1 m, 9.81 m/s^2, tau 540 s (normal modes 0.382 and 0.921 Hz, ratio 1 + sqrt(2)). Presets keep m1 = m2 = 1 kg, l1 = 1 m and vary l2; with equal masses the mode ratio is at least 1 + sqrt(2) (at l2 = l1) and is the same for l2/l1 and l1/l2. Presets: l2 = 1 (2.414), 1.5667 (5:2), 2 (golden ratio squared, 2.618), 2.1637 (8:3), 3.2476 (3:1), 6.886 (4:1). At a fixed lower-mode pitch only m2/m1, l2/l1 and the relative energy affect the sound.
16+- Energy is zero at hanging rest.
17+- Damping: linear drag on each bob, force -gamma m_i v_i (proportional to its mass; equal for equal bobs), so the generalized forces are -gamma p and every mode's amplitude decays with time constant tau = 2/gamma. Default tau = 540 s: about 10% amplitude per minute in the animation, about 2 s in the sound at x262. The state carries the dissipated energy W, so E + W is checked for conservation. With damping the energy slider sets the strike energy; with tau = 0 it sets the energy live.
18+- Live energy change: both momenta are scaled by one factor at fixed angles (a push along the current motion). A target below the current potential energy can't be reached at once; until it can, kinetic energy is removed at a rate of 6 per second.
19+- Lyapunov exponent: two-trajectory (Benettin) method in `src/lyapunov.ts`. A hidden shadow pendulum is kept 1e-8 away (angles in rad, momenta in units of sqrt((m1 + m2) l1^2 E_top)), moved back after every step, and the log growth averaged. The shadow keeps its own energy (forcing it onto the pendulum's energy by rescaling momenta inflates the separation near turning points). Checked (no damping, 300 s): 2 J and 10 J fall toward 0 (0.01/s), 30 J ~1.0/s, 50 J ~1.4/s; unchanged for shadow distances 1e-6 and 1e-10. With damping (tau 540 s) a 50 J strike's 10 s value falls from ~1.4 to ~0.1/s by 300 s.
20+- Integrator: 2-stage Gauss-Legendre (order 4, symplectic), fixed step h chosen per parameter set by a short calibration run so the relative energy error stays below 1e-6 up to the top of the energy slider (twice the both-inverted energy); 3.13 ms for the defaults, fixed-point stage solve. Angles are wrapped to (-pi, pi] after each step. The physics costs well under 1 ms of CPU per second; drawing dominates, so traces draw at most a min/max pair per pixel column and the spectrogram scrolls in new columns instead of redrawing.
21+
22+Audio: a Web Worker (`src/audio-worker.ts`) integrates the audio copy with substeps of at most 3.2 ms of simulated time per sample, high-passes at 20 Hz, and streams 1024-sample chunks through a MessageChannel to an AudioWorklet (`src/audio-worklet.ts`) that plays them from a ring buffer, keeping about 0.25 s queued. The page shows how many times faster than real time the worker computes, and any dropouts. The worker also sends each chunk to the page, which feeds it to the audio spectrogram as the audio clock reaches it (2048-sample window, new column every 512 samples).
23+
24+Code: `src/pendulum.ts` (physics), `src/sim.ts` (running simulation with energy target), `src/audio.ts` (audio setup), `src/signals.ts` (audible quantities), `src/lyapunov.ts` (Lyapunov exponent), `src/traces.ts` (strip charts), `src/spectrogram.ts` (spectrogram), `src/main.ts` (page).
index.htmladded+192−0View file
@@ -0,0 +1,192 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>Double pendulum</title>
7+ <style>
8+ :root {
9+ --bg: #f6f6f3;
10+ --card: #ffffff;
11+ --line: #e3e2dc;
12+ --text: #23262b;
13+ --muted: #7a7d82;
14+ --accent: #2f5d8a;
15+ --accent-soft: #e6eef6;
16+ --serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
17+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
18+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
19+ }
20+ * { box-sizing: border-box; }
21+ body {
22+ margin: 0;
23+ background: var(--bg);
24+ color: var(--text);
25+ font: 14px/1.45 var(--sans);
26+ }
27+ main { max-width: 1040px; margin: 0 auto; padding: 28px 16px 48px; }
28+ header { margin-bottom: 20px; }
29+ h1 { font: 400 28px/1.2 var(--serif); margin: 0 0 4px; letter-spacing: 0.01em; }
30+ .sub { margin: 0; color: var(--muted); }
31+ .grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
32+ @media (max-width: 900px) { .grid { grid-template-columns: minmax(0, 1fr); } }
33+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
34+ .label {
35+ font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted);
36+ }
37+ .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
38+ .num { font: 13px var(--mono); font-variant-numeric: tabular-nums; color: var(--text); }
39+ .muted { color: var(--muted); font-size: 12px; }
40+
41+ canvas { display: block; }
42+ canvas[hidden], figure[hidden], [hidden] { display: none !important; }
43+ #canvas { width: 100%; aspect-ratio: 1; }
44+
45+ input[type="range"] { accent-color: var(--accent); flex: 1; min-width: 120px; margin: 0; }
46+ select, input[type="number"] {
47+ font: inherit; font-size: 13px; color: var(--text); background: #fff;
48+ border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px;
49+ }
50+ input[type="number"] { width: 5.5em; font-family: var(--mono); }
51+ button {
52+ font: inherit; font-size: 13px; color: var(--text); background: #fff; cursor: pointer;
53+ border: 1px solid var(--line); border-radius: 6px; padding: 5px 12px;
54+ }
55+ button:hover { border-color: #c9c8c0; }
56+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; min-width: 76px; }
57+ button.primary:hover { filter: brightness(1.08); }
58+ button:disabled { color: #b5b5b0; cursor: default; }
59+ button:disabled:hover { border-color: var(--line); }
60+ button.sound { min-width: 86px; }
61+ button.sound.on { background: var(--accent-soft); border-color: #b9cde0; color: var(--accent); }
62+
63+ #controls { margin-top: 14px; }
64+ #energy-value { min-width: 5.5em; text-align: right; }
65+ #ghost-row { margin-top: 10px; min-height: 28px; }
66+ #ghost-controls { flex: 1; }
67+ #ghost-offset { max-width: 140px; min-width: 80px; }
68+ #ghost-sep { width: 100%; }
69+
70+ details { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
71+ summary { cursor: pointer; color: var(--muted); font-size: 13px; list-style: none; }
72+ summary::before { content: "▸ "; }
73+ details[open] summary::before { content: "▾ "; }
74+ #param-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px 16px; margin-top: 10px; }
75+ #param-form label { display: grid; grid-template-columns: 1.6em 5.5em auto; align-items: center; gap: 6px; font-size: 13px; }
76+ #param-form .unit { color: var(--muted); font-size: 12px; }
77+ #param-actions { margin-top: 10px; }
78+ #param-status { color: #a33; font-size: 12px; }
79+ #params { margin-top: 10px; font: 12px/1.6 var(--mono); color: var(--muted); white-space: pre; overflow-x: auto; }
80+
81+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
82+ .chip { position: relative; cursor: pointer; user-select: none; }
83+ .chip input { position: absolute; opacity: 0; pointer-events: none; }
84+ .chip span {
85+ display: inline-block; font-size: 12px; padding: 3px 11px; border-radius: 999px;
86+ border: 1px solid var(--line); color: var(--muted); background: #fff;
87+ }
88+ .chip:hover span { border-color: #c9c8c0; }
89+ .chip input:checked + span { background: var(--accent-soft); border-color: #b9cde0; color: var(--accent); }
90+ .chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }
91+
92+ figure { margin: 14px 0 0; }
93+ figure canvas { width: 100%; height: auto; border-radius: 4px; }
94+ figcaption { margin-top: 4px; }
95+ #traces canvas { width: 100%; height: auto; margin-top: 12px; }
96+ #plots-empty { margin: 14px 0 2px; }
97+
98+ #audio { margin-top: 16px; }
99+ #audio .row + .row { margin-top: 12px; }
100+ #audio label { display: flex; align-items: center; gap: 8px; }
101+ #audio .grow { flex: 1; min-width: 220px; }
102+ #volume { max-width: 110px; }
103+ </style>
104+ </head>
105+ <body>
106+ <main>
107+ <header>
108+ <h1>Double pendulum</h1>
109+ <p class="sub">Raise the energy and listen for where the motion becomes chaotic.</p>
110+ </header>
111+
112+ <div class="grid">
113+ <section class="card">
114+ <canvas id="canvas"></canvas>
115+ <form id="controls" class="row">
116+ <span class="label">Energy</span>
117+ <input id="energy" type="range" value="50" min="0" max="120" step="0.1" aria-label="Energy" />
118+ <span id="energy-value" class="num">50.0 J</span>
119+ <button type="submit" class="primary" title="Start from hanging rest with this energy">Strike</button>
120+ <button type="button" id="pause" disabled>Pause</button>
121+ </form>
122+ <div id="ghost-row" class="row">
123+ <label class="chip" title="A second pendulum started with the outer arm offset by a tiny angle"><input id="ghost-toggle" type="checkbox" /><span>Ghost</span></label>
124+ <span id="ghost-controls" class="row" hidden>
125+ <span class="label">Offset</span>
126+ <input id="ghost-offset" type="range" min="-12" max="-1" step="1" value="-10" aria-label="Ghost offset (power of ten)" />
127+ <span id="ghost-offset-value" class="num"></span>
128+ <button id="ghost-reset" type="button" title="Restart the ghost from the pendulum's current state">Reset</button>
129+ </span>
130+ <span id="ghost-sep" class="num muted"></span>
131+ </div>
132+ <details>
133+ <summary>Parameters</summary>
134+ <form id="param-form">
135+ <label><span>m<sub>1</sub></span> <input id="p-m1" type="number" step="any" /> <span class="unit">kg</span></label>
136+ <label><span>m<sub>2</sub></span> <input id="p-m2" type="number" step="any" /> <span class="unit">kg</span></label>
137+ <label><span>l<sub>1</sub></span> <input id="p-l1" type="number" step="any" /> <span class="unit">m</span></label>
138+ <label><span>l<sub>2</sub></span> <input id="p-l2" type="number" step="any" /> <span class="unit">m</span></label>
139+ <label><span>g</span> <input id="p-g" type="number" step="any" /> <span class="unit">m/s²</span></label>
140+ <label title="Amplitude decay time from drag on the bobs (all modes). 0 = no damping."><span>τ</span> <input id="p-tau" type="number" step="any" min="0" /> <span class="unit">s decay</span></label>
141+ </form>
142+ <div id="param-actions" class="row">
143+ <button type="submit" form="param-form">Apply</button>
144+ <label class="muted">Preset <select id="preset">
145+ <option value="">custom</option>
146+ <option value="equal">l2 = 1 m</option>
147+ <option value="tenth">l2 = 1.5667 m</option>
148+ <option value="golden">l2 = 2 m</option>
149+ <option value="eightThirds">l2 = 2.1637 m</option>
150+ <option value="twelfth">l2 = 3.2476 m</option>
151+ <option value="doubleOctave">l2 = 6.886 m</option>
152+ </select></label>
153+ <span id="param-status"></span>
154+ </div>
155+ <div id="params"></div>
156+ </details>
157+ </section>
158+
159+ <section class="card">
160+ <div class="chips" id="toggles">
161+ <label class="chip"><input id="spec-toggle" type="checkbox" /><span>Spectrogram</span></label>
162+ </div>
163+ <p id="plots-empty" class="muted">Choose what to plot.</p>
164+ <figure id="spec-fig" hidden>
165+ <div id="spectrogram"></div>
166+ <figcaption id="spec-label" class="muted"></figcaption>
167+ </figure>
168+ <div id="traces"></div>
169+ </section>
170+ </div>
171+
172+ <section class="card" id="audio">
173+ <div class="row">
174+ <button id="sound" type="button" class="sound" title="With sound on, every Strike plays a note">Sound off</button>
175+ <label><span class="label">Sound of</span> <select id="signal"></select></label>
176+ <label><span class="label">Volume</span> <input id="volume" type="range" min="0" max="1" step="0.01" value="0.5" /></label>
177+ <label class="chip"><input id="audio-spec-toggle" type="checkbox" /><span>Spectrogram</span></label>
178+ </div>
179+ <div class="row">
180+ <label class="grow"><span class="label">Pitch</span> <input id="pitch" type="range" min="0" max="1000" step="1" aria-label="Pitch of the lower mode" /></label>
181+ <span id="pitch-value" class="num"></span>
182+ </div>
183+ <div class="row"><span id="audio-status" class="muted"></span></div>
184+ <figure id="audio-spec-fig" hidden>
185+ <div id="audio-spectrogram"></div>
186+ <figcaption id="audio-spec-label" class="muted"></figcaption>
187+ </figure>
188+ </section>
189+ </main>
190+ <script type="module" src="/src/main.ts"></script>
191+ </body>
192+</html>
package-lock.jsonadded+1202−0View file
@@ -0,0 +1,1202 @@
1+{
2+ "name": "double-pendulum",
3+ "lockfileVersion": 3,
4+ "requires": true,
5+ "packages": {
6+ "": {
7+ "name": "double-pendulum",
8+ "devDependencies": {
9+ "typescript": "^7.0.2",
10+ "vite": "^8.3.4"
11+ }
12+ },
13+ "node_modules/@oxc-project/types": {
14+ "version": "0.153.0",
15+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
16+ "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
17+ "dev": true,
18+ "license": "MIT",
19+ "funding": {
20+ "url": "https://github.com/sponsors/oxc-project"
21+ }
22+ },
23+ "node_modules/@rolldown/binding-android-arm-eabi": {
24+ "version": "1.2.13",
25+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
26+ "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
27+ "cpu": [
28+ "arm"
29+ ],
30+ "dev": true,
31+ "license": "MIT",
32+ "optional": true,
33+ "os": [
34+ "android"
35+ ],
36+ "engines": {
37+ "node": "^20.19.0 || >=22.12.0"
38+ }
39+ },
40+ "node_modules/@rolldown/binding-android-arm64": {
41+ "version": "1.2.13",
42+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
43+ "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
44+ "cpu": [
45+ "arm64"
46+ ],
47+ "dev": true,
48+ "license": "MIT",
49+ "optional": true,
50+ "os": [
51+ "android"
52+ ],
53+ "engines": {
54+ "node": "^20.19.0 || >=22.12.0"
55+ }
56+ },
57+ "node_modules/@rolldown/binding-darwin-arm64": {
58+ "version": "1.2.13",
59+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
60+ "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
61+ "cpu": [
62+ "arm64"
63+ ],
64+ "dev": true,
65+ "license": "MIT",
66+ "optional": true,
67+ "os": [
68+ "darwin"
69+ ],
70+ "engines": {
71+ "node": "^20.19.0 || >=22.12.0"
72+ }
73+ },
74+ "node_modules/@rolldown/binding-darwin-x64": {
75+ "version": "1.2.13",
76+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
77+ "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
78+ "cpu": [
79+ "x64"
80+ ],
81+ "dev": true,
82+ "license": "MIT",
83+ "optional": true,
84+ "os": [
85+ "darwin"
86+ ],
87+ "engines": {
88+ "node": "^20.19.0 || >=22.12.0"
89+ }
90+ },
91+ "node_modules/@rolldown/binding-freebsd-x64": {
92+ "version": "1.2.13",
93+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
94+ "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
95+ "cpu": [
96+ "x64"
97+ ],
98+ "dev": true,
99+ "license": "MIT",
100+ "optional": true,
101+ "os": [
102+ "freebsd"
103+ ],
104+ "engines": {
105+ "node": "^20.19.0 || >=22.12.0"
106+ }
107+ },
108+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
109+ "version": "1.2.13",
110+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
111+ "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
112+ "cpu": [
113+ "arm"
114+ ],
115+ "dev": true,
116+ "license": "MIT",
117+ "optional": true,
118+ "os": [
119+ "linux"
120+ ],
121+ "engines": {
122+ "node": "^20.19.0 || >=22.12.0"
123+ }
124+ },
125+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
126+ "version": "1.2.13",
127+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
128+ "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
129+ "cpu": [
130+ "arm64"
131+ ],
132+ "dev": true,
133+ "libc": [
134+ "glibc"
135+ ],
136+ "license": "MIT",
137+ "optional": true,
138+ "os": [
139+ "linux"
140+ ],
141+ "engines": {
142+ "node": "^20.19.0 || >=22.12.0"
143+ }
144+ },
145+ "node_modules/@rolldown/binding-linux-arm64-musl": {
146+ "version": "1.2.13",
147+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
148+ "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
149+ "cpu": [
150+ "arm64"
151+ ],
152+ "dev": true,
153+ "libc": [
154+ "musl"
155+ ],
156+ "license": "MIT",
157+ "optional": true,
158+ "os": [
159+ "linux"
160+ ],
161+ "engines": {
162+ "node": "^20.19.0 || >=22.12.0"
163+ }
164+ },
165+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
166+ "version": "1.2.13",
167+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
168+ "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
169+ "cpu": [
170+ "ppc64"
171+ ],
172+ "dev": true,
173+ "libc": [
174+ "glibc"
175+ ],
176+ "license": "MIT",
177+ "optional": true,
178+ "os": [
179+ "linux"
180+ ],
181+ "engines": {
182+ "node": "^20.19.0 || >=22.12.0"
183+ }
184+ },
185+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
186+ "version": "1.2.13",
187+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
188+ "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
189+ "cpu": [
190+ "s390x"
191+ ],
192+ "dev": true,
193+ "libc": [
194+ "glibc"
195+ ],
196+ "license": "MIT",
197+ "optional": true,
198+ "os": [
199+ "linux"
200+ ],
201+ "engines": {
202+ "node": "^20.19.0 || >=22.12.0"
203+ }
204+ },
205+ "node_modules/@rolldown/binding-linux-x64-gnu": {
206+ "version": "1.2.13",
207+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
208+ "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
209+ "cpu": [
210+ "x64"
211+ ],
212+ "dev": true,
213+ "libc": [
214+ "glibc"
215+ ],
216+ "license": "MIT",
217+ "optional": true,
218+ "os": [
219+ "linux"
220+ ],
221+ "engines": {
222+ "node": "^20.19.0 || >=22.12.0"
223+ }
224+ },
225+ "node_modules/@rolldown/binding-linux-x64-musl": {
226+ "version": "1.2.13",
227+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
228+ "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
229+ "cpu": [
230+ "x64"
231+ ],
232+ "dev": true,
233+ "libc": [
234+ "musl"
235+ ],
236+ "license": "MIT",
237+ "optional": true,
238+ "os": [
239+ "linux"
240+ ],
241+ "engines": {
242+ "node": "^20.19.0 || >=22.12.0"
243+ }
244+ },
245+ "node_modules/@rolldown/binding-openharmony-arm64": {
246+ "version": "1.2.13",
247+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
248+ "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
249+ "cpu": [
250+ "arm64"
251+ ],
252+ "dev": true,
253+ "license": "MIT",
254+ "optional": true,
255+ "os": [
256+ "openharmony"
257+ ],
258+ "engines": {
259+ "node": "^20.19.0 || >=22.12.0"
260+ }
261+ },
262+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
263+ "version": "1.2.13",
264+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
265+ "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
266+ "cpu": [
267+ "arm64"
268+ ],
269+ "dev": true,
270+ "license": "MIT",
271+ "optional": true,
272+ "os": [
273+ "win32"
274+ ],
275+ "engines": {
276+ "node": "^20.19.0 || >=22.12.0"
277+ }
278+ },
279+ "node_modules/@rolldown/binding-win32-x64-msvc": {
280+ "version": "1.2.13",
281+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
282+ "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
283+ "cpu": [
284+ "x64"
285+ ],
286+ "dev": true,
287+ "license": "MIT",
288+ "optional": true,
289+ "os": [
290+ "win32"
291+ ],
292+ "engines": {
293+ "node": "^20.19.0 || >=22.12.0"
294+ }
295+ },
296+ "node_modules/@rolldown/pluginutils": {
297+ "version": "1.0.1",
298+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
299+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
300+ "dev": true,
301+ "license": "MIT"
302+ },
303+ "node_modules/@typescript/typescript-aix-ppc64": {
304+ "version": "7.0.2",
305+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
306+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
307+ "cpu": [
308+ "ppc64"
309+ ],
310+ "dev": true,
311+ "license": "Apache-2.0",
312+ "optional": true,
313+ "os": [
314+ "aix"
315+ ],
316+ "engines": {
317+ "node": ">=16.20.0"
318+ }
319+ },
320+ "node_modules/@typescript/typescript-darwin-arm64": {
321+ "version": "7.0.2",
322+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
323+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
324+ "cpu": [
325+ "arm64"
326+ ],
327+ "dev": true,
328+ "license": "Apache-2.0",
329+ "optional": true,
330+ "os": [
331+ "darwin"
332+ ],
333+ "engines": {
334+ "node": ">=16.20.0"
335+ }
336+ },
337+ "node_modules/@typescript/typescript-darwin-x64": {
338+ "version": "7.0.2",
339+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
340+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
341+ "cpu": [
342+ "x64"
343+ ],
344+ "dev": true,
345+ "license": "Apache-2.0",
346+ "optional": true,
347+ "os": [
348+ "darwin"
349+ ],
350+ "engines": {
351+ "node": ">=16.20.0"
352+ }
353+ },
354+ "node_modules/@typescript/typescript-freebsd-arm64": {
355+ "version": "7.0.2",
356+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
357+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
358+ "cpu": [
359+ "arm64"
360+ ],
361+ "dev": true,
362+ "license": "Apache-2.0",
363+ "optional": true,
364+ "os": [
365+ "freebsd"
366+ ],
367+ "engines": {
368+ "node": ">=16.20.0"
369+ }
370+ },
371+ "node_modules/@typescript/typescript-freebsd-x64": {
372+ "version": "7.0.2",
373+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
374+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
375+ "cpu": [
376+ "x64"
377+ ],
378+ "dev": true,
379+ "license": "Apache-2.0",
380+ "optional": true,
381+ "os": [
382+ "freebsd"
383+ ],
384+ "engines": {
385+ "node": ">=16.20.0"
386+ }
387+ },
388+ "node_modules/@typescript/typescript-linux-arm": {
389+ "version": "7.0.2",
390+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
391+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
392+ "cpu": [
393+ "arm"
394+ ],
395+ "dev": true,
396+ "license": "Apache-2.0",
397+ "optional": true,
398+ "os": [
399+ "linux"
400+ ],
401+ "engines": {
402+ "node": ">=16.20.0"
403+ }
404+ },
405+ "node_modules/@typescript/typescript-linux-arm64": {
406+ "version": "7.0.2",
407+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
408+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
409+ "cpu": [
410+ "arm64"
411+ ],
412+ "dev": true,
413+ "license": "Apache-2.0",
414+ "optional": true,
415+ "os": [
416+ "linux"
417+ ],
418+ "engines": {
419+ "node": ">=16.20.0"
420+ }
421+ },
422+ "node_modules/@typescript/typescript-linux-loong64": {
423+ "version": "7.0.2",
424+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
425+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
426+ "cpu": [
427+ "loong64"
428+ ],
429+ "dev": true,
430+ "license": "Apache-2.0",
431+ "optional": true,
432+ "os": [
433+ "linux"
434+ ],
435+ "engines": {
436+ "node": ">=16.20.0"
437+ }
438+ },
439+ "node_modules/@typescript/typescript-linux-mips64el": {
440+ "version": "7.0.2",
441+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
442+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
443+ "cpu": [
444+ "mips64el"
445+ ],
446+ "dev": true,
447+ "license": "Apache-2.0",
448+ "optional": true,
449+ "os": [
450+ "linux"
451+ ],
452+ "engines": {
453+ "node": ">=16.20.0"
454+ }
455+ },
456+ "node_modules/@typescript/typescript-linux-ppc64": {
457+ "version": "7.0.2",
458+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
459+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
460+ "cpu": [
461+ "ppc64"
462+ ],
463+ "dev": true,
464+ "license": "Apache-2.0",
465+ "optional": true,
466+ "os": [
467+ "linux"
468+ ],
469+ "engines": {
470+ "node": ">=16.20.0"
471+ }
472+ },
473+ "node_modules/@typescript/typescript-linux-riscv64": {
474+ "version": "7.0.2",
475+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
476+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
477+ "cpu": [
478+ "riscv64"
479+ ],
480+ "dev": true,
481+ "license": "Apache-2.0",
482+ "optional": true,
483+ "os": [
484+ "linux"
485+ ],
486+ "engines": {
487+ "node": ">=16.20.0"
488+ }
489+ },
490+ "node_modules/@typescript/typescript-linux-s390x": {
491+ "version": "7.0.2",
492+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
493+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
494+ "cpu": [
495+ "s390x"
496+ ],
497+ "dev": true,
498+ "license": "Apache-2.0",
499+ "optional": true,
500+ "os": [
501+ "linux"
502+ ],
503+ "engines": {
504+ "node": ">=16.20.0"
505+ }
506+ },
507+ "node_modules/@typescript/typescript-linux-x64": {
508+ "version": "7.0.2",
509+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
510+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
511+ "cpu": [
512+ "x64"
513+ ],
514+ "dev": true,
515+ "license": "Apache-2.0",
516+ "optional": true,
517+ "os": [
518+ "linux"
519+ ],
520+ "engines": {
521+ "node": ">=16.20.0"
522+ }
523+ },
524+ "node_modules/@typescript/typescript-netbsd-arm64": {
525+ "version": "7.0.2",
526+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
527+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
528+ "cpu": [
529+ "arm64"
530+ ],
531+ "dev": true,
532+ "license": "Apache-2.0",
533+ "optional": true,
534+ "os": [
535+ "netbsd"
536+ ],
537+ "engines": {
538+ "node": ">=16.20.0"
539+ }
540+ },
541+ "node_modules/@typescript/typescript-netbsd-x64": {
542+ "version": "7.0.2",
543+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
544+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
545+ "cpu": [
546+ "x64"
547+ ],
548+ "dev": true,
549+ "license": "Apache-2.0",
550+ "optional": true,
551+ "os": [
552+ "netbsd"
553+ ],
554+ "engines": {
555+ "node": ">=16.20.0"
556+ }
557+ },
558+ "node_modules/@typescript/typescript-openbsd-arm64": {
559+ "version": "7.0.2",
560+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
561+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
562+ "cpu": [
563+ "arm64"
564+ ],
565+ "dev": true,
566+ "license": "Apache-2.0",
567+ "optional": true,
568+ "os": [
569+ "openbsd"
570+ ],
571+ "engines": {
572+ "node": ">=16.20.0"
573+ }
574+ },
575+ "node_modules/@typescript/typescript-openbsd-x64": {
576+ "version": "7.0.2",
577+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
578+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
579+ "cpu": [
580+ "x64"
581+ ],
582+ "dev": true,
583+ "license": "Apache-2.0",
584+ "optional": true,
585+ "os": [
586+ "openbsd"
587+ ],
588+ "engines": {
589+ "node": ">=16.20.0"
590+ }
591+ },
592+ "node_modules/@typescript/typescript-sunos-x64": {
593+ "version": "7.0.2",
594+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
595+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
596+ "cpu": [
597+ "x64"
598+ ],
599+ "dev": true,
600+ "license": "Apache-2.0",
601+ "optional": true,
602+ "os": [
603+ "sunos"
604+ ],
605+ "engines": {
606+ "node": ">=16.20.0"
607+ }
608+ },
609+ "node_modules/@typescript/typescript-win32-arm64": {
610+ "version": "7.0.2",
611+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
612+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
613+ "cpu": [
614+ "arm64"
615+ ],
616+ "dev": true,
617+ "license": "Apache-2.0",
618+ "optional": true,
619+ "os": [
620+ "win32"
621+ ],
622+ "engines": {
623+ "node": ">=16.20.0"
624+ }
625+ },
626+ "node_modules/@typescript/typescript-win32-x64": {
627+ "version": "7.0.2",
628+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
629+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
630+ "cpu": [
631+ "x64"
632+ ],
633+ "dev": true,
634+ "license": "Apache-2.0",
635+ "optional": true,
636+ "os": [
637+ "win32"
638+ ],
639+ "engines": {
640+ "node": ">=16.20.0"
641+ }
642+ },
643+ "node_modules/detect-libc": {
644+ "version": "2.1.2",
645+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
646+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
647+ "dev": true,
648+ "license": "Apache-2.0",
649+ "engines": {
650+ "node": ">=8"
651+ }
652+ },
653+ "node_modules/fdir": {
654+ "version": "6.5.0",
655+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
656+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
657+ "dev": true,
658+ "license": "MIT",
659+ "engines": {
660+ "node": ">=12.0.0"
661+ },
662+ "peerDependencies": {
663+ "picomatch": "^3 || ^4"
664+ },
665+ "peerDependenciesMeta": {
666+ "picomatch": {
667+ "optional": true
668+ }
669+ }
670+ },
671+ "node_modules/fsevents": {
672+ "version": "2.3.3",
673+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
674+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
675+ "dev": true,
676+ "hasInstallScript": true,
677+ "license": "MIT",
678+ "optional": true,
679+ "os": [
680+ "darwin"
681+ ],
682+ "engines": {
683+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
684+ }
685+ },
686+ "node_modules/lightningcss": {
687+ "version": "1.33.0",
688+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
689+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
690+ "dev": true,
691+ "license": "MPL-2.0",
692+ "dependencies": {
693+ "detect-libc": "^2.0.3"
694+ },
695+ "engines": {
696+ "node": ">= 12.0.0"
697+ },
698+ "funding": {
699+ "type": "opencollective",
700+ "url": "https://opencollective.com/parcel"
701+ },
702+ "optionalDependencies": {
703+ "lightningcss-android-arm64": "1.33.0",
704+ "lightningcss-darwin-arm64": "1.33.0",
705+ "lightningcss-darwin-x64": "1.33.0",
706+ "lightningcss-freebsd-x64": "1.33.0",
707+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
708+ "lightningcss-linux-arm64-gnu": "1.33.0",
709+ "lightningcss-linux-arm64-musl": "1.33.0",
710+ "lightningcss-linux-x64-gnu": "1.33.0",
711+ "lightningcss-linux-x64-musl": "1.33.0",
712+ "lightningcss-win32-arm64-msvc": "1.33.0",
713+ "lightningcss-win32-x64-msvc": "1.33.0"
714+ }
715+ },
716+ "node_modules/lightningcss-android-arm64": {
717+ "version": "1.33.0",
718+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
719+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
720+ "cpu": [
721+ "arm64"
722+ ],
723+ "dev": true,
724+ "license": "MPL-2.0",
725+ "optional": true,
726+ "os": [
727+ "android"
728+ ],
729+ "engines": {
730+ "node": ">= 12.0.0"
731+ },
732+ "funding": {
733+ "type": "opencollective",
734+ "url": "https://opencollective.com/parcel"
735+ }
736+ },
737+ "node_modules/lightningcss-darwin-arm64": {
738+ "version": "1.33.0",
739+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
740+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
741+ "cpu": [
742+ "arm64"
743+ ],
744+ "dev": true,
745+ "license": "MPL-2.0",
746+ "optional": true,
747+ "os": [
748+ "darwin"
749+ ],
750+ "engines": {
751+ "node": ">= 12.0.0"
752+ },
753+ "funding": {
754+ "type": "opencollective",
755+ "url": "https://opencollective.com/parcel"
756+ }
757+ },
758+ "node_modules/lightningcss-darwin-x64": {
759+ "version": "1.33.0",
760+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
761+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
762+ "cpu": [
763+ "x64"
764+ ],
765+ "dev": true,
766+ "license": "MPL-2.0",
767+ "optional": true,
768+ "os": [
769+ "darwin"
770+ ],
771+ "engines": {
772+ "node": ">= 12.0.0"
773+ },
774+ "funding": {
775+ "type": "opencollective",
776+ "url": "https://opencollective.com/parcel"
777+ }
778+ },
779+ "node_modules/lightningcss-freebsd-x64": {
780+ "version": "1.33.0",
781+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
782+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
783+ "cpu": [
784+ "x64"
785+ ],
786+ "dev": true,
787+ "license": "MPL-2.0",
788+ "optional": true,
789+ "os": [
790+ "freebsd"
791+ ],
792+ "engines": {
793+ "node": ">= 12.0.0"
794+ },
795+ "funding": {
796+ "type": "opencollective",
797+ "url": "https://opencollective.com/parcel"
798+ }
799+ },
800+ "node_modules/lightningcss-linux-arm-gnueabihf": {
801+ "version": "1.33.0",
802+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
803+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
804+ "cpu": [
805+ "arm"
806+ ],
807+ "dev": true,
808+ "license": "MPL-2.0",
809+ "optional": true,
810+ "os": [
811+ "linux"
812+ ],
813+ "engines": {
814+ "node": ">= 12.0.0"
815+ },
816+ "funding": {
817+ "type": "opencollective",
818+ "url": "https://opencollective.com/parcel"
819+ }
820+ },
821+ "node_modules/lightningcss-linux-arm64-gnu": {
822+ "version": "1.33.0",
823+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
824+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
825+ "cpu": [
826+ "arm64"
827+ ],
828+ "dev": true,
829+ "libc": [
830+ "glibc"
831+ ],
832+ "license": "MPL-2.0",
833+ "optional": true,
834+ "os": [
835+ "linux"
836+ ],
837+ "engines": {
838+ "node": ">= 12.0.0"
839+ },
840+ "funding": {
841+ "type": "opencollective",
842+ "url": "https://opencollective.com/parcel"
843+ }
844+ },
845+ "node_modules/lightningcss-linux-arm64-musl": {
846+ "version": "1.33.0",
847+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
848+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
849+ "cpu": [
850+ "arm64"
851+ ],
852+ "dev": true,
853+ "libc": [
854+ "musl"
855+ ],
856+ "license": "MPL-2.0",
857+ "optional": true,
858+ "os": [
859+ "linux"
860+ ],
861+ "engines": {
862+ "node": ">= 12.0.0"
863+ },
864+ "funding": {
865+ "type": "opencollective",
866+ "url": "https://opencollective.com/parcel"
867+ }
868+ },
869+ "node_modules/lightningcss-linux-x64-gnu": {
870+ "version": "1.33.0",
871+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
872+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
873+ "cpu": [
874+ "x64"
875+ ],
876+ "dev": true,
877+ "libc": [
878+ "glibc"
879+ ],
880+ "license": "MPL-2.0",
881+ "optional": true,
882+ "os": [
883+ "linux"
884+ ],
885+ "engines": {
886+ "node": ">= 12.0.0"
887+ },
888+ "funding": {
889+ "type": "opencollective",
890+ "url": "https://opencollective.com/parcel"
891+ }
892+ },
893+ "node_modules/lightningcss-linux-x64-musl": {
894+ "version": "1.33.0",
895+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
896+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
897+ "cpu": [
898+ "x64"
899+ ],
900+ "dev": true,
901+ "libc": [
902+ "musl"
903+ ],
904+ "license": "MPL-2.0",
905+ "optional": true,
906+ "os": [
907+ "linux"
908+ ],
909+ "engines": {
910+ "node": ">= 12.0.0"
911+ },
912+ "funding": {
913+ "type": "opencollective",
914+ "url": "https://opencollective.com/parcel"
915+ }
916+ },
917+ "node_modules/lightningcss-win32-arm64-msvc": {
918+ "version": "1.33.0",
919+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
920+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
921+ "cpu": [
922+ "arm64"
923+ ],
924+ "dev": true,
925+ "license": "MPL-2.0",
926+ "optional": true,
927+ "os": [
928+ "win32"
929+ ],
930+ "engines": {
931+ "node": ">= 12.0.0"
932+ },
933+ "funding": {
934+ "type": "opencollective",
935+ "url": "https://opencollective.com/parcel"
936+ }
937+ },
938+ "node_modules/lightningcss-win32-x64-msvc": {
939+ "version": "1.33.0",
940+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
941+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
942+ "cpu": [
943+ "x64"
944+ ],
945+ "dev": true,
946+ "license": "MPL-2.0",
947+ "optional": true,
948+ "os": [
949+ "win32"
950+ ],
951+ "engines": {
952+ "node": ">= 12.0.0"
953+ },
954+ "funding": {
955+ "type": "opencollective",
956+ "url": "https://opencollective.com/parcel"
957+ }
958+ },
959+ "node_modules/nanoid": {
960+ "version": "3.3.20",
961+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
962+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
963+ "dev": true,
964+ "funding": [
965+ {
966+ "type": "github",
967+ "url": "https://github.com/sponsors/ai"
968+ }
969+ ],
970+ "license": "MIT",
971+ "bin": {
972+ "nanoid": "bin/nanoid.cjs"
973+ },
974+ "engines": {
975+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
976+ }
977+ },
978+ "node_modules/picocolors": {
979+ "version": "1.1.1",
980+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
981+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
982+ "dev": true,
983+ "license": "ISC"
984+ },
985+ "node_modules/picomatch": {
986+ "version": "4.0.7",
987+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
988+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
989+ "dev": true,
990+ "license": "MIT",
991+ "engines": {
992+ "node": ">=12"
993+ },
994+ "funding": {
995+ "url": "https://github.com/sponsors/jonschlinkert"
996+ }
997+ },
998+ "node_modules/postcss": {
999+ "version": "8.5.29",
1000+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
1001+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
1002+ "dev": true,
1003+ "funding": [
1004+ {
1005+ "type": "opencollective",
1006+ "url": "https://opencollective.com/postcss/"
1007+ },
1008+ {
1009+ "type": "tidelift",
1010+ "url": "https://tidelift.com/funding/github/npm/postcss"
1011+ },
1012+ {
1013+ "type": "github",
1014+ "url": "https://github.com/sponsors/ai"
1015+ }
1016+ ],
1017+ "license": "MIT",
1018+ "dependencies": {
1019+ "nanoid": "^3.3.19",
1020+ "picocolors": "^1.1.1",
1021+ "source-map-js": "^1.2.2"
1022+ },
1023+ "engines": {
1024+ "node": "^10 || ^12 || >=14"
1025+ }
1026+ },
1027+ "node_modules/rolldown": {
1028+ "version": "1.2.13",
1029+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
1030+ "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
1031+ "dev": true,
1032+ "license": "MIT",
1033+ "dependencies": {
1034+ "@oxc-project/types": "=0.153.0",
1035+ "@rolldown/pluginutils": "^1.0.0"
1036+ },
1037+ "bin": {
1038+ "rolldown": "bin/cli.mjs"
1039+ },
1040+ "engines": {
1041+ "node": "^20.19.0 || >=22.12.0"
1042+ },
1043+ "optionalDependencies": {
1044+ "@rolldown/binding-android-arm-eabi": "1.2.13",
1045+ "@rolldown/binding-android-arm64": "1.2.13",
1046+ "@rolldown/binding-darwin-arm64": "1.2.13",
1047+ "@rolldown/binding-darwin-x64": "1.2.13",
1048+ "@rolldown/binding-freebsd-x64": "1.2.13",
1049+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
1050+ "@rolldown/binding-linux-arm64-gnu": "1.2.13",
1051+ "@rolldown/binding-linux-arm64-musl": "1.2.13",
1052+ "@rolldown/binding-linux-ppc64-gnu": "1.2.13",
1053+ "@rolldown/binding-linux-s390x-gnu": "1.2.13",
1054+ "@rolldown/binding-linux-x64-gnu": "1.2.13",
1055+ "@rolldown/binding-linux-x64-musl": "1.2.13",
1056+ "@rolldown/binding-openharmony-arm64": "1.2.13",
1057+ "@rolldown/binding-win32-arm64-msvc": "1.2.13",
1058+ "@rolldown/binding-win32-x64-msvc": "1.2.13"
1059+ }
1060+ },
1061+ "node_modules/source-map-js": {
1062+ "version": "1.2.2",
1063+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
1064+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
1065+ "dev": true,
1066+ "license": "BSD-3-Clause",
1067+ "engines": {
1068+ "node": ">=0.10.0"
1069+ }
1070+ },
1071+ "node_modules/tinyglobby": {
1072+ "version": "0.2.17",
1073+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1074+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1075+ "dev": true,
1076+ "license": "MIT",
1077+ "dependencies": {
1078+ "fdir": "^6.5.0",
1079+ "picomatch": "^4.0.4"
1080+ },
1081+ "engines": {
1082+ "node": ">=12.0.0"
1083+ },
1084+ "funding": {
1085+ "url": "https://github.com/sponsors/SuperchupuDev"
1086+ }
1087+ },
1088+ "node_modules/typescript": {
1089+ "version": "7.0.2",
1090+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
1091+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
1092+ "dev": true,
1093+ "license": "Apache-2.0",
1094+ "bin": {
1095+ "tsc": "bin/tsc"
1096+ },
1097+ "engines": {
1098+ "node": ">=16.20.0"
1099+ },
1100+ "optionalDependencies": {
1101+ "@typescript/typescript-aix-ppc64": "7.0.2",
1102+ "@typescript/typescript-darwin-arm64": "7.0.2",
1103+ "@typescript/typescript-darwin-x64": "7.0.2",
1104+ "@typescript/typescript-freebsd-arm64": "7.0.2",
1105+ "@typescript/typescript-freebsd-x64": "7.0.2",
1106+ "@typescript/typescript-linux-arm": "7.0.2",
1107+ "@typescript/typescript-linux-arm64": "7.0.2",
1108+ "@typescript/typescript-linux-loong64": "7.0.2",
1109+ "@typescript/typescript-linux-mips64el": "7.0.2",
1110+ "@typescript/typescript-linux-ppc64": "7.0.2",
1111+ "@typescript/typescript-linux-riscv64": "7.0.2",
1112+ "@typescript/typescript-linux-s390x": "7.0.2",
1113+ "@typescript/typescript-linux-x64": "7.0.2",
1114+ "@typescript/typescript-netbsd-arm64": "7.0.2",
1115+ "@typescript/typescript-netbsd-x64": "7.0.2",
1116+ "@typescript/typescript-openbsd-arm64": "7.0.2",
1117+ "@typescript/typescript-openbsd-x64": "7.0.2",
1118+ "@typescript/typescript-sunos-x64": "7.0.2",
1119+ "@typescript/typescript-win32-arm64": "7.0.2",
1120+ "@typescript/typescript-win32-x64": "7.0.2"
1121+ }
1122+ },
1123+ "node_modules/vite": {
1124+ "version": "8.3.4",
1125+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
1126+ "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
1127+ "dev": true,
1128+ "license": "MIT",
1129+ "dependencies": {
1130+ "lightningcss": "^1.33.0",
1131+ "picomatch": "^4.0.7",
1132+ "postcss": "^8.5.29",
1133+ "rolldown": "~1.2.12",
1134+ "tinyglobby": "^0.2.17"
1135+ },
1136+ "bin": {
1137+ "vite": "bin/vite.js"
1138+ },
1139+ "engines": {
1140+ "node": "^20.19.0 || >=22.12.0"
1141+ },
1142+ "funding": {
1143+ "url": "https://github.com/vitejs/vite?sponsor=1"
1144+ },
1145+ "optionalDependencies": {
1146+ "fsevents": "~2.3.3"
1147+ },
1148+ "peerDependencies": {
1149+ "@types/node": "^20.19.0 || >=22.12.0",
1150+ "@vitejs/devtools": "^0.7.1",
1151+ "esbuild": "^0.27.0 || ^0.28.0",
1152+ "jiti": ">=1.21.0",
1153+ "less": "^4.0.0",
1154+ "sass": "^1.70.0",
1155+ "sass-embedded": "^1.70.0",
1156+ "stylus": ">=0.54.8",
1157+ "sugarss": "^5.0.0",
1158+ "terser": "^5.16.0",
1159+ "tsx": "^4.8.1",
1160+ "yaml": "^2.4.2"
1161+ },
1162+ "peerDependenciesMeta": {
1163+ "@types/node": {
1164+ "optional": true
1165+ },
1166+ "@vitejs/devtools": {
1167+ "optional": true
1168+ },
1169+ "esbuild": {
1170+ "optional": true
1171+ },
1172+ "jiti": {
1173+ "optional": true
1174+ },
1175+ "less": {
1176+ "optional": true
1177+ },
1178+ "sass": {
1179+ "optional": true
1180+ },
1181+ "sass-embedded": {
1182+ "optional": true
1183+ },
1184+ "stylus": {
1185+ "optional": true
1186+ },
1187+ "sugarss": {
1188+ "optional": true
1189+ },
1190+ "terser": {
1191+ "optional": true
1192+ },
1193+ "tsx": {
1194+ "optional": true
1195+ },
1196+ "yaml": {
1197+ "optional": true
1198+ }
1199+ }
1200+ }
1201+ }
1202+}
package.jsonadded+14−0View file
@@ -0,0 +1,14 @@
1+{
2+ "name": "double-pendulum",
3+ "private": true,
4+ "type": "module",
5+ "scripts": {
6+ "dev": "vite",
7+ "build": "tsc && vite build",
8+ "preview": "vite preview"
9+ },
10+ "devDependencies": {
11+ "typescript": "^7.0.2",
12+ "vite": "^8.3.4"
13+ }
14+}
src/audio-worker.tsadded+150−0View file
@@ -0,0 +1,150 @@
1+// Audio simulation, run in a Web Worker. Integrates its own copy of the
2+// pendulum sped up by a factor S (set from the requested pitch of the lower
3+// normal mode) and streams the selected signal (see signals.ts), as audio
4+// samples, to the AudioWorklet through a MessagePort, keeping about BUFFER
5+// seconds queued ahead of playback. Also sends a copy of each chunk to the
6+// page (for the audio spectrogram) and reports how fast it computes relative
7+// to real time. With damping, a note ends (and computing stops) once its
8+// energy has fallen by END_RATIO (-60 dB in amplitude).
9+
10+import { energy, normalModes, stepSize, topEnergy, type Params } from "./pendulum";
11+import { signalByKey, type Signal } from "./signals";
12+import { Sim } from "./sim";
13+
14+const CHUNK = 1024; // samples per message
15+const BUFFER = 0.25; // seconds of audio kept queued
16+const HIGHPASS_HZ = 20; // DC blocker cutoff
17+const REPORT_MS = 500;
18+const END_RATIO = 1e-6; // energy ratio at which a damped note ends
19+
20+export type ToWorker =
21+ | { type: "init"; P: Params; alpha: number; sampleRate: number; port: MessagePort }
22+ | { type: "params"; P: Params; alpha: number }
23+ | { type: "start"; y: Float64Array; E0: number; target: number }
24+ | { type: "stop" }
25+ | { type: "target"; E: number }
26+ | { type: "pitch"; hz: number }
27+ | { type: "signal"; key: string };
28+
29+export type FromWorker =
30+ | { type: "stats"; speed: number; underruns: number }
31+ | { type: "ended"; session: number }
32+ | { type: "samples"; session: number; data: Float32Array };
33+
34+let sim: Sim;
35+let port: MessagePort;
36+let fs = 48000;
37+let fLower = 1; // small-amplitude lower mode (Hz, simulated time)
38+let hMax = 0.0032; // largest integrator step (s of simulated time), as for the animation
39+let pitchHz = 100; // requested audio pitch of the lower mode
40+let signal: Signal = signalByKey("x2");
41+let gain = 1;
42+let session = 0;
43+let running = false;
44+let produced = 0, consumed = 0, underruns = 0;
45+let eStart = 0; // energy at the start of the note
46+let computeMs = 0, computedSamples = 0, lastReport = 0;
47+// One-pole DC blocker.
48+let hpR = 0.997, hpX = 0, hpY = 0;
49+
50+const value = () => signal.value(sim.P, sim.y);
51+const setSignal = (s: Signal) => {
52+ signal = s;
53+ gain = 0.5 / s.scale(sim.P);
54+ hpX = value(); // restart the DC blocker on the new signal
55+ hpY = 0;
56+};
57+
58+function setParams(P: Params, alpha: number): void {
59+ sim = new Sim(P, 0, alpha);
60+ fLower = normalModes(P).omega[0] / (2 * Math.PI);
61+ hMax = stepSize(P, 2 * topEnergy(P));
62+ setSignal(signal);
63+}
64+
65+onmessage = (e: MessageEvent<ToWorker>) => {
66+ const msg = e.data;
67+ switch (msg.type) {
68+ case "init":
69+ setParams(msg.P, msg.alpha);
70+ fs = msg.sampleRate;
71+ hpR = 1 - (2 * Math.PI * HIGHPASS_HZ) / fs;
72+ port = msg.port;
73+ port.onmessage = (m) => {
74+ if (m.data.session !== session) return;
75+ consumed = m.data.consumed;
76+ underruns = m.data.underruns;
77+ };
78+ break;
79+ case "params":
80+ setParams(msg.P, msg.alpha);
81+ break;
82+ case "start":
83+ session++;
84+ sim.setState(msg.y, msg.E0, msg.target);
85+ eStart = energy(sim.P, sim.y);
86+ produced = consumed = underruns = 0;
87+ computeMs = computedSamples = 0;
88+ lastReport = performance.now();
89+ setSignal(signal);
90+ if (!running) {
91+ running = true;
92+ pump();
93+ }
94+ break;
95+ case "stop":
96+ running = false;
97+ port.postMessage({ session, data: null });
98+ break;
99+ case "target":
100+ sim.target = msg.E;
101+ break;
102+ case "pitch":
103+ pitchHz = msg.hz;
104+ break;
105+ case "signal":
106+ if (sim) setSignal(signalByKey(msg.key));
107+ else signal = signalByKey(msg.key);
108+ break;
109+ }
110+};
111+
112+// Keep the queue topped up, then check again shortly.
113+function pump(): void {
114+ if (!running) return;
115+ const t0 = performance.now();
116+ while (produced - consumed < BUFFER * fs) {
117+ const chunk = new Float32Array(CHUNK);
118+ // Simulated time per sample; the speedup follows the current parameters.
119+ const dt = pitchHz / fLower / fs;
120+ const n = Math.ceil(dt / hMax);
121+ const h = dt / n;
122+ for (let i = 0; i < CHUNK; i++) {
123+ for (let k = 0; k < n; k++) sim.step(h);
124+ const x = value();
125+ hpY = x - hpX + hpR * hpY;
126+ hpX = x;
127+ chunk[i] = gain * hpY;
128+ }
129+ const copy = chunk.slice();
130+ postMessage({ type: "samples", session, data: copy } satisfies FromWorker, { transfer: [copy.buffer] });
131+ port.postMessage({ session, data: chunk }, [chunk.buffer]);
132+ produced += CHUNK;
133+ computedSamples += CHUNK;
134+ if (sim.P.tau > 0 && energy(sim.P, sim.y) < END_RATIO * eStart) {
135+ running = false;
136+ port.postMessage({ session, data: null });
137+ postMessage({ type: "ended", session } satisfies FromWorker);
138+ return;
139+ }
140+ }
141+ const now = performance.now();
142+ computeMs += now - t0;
143+ if (now - lastReport > REPORT_MS && computeMs > 0) {
144+ const speed = computedSamples / fs / (computeMs / 1000);
145+ postMessage({ type: "stats", speed, underruns } satisfies FromWorker);
146+ computeMs = computedSamples = 0;
147+ lastReport = now;
148+ }
149+ setTimeout(pump, 10);
150+}
src/audio-worklet.tsadded+79−0View file
@@ -0,0 +1,79 @@
1+// AudioWorklet that plays sample chunks streamed from the audio worker
2+// through a MessagePort, out of a ring buffer. Each note is a new session;
3+// chunks from older sessions are dropped. If the buffer runs dry during a
4+// session it plays silence and counts an underrun. When a note is cut off
5+// (stopped, or replaced by a new one) the last output value fades out over
6+// ~2 ms instead of jumping to the new signal, to avoid a click.
7+
8+// Globals of the AudioWorklet scope (not in the DOM type library).
9+declare class AudioWorkletProcessor {
10+ readonly port: MessagePort;
11+}
12+declare function registerProcessor(name: string, ctor: typeof AudioWorkletProcessor): void;
13+declare const sampleRate: number;
14+
15+const SIZE = 1 << 16; // ring buffer length (power of two)
16+const REPORT_BLOCKS = 16; // report consumption every this many 128-sample blocks
17+
18+class StreamPlayer extends AudioWorkletProcessor {
19+ private buf = new Float32Array(SIZE);
20+ private read = 0; // samples consumed this session
21+ private write = 0; // samples received this session
22+ private session = -1;
23+ private active = false;
24+ private underruns = 0;
25+ private blocks = 0;
26+ private worker: MessagePort | null = null;
27+ private last = 0; // last output sample
28+ private tail = 0; // decaying remainder of a cut-off note
29+ private tailDecay = Math.exp(-1 / (0.002 * sampleRate));
30+
31+ constructor() {
32+ super();
33+ // The main thread hands over the worker's port once.
34+ this.port.onmessage = (e) => {
35+ this.worker = e.data.port as MessagePort;
36+ this.worker.onmessage = (m) => this.receive(m.data);
37+ };
38+ }
39+
40+ private receive(msg: { session: number; data: Float32Array | null }): void {
41+ if (msg.session < this.session) return;
42+ if (msg.session > this.session) {
43+ this.session = msg.session;
44+ this.read = this.write = this.underruns = 0;
45+ this.tail = this.last;
46+ }
47+ if (msg.data === null) {
48+ // Session stopped: drop what is queued.
49+ this.active = false;
50+ this.read = this.write;
51+ this.tail = this.last;
52+ return;
53+ }
54+ this.active = true;
55+ for (let i = 0; i < msg.data.length; i++) this.buf[(this.write + i) & (SIZE - 1)] = msg.data[i];
56+ this.write += msg.data.length;
57+ }
58+
59+ process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
60+ const out = outputs[0];
61+ const n = out[0].length;
62+ let short = false;
63+ for (let i = 0; i < n; i++) {
64+ let v = this.tail;
65+ this.tail *= this.tailDecay;
66+ if (this.read < this.write) v += this.buf[this.read++ & (SIZE - 1)];
67+ else short = this.active;
68+ this.last = v;
69+ for (const ch of out) ch[i] = v;
70+ }
71+ if (short) this.underruns++;
72+ if (++this.blocks % REPORT_BLOCKS === 0 && this.worker) {
73+ this.worker.postMessage({ session: this.session, consumed: this.read, underruns: this.underruns });
74+ }
75+ return true;
76+ }
77+}
78+
79+registerProcessor("stream-player", StreamPlayer);
src/audio.tsadded+145−0View file
@@ -0,0 +1,145 @@
1+// Main-thread side of the audio: owns the AudioContext, the worklet that
2+// plays samples, and the worker that computes them. The worker and the
3+// worklet talk directly through a MessageChannel. Copies of the samples come
4+// back here for display; playedSamples() says how far playback has got.
5+// Sound is switched on and off with enable()/disable(); while it is on, each
6+// play() starts a new note (a damped note ends by itself).
7+
8+import type { Params } from "./pendulum";
9+import type { FromWorker, ToWorker } from "./audio-worker";
10+import workletUrl from "./audio-worklet.ts?worker&url";
11+
12+const FADE = 0.02; // s, fade in/out when sound is switched on or off
13+
14+type Stats = Extract<FromWorker, { type: "stats" }>;
15+
16+export interface AudioCallbacks {
17+ onStats: (s: Stats) => void;
18+ onSamples: (data: Float32Array) => void; // samples of the current note
19+ onEnded: () => void; // the current note decayed and stopped
20+}
21+
22+export class AudioOut {
23+ enabled = false; // sound on
24+ playing = false; // a note is being computed and played
25+ private P: Params;
26+ private alpha: number;
27+ private cb: AudioCallbacks;
28+ private session = 0; // matches the worker's count of starts
29+ private startTime = 0; // AudioContext time of the current note's start
30+ private ctx: AudioContext | null = null;
31+ private gain: GainNode | null = null;
32+ private worker: Worker | null = null;
33+ private volume = 0.5;
34+
35+ constructor(P: Params, alpha: number, cb: AudioCallbacks) {
36+ this.P = P;
37+ this.alpha = alpha;
38+ this.cb = cb;
39+ }
40+
41+ get sampleRate(): number {
42+ return this.ctx?.sampleRate ?? 48000;
43+ }
44+
45+ // Samples played so far in this note (from the audio clock).
46+ playedSamples(): number {
47+ return this.ctx ? (this.ctx.currentTime - this.startTime) * this.ctx.sampleRate : 0;
48+ }
49+
50+ private send(msg: ToWorker, transfer: Transferable[] = []): void {
51+ this.worker?.postMessage(msg, transfer);
52+ }
53+
54+ // Switch sound on. Must be called from a user gesture (browsers require
55+ // one to start audio).
56+ async enable(): Promise<void> {
57+ if (!this.ctx) {
58+ const ctx = new AudioContext();
59+ await ctx.audioWorklet.addModule(workletUrl);
60+ const node = new AudioWorkletNode(ctx, "stream-player", { outputChannelCount: [2] });
61+ this.gain = ctx.createGain();
62+ this.gain.gain.value = 0;
63+ node.connect(this.gain).connect(ctx.destination);
64+ this.worker = new Worker(new URL("./audio-worker.ts", import.meta.url), { type: "module" });
65+ this.worker.onmessage = (e: MessageEvent<FromWorker>) => {
66+ const msg = e.data;
67+ if (msg.type === "stats") this.cb.onStats(msg);
68+ else if (msg.session !== this.session || !this.playing) return;
69+ else if (msg.type === "samples") this.cb.onSamples(msg.data);
70+ else if (msg.type === "ended") {
71+ this.playing = false;
72+ this.cb.onEnded();
73+ }
74+ };
75+ const channel = new MessageChannel();
76+ node.port.postMessage({ port: channel.port1 }, [channel.port1]);
77+ this.send(
78+ { type: "init", P: this.P, alpha: this.alpha, sampleRate: ctx.sampleRate, port: channel.port2 },
79+ [channel.port2],
80+ );
81+ this.ctx = ctx;
82+ }
83+ await this.ctx.resume();
84+ const g = this.gain!.gain, now = this.ctx.currentTime;
85+ g.cancelScheduledValues(now);
86+ g.setValueAtTime(0, now);
87+ g.linearRampToValueAtTime(this.volume, now + FADE);
88+ this.enabled = true;
89+ }
90+
91+ // Switch sound off (fades out, stops the note, suspends the audio device).
92+ disable(): void {
93+ if (!this.ctx || !this.enabled) return;
94+ const ctx = this.ctx, g = this.gain!.gain, now = ctx.currentTime;
95+ g.cancelScheduledValues(now);
96+ g.setValueAtTime(g.value, now);
97+ g.linearRampToValueAtTime(0, now + FADE);
98+ setTimeout(() => {
99+ this.stopNote();
100+ if (!this.enabled) ctx.suspend();
101+ }, 1000 * FADE + 10);
102+ this.enabled = false;
103+ }
104+
105+ // Start a new note from the given state (replacing any current note).
106+ play(y: Float64Array, E0: number, target: number, pitchHz: number, signal: string): void {
107+ if (!this.ctx || !this.enabled) return;
108+ this.send({ type: "pitch", hz: pitchHz });
109+ this.send({ type: "signal", key: signal });
110+ this.send({ type: "start", y, E0, target });
111+ this.session++;
112+ this.startTime = this.ctx.currentTime;
113+ this.playing = true;
114+ }
115+
116+ // Stop the current note, if any.
117+ stopNote(): void {
118+ if (!this.playing) return;
119+ this.send({ type: "stop" });
120+ this.playing = false;
121+ }
122+
123+ setTarget(E: number): void {
124+ if (this.playing) this.send({ type: "target", E });
125+ }
126+
127+ // New parameters (takes effect from the next note).
128+ setParams(P: Params): void {
129+ this.P = P;
130+ this.send({ type: "params", P, alpha: this.alpha });
131+ }
132+
133+ setPitch(hz: number): void {
134+ this.send({ type: "pitch", hz });
135+ }
136+
137+ setSignal(key: string): void {
138+ this.send({ type: "signal", key });
139+ }
140+
141+ setVolume(v: number): void {
142+ this.volume = v;
143+ if (this.ctx && this.enabled) this.gain!.gain.setTargetAtTime(v, this.ctx.currentTime, FADE);
144+ }
145+}
src/lyapunov.tsadded+112−0View file
@@ -0,0 +1,112 @@
1+// Largest Lyapunov exponent by the two-trajectory (Benettin) method. A shadow
2+// copy of the pendulum is kept at distance D0 from it: after every step the
3+// separation is measured, its logarithmic growth recorded, and the shadow
4+// moved back to distance D0 along the current separation direction. The
5+// exponent is the average growth rate: over a sliding window (finite-time
6+// exponent, which follows a damped ring-down) and since the start.
7+//
8+// Distance: angles in rad (difference wrapped to (-pi, pi]) and momenta in
9+// units of pRef = sqrt((m1 + m2) l1^2 E_top), so both count comparably at high
10+// energy. The long-time exponent does not depend on this choice; finite-time
11+// values depend on it a little.
12+
13+import { energy, topEnergy } from "./pendulum";
14+import { Sim } from "./sim";
15+
16+const D0 = 1e-8; // shadow distance
17+const CAP = 1 << 16; // per-step growth values kept (> window / step)
18+
19+const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a));
20+
21+export class Lyapunov {
22+ private window: number; // s
23+ private shadow: Sim | null = null;
24+ private pRef = 1;
25+ // Ring of (time, log growth) per step, for the sliding window.
26+ private ts = new Float64Array(CAP);
27+ private gs = new Float64Array(CAP);
28+ private head = 0;
29+ private tail = 0;
30+ private windowSum = 0;
31+ private totalSum = 0;
32+ private t0 = 0; // start time
33+ private t = 0; // time of the last step
34+
35+ constructor(window: number) {
36+ this.window = window;
37+ }
38+
39+ // Start (or restart) measuring from the pendulum's current state.
40+ start(sim: Sim): void {
41+ const P = sim.P;
42+ this.pRef = Math.sqrt((P.m1 + P.m2) * P.l1 * P.l1 * topEnergy(P));
43+ const y = Float64Array.from(sim.y);
44+ // Initial separation: equal parts in all four coordinates.
45+ y[0] += D0 / 2;
46+ y[1] += D0 / 2;
47+ y[2] += (D0 / 2) * this.pRef;
48+ y[3] += (D0 / 2) * this.pRef;
49+ this.shadow = new Sim(P, 0, 0.5);
50+ this.shadow.setState(y, 0, 0);
51+ this.matchEnergy(sim);
52+ this.head = this.tail = 0;
53+ this.windowSum = this.totalSum = 0;
54+ this.t0 = this.t = sim.t;
55+ }
56+
57+ stop(): void {
58+ this.shadow = null;
59+ }
60+
61+ // Advance the shadow by h (call right after sim.step(h)) and measure.
62+ step(sim: Sim, h: number): void {
63+ const s = this.shadow;
64+ if (!s) return;
65+ s.step(h);
66+ const a = sim.y, b = s.y, pr = this.pRef;
67+ const d0 = wrap(b[0] - a[0]), d1 = wrap(b[1] - a[1]);
68+ const d2 = (b[2] - a[2]) / pr, d3 = (b[3] - a[3]) / pr;
69+ const d = Math.hypot(d0, d1, d2, d3);
70+ const r = D0 / d;
71+ b[0] = a[0] + d0 * r;
72+ b[1] = a[1] + d1 * r;
73+ b[2] = a[2] + d2 * r * pr;
74+ b[3] = a[3] + d3 * r * pr;
75+ this.matchEnergy(sim);
76+
77+ const g = Math.log(d / D0);
78+ this.t = sim.t;
79+ this.totalSum += g;
80+ this.windowSum += g;
81+ this.ts[this.head] = sim.t;
82+ this.gs[this.head] = g;
83+ this.head = (this.head + 1) % CAP;
84+ while (this.tail !== this.head && this.ts[this.tail] <= sim.t - this.window) {
85+ this.windowSum -= this.gs[this.tail];
86+ this.tail = (this.tail + 1) % CAP;
87+ }
88+ }
89+
90+ // Exponent over the last `window` seconds (or since the start, if shorter), 1/s.
91+ windowed(): number {
92+ const span = Math.min(this.window, this.t - this.t0);
93+ return this.shadow && span > 0 ? this.windowSum / span : NaN;
94+ }
95+
96+ // Exponent since the start, 1/s.
97+ mean(): number {
98+ const span = this.t - this.t0;
99+ return this.shadow && span > 0 ? this.totalSum / span : NaN;
100+ }
101+
102+ // The shadow keeps its own energy (forcing it onto the pendulum's exact
103+ // energy would rescale momenta, which near a turning point, T ~ 0, injects
104+ // a large artificial separation). Without damping it follows changes of the
105+ // pendulum's energy target by the same amount.
106+ private matchEnergy(sim: Sim): void {
107+ const s = this.shadow!;
108+ const E = energy(s.P, s.y);
109+ s.E0 = E;
110+ s.target = s.P.tau > 0 ? E : E + (sim.target - sim.E0);
111+ }
112+}
src/main.tsadded+502−0View file
@@ -0,0 +1,502 @@
1+import { AudioOut } from "./audio";
2+import { Lyapunov } from "./lyapunov";
3+import {
4+ energy, kinetic, normalModes, potential, stepSize, topEnergy, type Params,
5+} from "./pendulum";
6+import { SIGNALS, signalByKey } from "./signals";
7+import { Sim } from "./sim";
8+import { Spectrogram } from "./spectrogram";
9+import { TracePanel } from "./traces";
10+
11+const ALPHA = 0.5; // fraction of energy in the lower mode
12+// Parameters; changed with the parameter form (Apply restarts everything).
13+// tau: amplitude decay time from drag on the bobs; 540 s barely shows in the
14+// animation (~10% amplitude per minute) but makes each struck note ring down
15+// in a couple of seconds of sound. 0 = no damping.
16+let P: Params = { m1: 1, m2: 1, l1: 1, l2: 1, g: 9.81, tau: 540 };
17+const damped = () => P.tau > 0;
18+let H = stepSize(P, 2 * topEnergy(P)); // integrator step (s), for the energy slider range
19+
20+const canvas = document.getElementById("canvas") as HTMLCanvasElement;
21+const ctx = canvas.getContext("2d")!;
22+const energyInput = document.getElementById("energy") as HTMLInputElement;
23+const energyValue = document.getElementById("energy-value")!;
24+
25+// The animated pendulum, in real time.
26+// It hangs at rest until the first Strike; Pause freezes it.
27+let sim = new Sim(P, 0, ALPHA);
28+let running = false;
29+const pauseButton = document.getElementById("pause") as HTMLButtonElement;
30+const setRunning = (r: boolean) => {
31+ running = r;
32+ pauseButton.textContent = r ? "Pause" : "Resume";
33+};
34+pauseButton.addEventListener("click", () => setRunning(!running));
35+
36+// What follows from the parameters.
37+const modeFreqs = () => normalModes(P).omega.map((w) => w / (2 * Math.PI));
38+let modeHz = modeFreqs();
39+function showParams() {
40+ const { m1, m2, l1, l2, g } = P;
41+ document.getElementById("params")!.textContent =
42+ `normal modes ${modeHz[0].toFixed(3)} Hz, ${modeHz[1].toFixed(3)} Hz` +
43+ ` (ratio ${(modeHz[1] / modeHz[0]).toFixed(4)})\n` +
44+ `outer arm can flip E > ${(2 * m2 * g * l2).toFixed(1)} J\n` +
45+ `inner arm can flip E > ${(2 * (m1 + m2) * g * l1).toFixed(1)} J\n` +
46+ `both inverted E = ${topEnergy(P).toFixed(1)} J\n` +
47+ `decay time ${damped() ? `${P.tau} s (amplitude, all modes)` : "none (no damping)"}\n` +
48+ `integrator step ${(1000 * H).toFixed(2)} ms`;
49+}
50+showParams();
51+
52+// The signal that is heard and shown in both spectrograms.
53+const signalSelect = document.getElementById("signal") as HTMLSelectElement;
54+for (const s of SIGNALS) signalSelect.add(new Option(s.label, s.key));
55+let signal = signalByKey(signalSelect.value);
56+
57+// Spectrogram of the animation's signal, sampled every SPEC_DECIM steps
58+// (32 ms; 8.2 s window).
59+const SPEC_DECIM = 10;
60+const outerX = () => P.l1 * Math.sin(sim.y[0]) + P.l2 * Math.sin(sim.y[1]);
61+const specToggle = document.getElementById("spec-toggle") as HTMLInputElement;
62+const specFig = document.getElementById("spec-fig")!;
63+// Rebuilt when the parameters change (its sample spacing follows H).
64+const makeSpectrogram = () => {
65+ const s = new Spectrogram({ dt: SPEC_DECIM * H, n: 256, nfft: 1024, hop: 5, refFreqs: modeHz });
66+ document.getElementById("spectrogram")!.replaceChildren(s.canvas);
67+ return s;
68+};
69+let spectrogram = makeSpectrogram();
70+specToggle.addEventListener("change", () => (specFig.hidden = !specToggle.checked));
71+const specLabel = document.getElementById("spec-label")!;
72+const showSpecLabel = () =>
73+ (specLabel.textContent = `Spectrogram of ${signal.label}, simulated time. Dashed: normal modes.`);
74+showSpecLabel();
75+let stepCount = 0;
76+
77+// Energy balance: E plus the energy dissipated since the last strike or
78+// rescale, against the energy then (exact conservation would give 0).
79+const relErr = () => {
80+ const E = energy(P, sim.y) + sim.y[4];
81+ return sim.E0 > 0 ? (E - sim.E0) / sim.E0 : E - sim.E0;
82+};
83+// Largest Lyapunov exponent of the animated pendulum, measured from each
84+// Strike (10 s window for the finite-time value).
85+const lyap = new Lyapunov(10);
86+// The ghost (defined below) and its separation, for the Separation chart.
87+let ghost: Sim | null = null;
88+let ghostT0 = 0; // time of the last ghost start
89+// Angular distance between the pendulum and its ghost (rad).
90+function ghostSeparation(): number {
91+ if (!ghost) return NaN;
92+ const d = (a: number, b: number) => Math.atan2(Math.sin(a - b), Math.cos(a - b));
93+ return Math.hypot(d(sim.y[0], ghost.y[0]), d(sim.y[1], ghost.y[1]));
94+}
95+
96+const toggles = document.getElementById("toggles")!;
97+const traces = new TracePanel(
98+ toggles,
99+ document.getElementById("traces")!,
100+ [
101+ {
102+ label: "Energy",
103+ unit: "J",
104+ series: [
105+ { label: "E", value: () => energy(P, sim.y) },
106+ { label: "T", value: () => kinetic(P, sim.y) },
107+ { label: "V", value: () => potential(P, sim.y) },
108+ ],
109+ },
110+ { label: "Error", series: [{ label: "energy balance error", value: relErr }] },
111+ {
112+ label: "Angles",
113+ unit: "rad",
114+ series: [
115+ { label: "th1", value: () => sim.y[0], period: 2 * Math.PI },
116+ { label: "th2", value: () => sim.y[1], period: 2 * Math.PI },
117+ ],
118+ },
119+ {
120+ label: "Momenta",
121+ unit: "kg m²/s",
122+ series: [
123+ { label: "p1", value: () => sim.y[2] },
124+ { label: "p2", value: () => sim.y[3] },
125+ ],
126+ },
127+ {
128+ label: "Positions",
129+ unit: "m",
130+ series: [
131+ { label: "x1", value: () => P.l1 * Math.sin(sim.y[0]) },
132+ { label: "x2", value: outerX },
133+ { label: "y1", value: () => -P.l1 * Math.cos(sim.y[0]) },
134+ { label: "y2", value: () => -P.l1 * Math.cos(sim.y[0]) - P.l2 * Math.cos(sim.y[1]) },
135+ ],
136+ },
137+ {
138+ label: "Separation",
139+ unit: "rad",
140+ log: true,
141+ window: 30,
142+ series: [{ label: "ghost", value: ghostSeparation }],
143+ },
144+ {
145+ label: "Lyapunov",
146+ unit: "1/s",
147+ window: 30,
148+ series: [
149+ { label: "λ 10 s", value: () => lyap.windowed() },
150+ { label: "λ since strike", value: () => lyap.mean() },
151+ ],
152+ },
153+ ],
154+ 10, // visible time span (s)
155+);
156+// Hint when no plot is selected.
157+const plotsEmpty = document.getElementById("plots-empty")!;
158+toggles.addEventListener("change", () => (plotsEmpty.hidden = specToggle.checked || traces.anyShown()));
159+
160+// Sound: a separate, sped-up simulation. With sound on, each Strike starts a
161+// note from the same state as the animation; turning sound on while the
162+// pendulum swings starts one from its current state. A damped note ends by
163+// itself.
164+const soundButton = document.getElementById("sound") as HTMLButtonElement;
165+const pitchInput = document.getElementById("pitch") as HTMLInputElement;
166+const pitchValue = document.getElementById("pitch-value")!;
167+const volumeInput = document.getElementById("volume") as HTMLInputElement;
168+const audioStatus = document.getElementById("audio-status")!;
169+// Pitch slider 0..1000 maps log-uniformly to 30..1000 Hz for the lower mode.
170+const PITCH_MIN = 30, PITCH_MAX = 1000;
171+const pitchHz = () => PITCH_MIN * (PITCH_MAX / PITCH_MIN) ** (Number(pitchInput.value) / 1000);
172+pitchInput.value = String(Math.round((1000 * Math.log(100 / PITCH_MIN)) / Math.log(PITCH_MAX / PITCH_MIN)));
173+const audioModeHz = () => modeHz.map((f) => (f * pitchHz()) / modeHz[0]);
174+const audioSpecLabel = document.getElementById("audio-spec-label")!;
175+const showPitch = () => {
176+ const [lo, hi] = audioModeHz();
177+ const S = lo / modeHz[0];
178+ pitchValue.textContent =
179+ `${lo.toFixed(0)} Hz · upper ${hi.toFixed(0)} Hz · ×${S.toFixed(0)}` +
180+ (damped() ? ` · decay ${(P.tau / S).toFixed(1)} s` : "");
181+ audioSpecLabel.textContent =
182+ `Spectrogram of the sound (${signal.label}, sped up ×${(lo / modeHz[0]).toFixed(0)}). Dashed: normal modes.`;
183+};
184+showPitch();
185+
186+// Spectrogram of the audio, fed with the worker's samples as the audio clock
187+// reaches them (2048-sample window, new column every 512 samples, 960
188+// columns: ~10 s at 48 kHz). Rebuilt if the audio device's sample rate
189+// differs.
190+const makeAudioSpec = (rate: number) => {
191+ const s = new Spectrogram({ dt: 1 / rate, n: 2048, nfft: 4096, hop: 512, refFreqs: audioModeHz(), width: 960 });
192+ document.getElementById("audio-spectrogram")!.replaceChildren(s.canvas);
193+ return s;
194+};
195+let audioSpecRate = 48000;
196+let audioSpec = makeAudioSpec(audioSpecRate);
197+const audioSpecToggle = document.getElementById("audio-spec-toggle") as HTMLInputElement;
198+const audioSpecFig = document.getElementById("audio-spec-fig")!;
199+audioSpecToggle.addEventListener("change", () => (audioSpecFig.hidden = !audioSpecToggle.checked));
200+const queue: Float32Array[] = []; // sample chunks not yet shown
201+let queueOffset = 0; // samples of queue[0] already shown
202+let fed = 0; // samples shown this session
203+
204+function feedAudioSpec() {
205+ const played = audio.playedSamples();
206+ while (fed < played && queue.length > 0) {
207+ const chunk = queue[0];
208+ const n = Math.min(chunk.length - queueOffset, Math.ceil(played - fed));
209+ for (let i = 0; i < n; i++) audioSpec.push(chunk[queueOffset + i]);
210+ queueOffset += n;
211+ fed += n;
212+ if (queueOffset === chunk.length) {
213+ queue.shift();
214+ queueOffset = 0;
215+ }
216+ }
217+}
218+
219+const audio = new AudioOut(P, ALPHA, {
220+ onStats: (s) => {
221+ if (!audio.playing) return;
222+ audioStatus.textContent =
223+ `Computed ${s.speed.toFixed(1)}× faster than real time` +
224+ (s.speed < 1.5 ? " (too slow for live audio)" : "") +
225+ (s.underruns > 0 ? ` · ${s.underruns} dropouts` : "");
226+ },
227+ onSamples: (data) => queue.push(data),
228+ onEnded: () => (audioStatus.textContent = "Note finished (decayed by 60 dB)."),
229+});
230+
231+// Start a note from the animation's current state.
232+function startNote() {
233+ queue.length = 0;
234+ queueOffset = fed = 0;
235+ audio.play(sim.y, sim.E0, sim.target, pitchHz(), signal.key);
236+ audioSpec.clear();
237+ audioStatus.textContent = "";
238+}
239+
240+soundButton.addEventListener("click", async () => {
241+ if (audio.enabled) {
242+ audio.disable();
243+ soundButton.textContent = "Sound off";
244+ soundButton.classList.remove("on");
245+ audioStatus.textContent = "";
246+ return;
247+ }
248+ await audio.enable();
249+ soundButton.textContent = "Sound on";
250+ soundButton.classList.add("on");
251+ if (audio.sampleRate !== audioSpecRate) audioSpec = makeAudioSpec((audioSpecRate = audio.sampleRate));
252+ if (running && energy(P, sim.y) > 0) startNote();
253+ else audioStatus.textContent = "Press Strike to play a note.";
254+});
255+pitchInput.addEventListener("input", () => {
256+ showPitch();
257+ audio.setPitch(pitchHz());
258+ audioSpec.setRefFreqs(audioModeHz());
259+});
260+signalSelect.addEventListener("change", () => {
261+ signal = signalByKey(signalSelect.value);
262+ showSpecLabel();
263+ showPitch();
264+ spectrogram.clear();
265+ audio.setSignal(signal.key);
266+});
267+volumeInput.addEventListener("input", () => audio.setVolume(Number(volumeInput.value)));
268+
269+// Without damping the slider sets the energy live; with damping it sets the
270+// energy of the next strike.
271+const sliderEnergy = () => Number(energyInput.value);
272+energyInput.addEventListener("input", () => {
273+ if (damped()) return;
274+ sim.target = sliderEnergy();
275+ if (ghost) ghost.target = sim.target;
276+ audio.setTarget(sim.target);
277+});
278+
279+// Ghost: a second pendulum started from the same state but with the outer
280+// arm's angle offset by delta, run in lockstep (never pulled back), to show
281+// how fast nearby motions separate. Restarted on Strike, on Reset, and when
282+// switched on.
283+const ghostToggle = document.getElementById("ghost-toggle") as HTMLInputElement;
284+const ghostControls = document.getElementById("ghost-controls")!;
285+const ghostOffset = document.getElementById("ghost-offset") as HTMLInputElement; // log10 of delta
286+const ghostOffsetValue = document.getElementById("ghost-offset-value")!;
287+const ghostSep = document.getElementById("ghost-sep")!;
288+const SUP: Record<string, string> = { "-": "⁻", 0: "⁰", 1: "¹", 2: "²", 3: "³", 4: "⁴", 5: "⁵", 6: "⁶", 7: "⁷", 8: "⁸", 9: "⁹" };
289+const showGhostOffset = () =>
290+ (ghostOffsetValue.textContent = `10${[...ghostOffset.value].map((c) => SUP[c]).join("")} rad`);
291+showGhostOffset();
292+function resetGhost() {
293+ ghost = null;
294+ if (!ghostToggle.checked || energy(P, sim.y) === 0) return;
295+ const y = Float64Array.from(sim.y);
296+ y[1] += 10 ** Number(ghostOffset.value);
297+ y[4] = 0;
298+ const E = energy(P, y);
299+ ghost = new Sim(P, 0, ALPHA);
300+ ghost.setState(y, E, damped() ? E : sim.target);
301+ ghostT0 = sim.t;
302+}
303+ghostToggle.addEventListener("change", () => {
304+ ghostControls.hidden = !ghostToggle.checked;
305+ resetGhost();
306+});
307+ghostOffset.addEventListener("input", () => {
308+ showGhostOffset();
309+ resetGhost();
310+});
311+document.getElementById("ghost-reset")!.addEventListener("click", resetGhost);
312+// Energy value; without damping, a down arrow while a lower target is not yet
313+// reachable.
314+let shownEnergy = "";
315+function showEnergy() {
316+ const lowering = !damped() && running && sim.E0 > sim.target;
317+ const text = `${sliderEnergy().toFixed(1)} J${lowering ? " ↓" : ""}`;
318+ if (text !== shownEnergy) energyValue.textContent = shownEnergy = text;
319+}
320+
321+document.getElementById("controls")!.addEventListener("submit", (e) => {
322+ e.preventDefault();
323+ sim.strike(sliderEnergy());
324+ resetGhost();
325+ lyap.start(sim);
326+ setRunning(true);
327+ pauseButton.disabled = false;
328+ traces.clear();
329+ spectrogram.clear();
330+ if (audio.enabled) startNote();
331+});
332+
333+// Parameter form. Presets keep m1 = m2 = 1 kg and l1 = 1 m and vary l2. With
334+// equal masses the squared mode ratio is (L + 1 + s) / (L + 1 - s), where
335+// L = l2/l1 and s = sqrt(L^2 + 1): it is never below 1 + sqrt(2) (at L = 1)
336+// and is the same for L and 1/L.
337+const preset = (l2: number) => ({ m1: 1, m2: 1, l1: 1, l2, g: 9.81 });
338+const PRESETS: Record<string, Omit<Params, "tau">> = {
339+ equal: preset(1), // 1 + sqrt(2) = 2.414
340+ tenth: preset(1.5667), // 5:2
341+ golden: preset(2), // golden ratio squared, 2.618
342+ eightThirds: preset(2.1637), // 8:3
343+ twelfth: preset(3.2476), // 3:1
344+ doubleOctave: preset(6.886), // 4:1
345+};
346+const KEYS = ["m1", "m2", "l1", "l2", "g", "tau"] as const;
347+const paramInput = (k: string) => document.getElementById(`p-${k}`) as HTMLInputElement;
348+const presetSelect = document.getElementById("preset") as HTMLSelectElement;
349+const paramStatus = document.getElementById("param-status")!;
350+const fillParams = (p: Partial<Params>) =>
351+ KEYS.forEach((k) => p[k] !== undefined && (paramInput(k).value = String(p[k])));
352+fillParams(P);
353+presetSelect.value = "equal";
354+energyInput.max = String(2 * topEnergy(P));
355+energyInput.step = String((2 * topEnergy(P)) / 1200);
356+presetSelect.addEventListener("change", () => {
357+ if (!PRESETS[presetSelect.value]) return;
358+ fillParams(PRESETS[presetSelect.value]);
359+ applyParams();
360+});
361+document.getElementById("param-form")!.addEventListener("submit", (e) => {
362+ e.preventDefault();
363+ presetSelect.value = "";
364+ applyParams();
365+});
366+
367+// Apply the parameters in the form: the pendulum returns to rest (waiting for
368+// a Strike) and the strike energy keeps its fraction of the both-inverted
369+// energy.
370+function applyParams() {
371+ const next = Object.fromEntries(KEYS.map((k) => [k, Number(paramInput(k).value)])) as unknown as Params;
372+ const ok = (k: (typeof KEYS)[number]) => Number.isFinite(next[k]) && (k === "tau" ? next[k] >= 0 : next[k] > 0);
373+ if (!KEYS.every(ok)) {
374+ paramStatus.textContent = "masses, lengths and g must be positive; decay time 0 or more";
375+ return;
376+ }
377+ paramStatus.textContent = "";
378+ const frac = sliderEnergy() / topEnergy(P);
379+ P = next;
380+ H = stepSize(P, 2 * topEnergy(P));
381+ modeHz = modeFreqs();
382+ const eMax = 2 * topEnergy(P);
383+ energyInput.max = String(eMax);
384+ energyInput.step = String(eMax / 1200);
385+ const E = frac * topEnergy(P);
386+ energyInput.value = String(E);
387+ sim = new Sim(P, 0, ALPHA);
388+ ghost = null;
389+ lyap.stop();
390+ setRunning(false);
391+ pauseButton.disabled = true;
392+ showParams();
393+ spectrogram = makeSpectrogram();
394+ traces.clear();
395+ showPitch();
396+ audioSpec.setRefFreqs(audioModeHz());
397+ audio.setParams(P);
398+ audio.stopNote();
399+}
400+
401+// Keep the canvas backing store at device resolution.
402+function fitCanvas() {
403+ const dpr = window.devicePixelRatio || 1;
404+ const w = Math.round(canvas.clientWidth * dpr);
405+ if (canvas.width !== w) canvas.width = canvas.height = w;
406+}
407+
408+// Rods and bobs of a pendulum in state y; the ghost is translucent orange
409+// with hollow bobs.
410+function drawPendulum(y: Float64Array, isGhost: boolean) {
411+ const w = canvas.width, dpr = window.devicePixelRatio || 1;
412+ const { l1, l2, m1, m2 } = P;
413+ const scale = (0.44 * w) / (l1 + l2); // pixels per meter
414+ const x0 = w / 2, y0 = w / 2;
415+ const x1 = x0 + scale * l1 * Math.sin(y[0]), y1 = y0 + scale * l1 * Math.cos(y[0]);
416+ const x2 = x1 + scale * l2 * Math.sin(y[1]), y2 = y1 + scale * l2 * Math.cos(y[1]);
417+ const color = isGhost ? "rgba(200, 116, 44, 0.75)" : "#2b2e33";
418+ ctx.strokeStyle = color;
419+ ctx.lineWidth = (isGhost ? 1.5 : 2) * dpr;
420+ ctx.lineCap = "round";
421+ ctx.beginPath();
422+ ctx.moveTo(x0, y0);
423+ ctx.lineTo(x1, y1);
424+ ctx.lineTo(x2, y2);
425+ ctx.stroke();
426+ // Bobs, area proportional to mass.
427+ const mMax = Math.max(m1, m2);
428+ for (const [x, yy, m] of [[x1, y1, m1], [x2, y2, m2]]) {
429+ ctx.beginPath();
430+ ctx.arc(x, yy, Math.max(3, 10 * Math.sqrt(m / mMax)) * dpr, 0, 2 * Math.PI);
431+ if (isGhost) {
432+ ctx.lineWidth = 1.5 * dpr;
433+ ctx.strokeStyle = color;
434+ ctx.stroke();
435+ } else {
436+ ctx.fillStyle = "#2f5d8a";
437+ ctx.fill();
438+ ctx.lineWidth = 1.5 * dpr;
439+ ctx.strokeStyle = "#fff";
440+ ctx.stroke();
441+ }
442+ }
443+}
444+
445+let shownSep = "";
446+function draw() {
447+ fitCanvas();
448+ const w = canvas.width, dpr = window.devicePixelRatio || 1;
449+ const { l1, l2 } = P;
450+ const scale = (0.44 * w) / (l1 + l2); // pixels per meter
451+ const x0 = w / 2, y0 = w / 2;
452+ ctx.clearRect(0, 0, w, w);
453+ // Reach of the outer bob.
454+ ctx.strokeStyle = "#e8e7e1";
455+ ctx.lineWidth = dpr;
456+ ctx.setLineDash([3 * dpr, 4 * dpr]);
457+ ctx.beginPath();
458+ ctx.arc(x0, y0, scale * (l1 + l2), 0, 2 * Math.PI);
459+ ctx.stroke();
460+ ctx.setLineDash([]);
461+ // Mount.
462+ ctx.strokeStyle = "#9a9a94";
463+ ctx.lineWidth = 2 * dpr;
464+ ctx.beginPath();
465+ ctx.moveTo(x0 - 14 * dpr, y0);
466+ ctx.lineTo(x0 + 14 * dpr, y0);
467+ ctx.stroke();
468+ if (ghost) drawPendulum(ghost.y, true);
469+ drawPendulum(sim.y, false);
470+ ctx.fillStyle = "#2b2e33";
471+ ctx.beginPath();
472+ ctx.arc(x0, y0, 3 * dpr, 0, 2 * Math.PI);
473+ ctx.fill();
474+ showEnergy();
475+ const sep = ghost
476+ ? `separation ${ghostSeparation().toExponential(1)} rad · ${(sim.t - ghostT0).toFixed(1)} s`
477+ : "";
478+ if (sep !== shownSep) ghostSep.textContent = shownSep = sep;
479+}
480+
481+let last = performance.now();
482+let pending = 0; // simulated time owed to the wall clock (s)
483+function frame(now: number) {
484+ pending += Math.min(0.1, (now - last) / 1000);
485+ last = now;
486+ if (!running) pending = 0;
487+ while (pending >= H) {
488+ sim.step(H);
489+ ghost?.step(H);
490+ lyap.step(sim, H);
491+ pending -= H;
492+ traces.sample(sim.t);
493+ if (++stepCount % SPEC_DECIM === 0) spectrogram.push(signal.value(P, sim.y));
494+ }
495+ draw();
496+ traces.draw();
497+ if (!specFig.hidden) spectrogram.draw();
498+ feedAudioSpec();
499+ if (!audioSpecFig.hidden) audioSpec.draw();
500+ requestAnimationFrame(frame);
501+}
502+requestAnimationFrame(frame);
src/pendulum.tsadded+192−0View file
@@ -0,0 +1,192 @@
1+// Planar double pendulum: point masses on massless rods, SI units, with
2+// optional linear drag on the bobs.
3+// State y = [th1, th2, p1, p2, W]: absolute angles (rad) from the downward
4+// vertical, their canonical momenta (kg m^2/s), and W, the energy (J)
5+// dissipated by drag so far (so E + W is conserved).
6+
7+export interface Params {
8+ m1: number; // kg
9+ m2: number; // kg
10+ l1: number; // m
11+ l2: number; // m
12+ g: number; // m/s^2
13+ tau: number; // s, amplitude decay time from drag (all modes); 0 = no damping
14+}
15+
16+export const STATE_SIZE = 5;
17+
18+// Drag rate gamma (1/s). Each bob feels a force -gamma * m_i * v_i (linear
19+// drag proportional to its mass; equal for equal bobs). The generalized
20+// forces are then -gamma * p, so the dissipated power is 2 gamma T and every
21+// small-amplitude mode decays as exp(-gamma t / 2) = exp(-t / tau).
22+export function dragRate(P: Params): number {
23+ return P.tau > 0 ? 2 / P.tau : 0;
24+}
25+
26+// Equations of motion (Hamilton's, plus drag at rate gamma), written into out.
27+export function rhs(P: Params, gamma: number, y: Float64Array, out: Float64Array): void {
28+ const { m1, m2, l1, l2, g } = P;
29+ const th1 = y[0], th2 = y[1], p1 = y[2], p2 = y[3];
30+ const d = th1 - th2;
31+ // Mass matrix [[a, c], [c, b]], so p = M * thdot.
32+ const a = (m1 + m2) * l1 * l1;
33+ const b = m2 * l2 * l2;
34+ const c = m2 * l1 * l2 * Math.cos(d);
35+ const det = a * b - c * c;
36+ const w1 = (b * p1 - c * p2) / det;
37+ const w2 = (a * p2 - c * p1) / det;
38+ // dT/d(th1 - th2) at fixed p equals m2*l1*l2*w1*w2*sin(d).
39+ const k = m2 * l1 * l2 * Math.sin(d) * w1 * w2;
40+ out[0] = w1;
41+ out[1] = w2;
42+ out[2] = -k - (m1 + m2) * g * l1 * Math.sin(th1) - gamma * p1;
43+ out[3] = k - m2 * g * l2 * Math.sin(th2) - gamma * p2;
44+ out[4] = gamma * (w1 * p1 + w2 * p2); // dissipated power, 2 gamma T
45+}
46+
47+export function kinetic(P: Params, y: Float64Array): number {
48+ const { m1, m2, l1, l2 } = P;
49+ const p1 = y[2], p2 = y[3];
50+ const a = (m1 + m2) * l1 * l1;
51+ const b = m2 * l2 * l2;
52+ const c = m2 * l1 * l2 * Math.cos(y[0] - y[1]);
53+ const det = a * b - c * c;
54+ return (b * p1 * p1 - 2 * c * p1 * p2 + a * p2 * p2) / (2 * det);
55+}
56+
57+// Potential energy, zero when hanging at rest.
58+export function potential(P: Params, y: Float64Array): number {
59+ const { m1, m2, l1, l2, g } = P;
60+ return (m1 + m2) * g * l1 * (1 - Math.cos(y[0])) + m2 * g * l2 * (1 - Math.cos(y[1]));
61+}
62+
63+export function energy(P: Params, y: Float64Array): number {
64+ return kinetic(P, y) + potential(P, y);
65+}
66+
67+// Set the energy to E by scaling both momenta, keeping the angles: a push
68+// along the current direction of motion. Not possible when E < V(theta) or
69+// when the pendulum is momentarily at rest; returns false and leaves y alone.
70+export function rescaleEnergy(P: Params, y: Float64Array, E: number): boolean {
71+ const T = kinetic(P, y), V = potential(P, y);
72+ if (E < V || T <= 0) return false;
73+ const s = Math.sqrt((E - V) / T);
74+ y[2] *= s;
75+ y[3] *= s;
76+ return true;
77+}
78+
79+// Small-oscillation normal modes about hanging rest. Returns angular
80+// frequencies (rad/s, lower first) and mode shapes v normalized so
81+// v^T M0 v = 1.
82+export function normalModes(P: Params) {
83+ const { m1, m2, l1, l2, g } = P;
84+ const a = (m1 + m2) * l1 * l1, b = m2 * l2 * l2, c = m2 * l1 * l2; // M0
85+ const k1 = (m1 + m2) * g * l1, k2 = m2 * g * l2; // stiffness diag(k1, k2)
86+ // det(K - w^2 M0) = 0 is a quadratic in w^2.
87+ const det = a * b - c * c;
88+ const B = k1 * b + k2 * a;
89+ const disc = Math.sqrt(B * B - 4 * det * k1 * k2);
90+ const omega2 = [(B - disc) / (2 * det), (B + disc) / (2 * det)];
91+ const shapes = omega2.map((w2) => {
92+ const v0 = w2 * c, v1 = k1 - w2 * a;
93+ const n = Math.sqrt(a * v0 * v0 + 2 * c * v0 * v1 + b * v1 * v1);
94+ return [v0 / n, v1 / n];
95+ });
96+ return { omega: omega2.map(Math.sqrt), shapes };
97+}
98+
99+// Energy (J) with both arms inverted.
100+export function topEnergy(P: Params): number {
101+ return 2 * (P.m1 + P.m2) * P.g * P.l1 + 2 * P.m2 * P.g * P.l2;
102+}
103+
104+// Integrator step (s) that keeps the relative energy error below `tol` up to
105+// energy Emax. No simple formula fits all mass and length ratios, so this
106+// calibrates: a 60 s test run at Emax (strikes with alpha = 0 and 0.5, the
107+// worst cases seen) from a trial step of 1/340 of the upper mode's period,
108+// then rescales using error ~ h^4, with a 10% margin. Takes ~10 to 50 ms.
109+// Damping is ignored here (it only lowers the energy).
110+export function stepSize(P: Params, Emax: number, tol = 1e-6): number {
111+ const h0 = (2 * Math.PI) / normalModes(P).omega[1] / 340;
112+ const integrator = new GaussLegendre2({ ...P, tau: 0 });
113+ let err = 0;
114+ for (const alpha of [0, 0.5]) {
115+ const y = strike(P, Emax, alpha);
116+ const E0 = energy(P, y);
117+ for (let i = 0; i < 60 / h0; i++) {
118+ integrator.step(y, h0);
119+ if (i % 10 === 0) err = Math.max(err, Math.abs(energy(P, y) - E0) / E0);
120+ }
121+ }
122+ return 0.9 * h0 * Math.min(2, (tol / err) ** 0.25);
123+}
124+
125+// Strike from hanging rest with total energy E (J). alpha is the fraction of
126+// energy in the lower normal mode (exact at small E; the same momentum
127+// direction is used at all E).
128+export function strike(P: Params, E: number, alpha: number): Float64Array {
129+ const { m1, m2, l1, l2 } = P;
130+ const { shapes } = normalModes(P);
131+ const s1 = Math.sqrt(2 * alpha * E), s2 = Math.sqrt(2 * (1 - alpha) * E);
132+ const w1 = s1 * shapes[0][0] + s2 * shapes[1][0];
133+ const w2 = s1 * shapes[0][1] + s2 * shapes[1][1];
134+ const a = (m1 + m2) * l1 * l1, b = m2 * l2 * l2, c = m2 * l1 * l2;
135+ return new Float64Array([0, 0, a * w1 + c * w2, c * w1 + b * w2, 0]);
136+}
137+
138+// 2-stage Gauss-Legendre (order 4; symplectic and time-symmetric when there
139+// is no damping). The implicit stage equations are solved by fixed-point
140+// iteration.
141+const S3 = Math.sqrt(3) / 6;
142+const A11 = 0.25, A12 = 0.25 - S3, A21 = 0.25 + S3, A22 = 0.25;
143+
144+export class GaussLegendre2 {
145+ private k1 = new Float64Array(STATE_SIZE);
146+ private k2 = new Float64Array(STATE_SIZE);
147+ private n1 = new Float64Array(STATE_SIZE);
148+ private n2 = new Float64Array(STATE_SIZE);
149+ private y1 = new Float64Array(STATE_SIZE);
150+ private y2 = new Float64Array(STATE_SIZE);
151+ iterations = 0; // fixed-point iterations used by the last step
152+ private P: Params;
153+ private gamma: number; // drag rate
154+
155+ constructor(P: Params) {
156+ this.P = P;
157+ this.gamma = dragRate(P);
158+ }
159+
160+ step(y: Float64Array, h: number): void {
161+ const { k1, k2, n1, n2, y1, y2, P, gamma } = this;
162+ rhs(P, gamma, y, k1);
163+ k2.set(k1);
164+ let it = 0;
165+ for (; it < 50; it++) {
166+ for (let i = 0; i < STATE_SIZE; i++) {
167+ y1[i] = y[i] + h * (A11 * k1[i] + A12 * k2[i]);
168+ y2[i] = y[i] + h * (A21 * k1[i] + A22 * k2[i]);
169+ }
170+ rhs(P, gamma, y1, n1);
171+ rhs(P, gamma, y2, n2);
172+ let diff = 0, scale = 1;
173+ for (let i = 0; i < 4; i++) {
174+ diff = Math.max(diff, Math.abs(n1[i] - k1[i]), Math.abs(n2[i] - k2[i]));
175+ scale = Math.max(scale, Math.abs(n1[i]), Math.abs(n2[i]));
176+ }
177+ k1.set(n1);
178+ k2.set(n2);
179+ if (diff <= 1e-14 * scale) break;
180+ }
181+ this.iterations = it + 1;
182+ for (let i = 0; i < STATE_SIZE; i++) y[i] += 0.5 * h * (k1[i] + k2[i]);
183+ // Keep angles in (-pi, pi]: an arm that rotates would otherwise grow its
184+ // angle without bound and lose precision in sin/cos.
185+ y[0] = wrap(y[0]);
186+ y[1] = wrap(y[1]);
187+ }
188+}
189+
190+function wrap(th: number): number {
191+ return th - 2 * Math.PI * Math.round(th / (2 * Math.PI));
192+}
src/signals.tsadded+49−0View file
@@ -0,0 +1,49 @@
1+// Quantities that can be heard (and shown in the spectrograms). Each has a
2+// rough scale used to bring the audio to similar levels: positions by arm
3+// length, momenta by their size at the both-inverted energy.
4+
5+import { topEnergy as eTop, type Params } from "./pendulum";
6+
7+export interface Signal {
8+ key: string;
9+ label: string;
10+ value: (P: Params, y: Float64Array) => number;
11+ scale: (P: Params) => number;
12+}
13+
14+export const SIGNALS: Signal[] = [
15+ {
16+ key: "x2", label: "x2 (outer bob x)",
17+ value: (P, y) => P.l1 * Math.sin(y[0]) + P.l2 * Math.sin(y[1]),
18+ scale: (P) => P.l1 + P.l2,
19+ },
20+ {
21+ key: "y2", label: "y2 (outer bob y)",
22+ value: (P, y) => -P.l1 * Math.cos(y[0]) - P.l2 * Math.cos(y[1]),
23+ scale: (P) => P.l1 + P.l2,
24+ },
25+ {
26+ key: "x1", label: "x1 (inner bob x)",
27+ value: (P, y) => P.l1 * Math.sin(y[0]),
28+ scale: (P) => P.l1,
29+ },
30+ {
31+ key: "y1", label: "y1 (inner bob y)",
32+ value: (P, y) => -P.l1 * Math.cos(y[0]),
33+ scale: (P) => P.l1,
34+ },
35+ { key: "sin1", label: "sin th1", value: (_P, y) => Math.sin(y[0]), scale: () => 1 },
36+ { key: "sin2", label: "sin th2", value: (_P, y) => Math.sin(y[1]), scale: () => 1 },
37+ {
38+ key: "p1", label: "p1",
39+ value: (_P, y) => y[2],
40+ scale: (P) => Math.sqrt(2 * (P.m1 + P.m2) * P.l1 * P.l1 * eTop(P)),
41+ },
42+ {
43+ key: "p2", label: "p2",
44+ value: (_P, y) => y[3],
45+ scale: (P) => Math.sqrt(2 * P.m2 * P.l2 * P.l2 * eTop(P)),
46+ },
47+];
48+
49+export const signalByKey = (key: string): Signal => SIGNALS.find((s) => s.key === key) ?? SIGNALS[0];
src/sim.tsadded+75−0View file
@@ -0,0 +1,75 @@
1+// A running pendulum: state, integrator, and the energy target. Used by both
2+// the animation (main thread) and the audio (worker). Without damping the
3+// energy follows the target (slider); with damping the target is only the
4+// energy of the last strike.
5+
6+import {
7+ energy,
8+ GaussLegendre2,
9+ kinetic,
10+ potential,
11+ rescaleEnergy,
12+ strike,
13+ type Params,
14+} from "./pendulum";
15+
16+// While a lower target is not yet reachable (target < V), remove kinetic
17+// energy at this rate (fraction per second).
18+const BRAKE_RATE = 6;
19+
20+export class Sim {
21+ readonly P: Params;
22+ y: Float64Array;
23+ E0: number; // energy at the last strike or rescale; E + y[4] stays equal to it
24+ target: number; // energy the slider asks for
25+ t = 0;
26+ private alpha: number;
27+ private integrator: GaussLegendre2;
28+
29+ constructor(P: Params, E: number, alpha: number) {
30+ this.P = P;
31+ this.alpha = alpha;
32+ this.integrator = new GaussLegendre2(P);
33+ this.y = strike(P, E, alpha);
34+ this.E0 = energy(P, this.y);
35+ this.target = E;
36+ }
37+
38+ strike(E: number): void {
39+ this.y = strike(this.P, E, this.alpha);
40+ this.E0 = energy(this.P, this.y);
41+ this.target = E;
42+ this.t = 0;
43+ }
44+
45+ // Take over another simulation's state.
46+ setState(y: Float64Array, E0: number, target: number): void {
47+ this.y = Float64Array.from(y);
48+ this.E0 = E0;
49+ this.target = target;
50+ }
51+
52+ step(h: number): void {
53+ this.applyTarget(h);
54+ this.integrator.step(this.y, h);
55+ this.t += h;
56+ }
57+
58+ // Move the energy toward the target.
59+ private applyTarget(h: number): void {
60+ const { P, y } = this;
61+ if (this.E0 === this.target) return;
62+ if (rescaleEnergy(P, y, this.target)) {
63+ this.E0 = this.target;
64+ y[4] = 0;
65+ } else if (this.target < potential(P, y)) {
66+ rescaleEnergy(P, y, potential(P, y) + (1 - BRAKE_RATE * h) * kinetic(P, y));
67+ this.E0 = energy(P, y);
68+ y[4] = 0;
69+ } else if (this.E0 < 1e-12) {
70+ // At rest at the bottom: nothing to push along, so strike instead.
71+ this.y = strike(P, this.target, this.alpha);
72+ this.E0 = this.target;
73+ }
74+ }
75+}
src/spectrogram.tsadded+267−0View file
@@ -0,0 +1,267 @@
1+// Live spectrogram of one signal. Samples arrive at a fixed spacing dt (s);
2+// every `hop` samples a Hann-windowed FFT of the last `n` samples (zero
3+// padded to `nfft`) becomes one column. The frequency axis tops out just
4+// above the highest frequency with significant power in the time-averaged
5+// spectrum of the visible history.
6+
7+const HEIGHT = 160;
8+const RANGE_DB = 60; // color range below the peak
9+const CONTENT_DB = 30; // "significant" power for the frequency auto scale
10+const FULL_EVERY = 50; // columns between full re-renders
11+
12+export interface SpectrogramOptions {
13+ dt: number; // sample spacing (s)
14+ n: number; // samples per FFT window
15+ nfft: number; // zero-padded FFT length (power of two)
16+ hop: number; // samples between columns
17+ refFreqs: number[]; // frequencies to mark with dashed lines (Hz)
18+ width?: number; // columns kept and drawn (canvas width), default 480
19+}
20+
21+export class Spectrogram {
22+ canvas: HTMLCanvasElement;
23+ private width: number; // columns kept and drawn
24+ private dt: number;
25+ private n: number;
26+ private hop: number;
27+ private bins: number;
28+ private refFreqs: number[];
29+ private buf: Float64Array; // last n samples (ring)
30+ private nSamples = 0;
31+ private cols: Float32Array; // power (ring of columns)
32+ private sum: Float64Array; // sum of the stored columns
33+ private nCols = 0;
34+ private headCol = 0;
35+ private hann: Float64Array;
36+ private fft: FFT;
37+ private re: Float64Array;
38+ private im: Float64Array;
39+ private fmax = 1;
40+ private peak = 0; // power at the top of the color scale
41+ private image: ImageData;
42+ private newCols = 0; // columns added since the last draw
43+ private sinceFull = Infinity; // columns drawn since the last full render
44+
45+ constructor(opts: SpectrogramOptions) {
46+ this.width = opts.width ?? 480;
47+ this.dt = opts.dt;
48+ this.n = opts.n;
49+ this.hop = opts.hop;
50+ this.bins = opts.nfft / 2 + 1;
51+ this.refFreqs = opts.refFreqs;
52+ this.buf = new Float64Array(this.n);
53+ this.cols = new Float32Array(this.width * this.bins);
54+ this.sum = new Float64Array(this.bins);
55+ this.hann = new Float64Array(this.n);
56+ for (let i = 0; i < this.n; i++) this.hann[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (this.n - 1));
57+ this.fft = new FFT(opts.nfft);
58+ this.re = new Float64Array(opts.nfft);
59+ this.im = new Float64Array(opts.nfft);
60+ this.canvas = document.createElement("canvas");
61+ this.canvas.width = this.width;
62+ this.canvas.height = HEIGHT;
63+ this.image = this.canvas.getContext("2d")!.createImageData(this.width, HEIGHT);
64+ }
65+
66+ clear(): void {
67+ this.nSamples = 0;
68+ this.nCols = 0;
69+ this.headCol = 0;
70+ this.sum.fill(0);
71+ this.newCols = 0;
72+ this.sinceFull = Infinity;
73+ }
74+
75+ setRefFreqs(f: number[]): void {
76+ this.refFreqs = f;
77+ this.sinceFull = Infinity; // redraw
78+ }
79+
80+ push(x: number): void {
81+ this.buf[this.nSamples % this.n] = x;
82+ this.nSamples++;
83+ if (this.nSamples >= this.n && this.nSamples % this.hop === 0) this.addColumn();
84+ }
85+
86+ private addColumn(): void {
87+ const { buf, re, im, hann, cols, sum, n, bins } = this;
88+ let mean = 0;
89+ for (let i = 0; i < n; i++) mean += buf[i];
90+ mean /= n;
91+ const start = this.nSamples % n; // oldest sample
92+ re.fill(0);
93+ im.fill(0);
94+ for (let i = 0; i < n; i++) re[i] = (buf[(start + i) % n] - mean) * hann[i];
95+ this.fft.transform(re, im);
96+ const off = this.headCol * bins;
97+ for (let k = 0; k < bins; k++) {
98+ if (this.nCols === this.width) sum[k] -= cols[off + k]; // evicted column
99+ cols[off + k] = re[k] * re[k] + im[k] * im[k];
100+ sum[k] += cols[off + k];
101+ }
102+ this.headCol = (this.headCol + 1) % this.width;
103+ this.nCols = Math.min(this.nCols + 1, this.width);
104+ this.newCols++;
105+ }
106+
107+ // Draws only when columns have been added. New columns scroll in; the
108+ // whole image is re-rendered only when the frequency range changes, a peak
109+ // 3 dB louder arrives, or every FULL_EVERY columns (so the color scale can
110+ // relax after a loud passage).
111+ draw(): void {
112+ if (this.newCols === 0 && this.sinceFull !== Infinity) return;
113+ const { sum, cols, bins } = this;
114+ const df = this.df();
115+
116+ // Highest bin of the averaged spectrum within CONTENT_DB of its peak sets
117+ // the frequency range.
118+ let sumPeak = 0;
119+ for (let k = 1; k < bins; k++) if (sum[k] > sumPeak) sumPeak = sum[k];
120+ let top = bins - 1;
121+ while (top > 1 && sum[top] < sumPeak * 10 ** (-CONTENT_DB / 10)) top--;
122+ const fmax = this.nCols > 0 ? Math.min(niceCeil(1.2 * top * df), (bins - 1) * df) : this.fmax;
123+
124+ let newPeak = 0;
125+ for (let a = 1; a <= this.newCols; a++) {
126+ const off = ((this.headCol - a + this.width) % this.width) * bins;
127+ for (let k = 1; k < bins; k++) if (cols[off + k] > newPeak) newPeak = cols[off + k];
128+ }
129+ const full =
130+ fmax !== this.fmax || newPeak > 2 * this.peak || // 3 dB louder
131+ this.sinceFull + this.newCols >= FULL_EVERY || this.newCols >= this.width;
132+ this.fmax = fmax;
133+
134+ const data = this.image.data;
135+ if (full) {
136+ this.peak = 0;
137+ for (let i = 0; i < this.nCols * bins; i++) if (cols[i] > this.peak) this.peak = cols[i];
138+ for (let x = 0; x < this.width; x++) this.renderColumn(x, this.width - 1 - x);
139+ this.sinceFull = 0;
140+ } else {
141+ // Scroll left by the number of new columns, then render them.
142+ const shift = this.newCols;
143+ for (let y = 0; y < HEIGHT; y++) {
144+ const row = 4 * y * this.width;
145+ data.copyWithin(row, row + 4 * shift, row + 4 * this.width);
146+ }
147+ for (let x = this.width - shift; x < this.width; x++) this.renderColumn(x, this.width - 1 - x);
148+ this.sinceFull += shift;
149+ }
150+ this.newCols = 0;
151+
152+ const ctx = this.canvas.getContext("2d")!;
153+ ctx.putImageData(this.image, 0, 0);
154+ const py = (f: number) => (HEIGHT - 1) * (1 - f / this.fmax);
155+ ctx.strokeStyle = "rgba(255,255,255,0.7)";
156+ ctx.setLineDash([4, 4]);
157+ for (const f of this.refFreqs) {
158+ if (f >= this.fmax) continue;
159+ ctx.beginPath();
160+ ctx.moveTo(0, py(f));
161+ ctx.lineTo(this.width, py(f));
162+ ctx.stroke();
163+ }
164+ ctx.setLineDash([]);
165+ ctx.font = "12px ui-monospace, monospace";
166+ label(ctx, `${this.fmax} Hz`, 2, 2);
167+ label(ctx, "0", 2, HEIGHT - 18);
168+ }
169+
170+ private df(): number {
171+ return 1 / (2 * (this.bins - 1) * this.dt); // bin spacing (Hz)
172+ }
173+
174+ // Paint pixel column x with the stored column `age` columns old (light gray
175+ // if there is none).
176+ private renderColumn(x: number, age: number): void {
177+ const data = this.image.data;
178+ const { bins } = this;
179+ const df = this.df();
180+ const peakDb = 10 * Math.log10(this.peak + 1e-30);
181+ const off = age < this.nCols ? ((this.headCol - 1 - age + 2 * this.width) % this.width) * bins : -1;
182+ for (let y = 0; y < HEIGHT; y++) {
183+ const p = 4 * (y * this.width + x);
184+ if (off < 0) {
185+ data[p] = data[p + 1] = data[p + 2] = 246; // no data yet
186+ } else {
187+ const f = (this.fmax * (HEIGHT - 1 - y)) / (HEIGHT - 1);
188+ const k = Math.min(bins - 1, Math.round(f / df));
189+ const db = 10 * Math.log10(this.cols[off + k] + 1e-30);
190+ colormap((db - (peakDb - RANGE_DB)) / RANGE_DB, data, p);
191+ }
192+ data[p + 3] = 255;
193+ }
194+ }
195+}
196+
197+// White text on a dark box, readable over any part of the image.
198+function label(ctx: CanvasRenderingContext2D, text: string, x: number, y: number): void {
199+ ctx.fillStyle = "rgba(0,0,0,0.6)";
200+ ctx.fillRect(x, y, ctx.measureText(text).width + 6, 16);
201+ ctx.fillStyle = "#fff";
202+ ctx.textBaseline = "top";
203+ ctx.fillText(text, x + 3, y + 2);
204+}
205+
206+// Smallest 1, 2 or 5 times a power of ten that is >= x.
207+function niceCeil(x: number): number {
208+ const p = Math.pow(10, Math.floor(Math.log10(x)));
209+ for (const m of [1, 2, 5, 10]) if (m * p >= x) return +(m * p).toPrecision(1);
210+ return 10 * p;
211+}
212+
213+// Inferno-like colormap, u in [0, 1] (clamped).
214+const STOPS = [
215+ [0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99],
216+ [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164],
217+];
218+function colormap(u: number, out: Uint8ClampedArray, p: number): void {
219+ const s = Math.max(0, Math.min(1, u)) * (STOPS.length - 1);
220+ const i = Math.min(STOPS.length - 2, Math.floor(s)), w = s - i;
221+ for (let ch = 0; ch < 3; ch++) out[p + ch] = STOPS[i][ch] * (1 - w) + STOPS[i + 1][ch] * w;
222+}
223+
224+// In-place radix-2 complex FFT of a fixed power-of-two length, with
225+// precomputed twiddle factors.
226+class FFT {
227+ private n: number;
228+ private cos: Float64Array;
229+ private sin: Float64Array;
230+
231+ constructor(n: number) {
232+ this.n = n;
233+ this.cos = new Float64Array(n / 2);
234+ this.sin = new Float64Array(n / 2);
235+ for (let k = 0; k < n / 2; k++) {
236+ this.cos[k] = Math.cos((-2 * Math.PI * k) / n);
237+ this.sin[k] = Math.sin((-2 * Math.PI * k) / n);
238+ }
239+ }
240+
241+ transform(re: Float64Array, im: Float64Array): void {
242+ const n = this.n;
243+ for (let i = 1, j = 0; i < n; i++) {
244+ let bit = n >> 1;
245+ for (; j & bit; bit >>= 1) j ^= bit;
246+ j ^= bit;
247+ if (i < j) {
248+ let tmp = re[i]; re[i] = re[j]; re[j] = tmp;
249+ tmp = im[i]; im[i] = im[j]; im[j] = tmp;
250+ }
251+ }
252+ for (let len = 2; len <= n; len <<= 1) {
253+ const half = len >> 1, step = n / len;
254+ for (let i = 0; i < n; i += len) {
255+ for (let k = 0; k < half; k++) {
256+ const wr = this.cos[k * step], wi = this.sin[k * step];
257+ const a = i + k, b = a + half;
258+ const xr = re[b] * wr - im[b] * wi, xi = re[b] * wi + im[b] * wr;
259+ re[b] = re[a] - xr;
260+ im[b] = im[a] - xi;
261+ re[a] += xr;
262+ im[a] += xi;
263+ }
264+ }
265+ }
266+ }
267+}
src/traces.tsadded+211−0View file
@@ -0,0 +1,211 @@
1+// Live scrolling time traces. Quantities are arranged in groups; each group is
2+// one chart (shared y axis, one line per series) shown or hidden by a
3+// toggle chip. The y range fits the samples in the visible window (linear or
4+// log scale). Each pixel column draws only the min and max of its samples, so
5+// cost does not grow with the sample rate and short spikes are kept.
6+// Non-finite values (NaN) leave a gap.
7+
8+export interface Series {
9+ label: string;
10+ value: () => number;
11+ period?: number; // for wrapped quantities: break the line at wraps
12+}
13+
14+export interface Group {
15+ label: string; // toggle text
16+ unit?: string; // shown after the legend
17+ series: Series[];
18+ shown?: boolean; // initially shown
19+ window?: number; // visible time span for this chart (default: the panel's)
20+ log?: boolean; // log10 y axis (non-positive values leave a gap)
21+}
22+
23+const CAPACITY = 16384; // samples kept per series (ring buffer)
24+const WIDTH = 480, HEIGHT = 92; // CSS pixels; drawn at devicePixelRatio
25+const TOP = 18; // legend row height; the plot is below it
26+const COLORS = ["#2f5d8a", "#c8742c", "#4a8c5c", "#a8476e"];
27+const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
28+const MUTED = "#999";
29+
30+export class TracePanel {
31+ private groups: Group[];
32+ private window: number; // visible time span
33+ private t = new Float64Array(CAPACITY);
34+ private v: Float64Array[][]; // [group][series]
35+ private count = 0;
36+ private head = 0; // next write index
37+ private canvases: HTMLCanvasElement[];
38+ private dpr = window.devicePixelRatio || 1;
39+ // Per draw: buffer index and pixel column of each visible sample, oldest first.
40+ private idx = new Int32Array(CAPACITY);
41+ private col = new Int32Array(CAPACITY);
42+
43+ // Toggle chips go into `toggles`, charts into `charts`.
44+ constructor(toggles: HTMLElement, charts: HTMLElement, groups: Group[], window: number) {
45+ this.groups = groups;
46+ this.window = window;
47+ this.v = groups.map((g) => g.series.map(() => new Float64Array(CAPACITY)));
48+ this.canvases = groups.map((g) => {
49+ const canvas = document.createElement("canvas");
50+ canvas.width = WIDTH * this.dpr;
51+ canvas.height = HEIGHT * this.dpr;
52+ canvas.hidden = !g.shown;
53+ const box = document.createElement("input");
54+ box.type = "checkbox";
55+ box.checked = !!g.shown;
56+ box.addEventListener("change", () => (canvas.hidden = !box.checked));
57+ const text = document.createElement("span");
58+ text.textContent = g.label;
59+ const chip = document.createElement("label");
60+ chip.className = "chip";
61+ chip.append(box, text);
62+ toggles.appendChild(chip);
63+ return canvas;
64+ });
65+ for (const c of this.canvases) charts.appendChild(c);
66+ }
67+
68+ anyShown(): boolean {
69+ return this.canvases.some((c) => !c.hidden);
70+ }
71+
72+ clear(): void {
73+ this.count = 0;
74+ this.head = 0;
75+ }
76+
77+ // Record the current value of every series at time t.
78+ sample(t: number): void {
79+ this.t[this.head] = t;
80+ this.groups.forEach((g, j) =>
81+ g.series.forEach((s, k) => (this.v[j][k][this.head] = s.value())),
82+ );
83+ this.head = (this.head + 1) % CAPACITY;
84+ this.count = Math.min(this.count + 1, CAPACITY);
85+ }
86+
87+ // Indices and pixel columns of the samples in the last `win` seconds.
88+ private layout(win: number): number {
89+ const newest = (this.head - 1 + CAPACITY) % CAPACITY;
90+ const tStart = this.t[newest] - win;
91+ let n = 0;
92+ while (n < this.count && this.t[(newest - n + CAPACITY) % CAPACITY] >= tStart) n++;
93+ const first = (newest - n + 1 + CAPACITY) % CAPACITY;
94+ for (let m = 0; m < n; m++) {
95+ const i = first + m < CAPACITY ? first + m : first + m - CAPACITY;
96+ this.idx[m] = i;
97+ this.col[m] = Math.floor(((this.t[i] - tStart) / win) * WIDTH);
98+ }
99+ return n;
100+ }
101+
102+ draw(): void {
103+ if (this.count === 0) return;
104+ const newest = (this.head - 1 + CAPACITY) % CAPACITY;
105+ const { idx, col } = this;
106+ let laidOut = NaN, n = 0;
107+
108+ this.groups.forEach((g, j) => {
109+ const canvas = this.canvases[j];
110+ if (canvas.hidden) return;
111+ const win = g.window ?? this.window;
112+ if (win !== laidOut) n = this.layout((laidOut = win));
113+ const tv = g.log ? (x: number) => (x > 0 ? Math.log10(x) : NaN) : (x: number) => x;
114+ const ctx = canvas.getContext("2d")!;
115+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
116+ let lo = Infinity, hi = -Infinity;
117+ for (const vals of this.v[j]) {
118+ for (let m = 0; m < n; m++) {
119+ const x = tv(vals[idx[m]]);
120+ if (x < lo) lo = x;
121+ if (x > hi) hi = x;
122+ }
123+ }
124+ const empty = !(hi >= lo);
125+ if (empty) { lo = 0; hi = 1; }
126+ if (hi - lo <= 1e-300) { lo -= 1; hi += 1; } // constant trace
127+ const pad = 0.1 * (hi - lo);
128+ const yMin = lo - pad, yMax = hi + pad;
129+ const py = (x: number) => HEIGHT - ((x - yMin) / (yMax - yMin)) * (HEIGHT - TOP);
130+
131+ ctx.clearRect(0, 0, WIDTH, HEIGHT);
132+ ctx.lineWidth = 1.25;
133+ ctx.lineJoin = "round";
134+ g.series.forEach((s, k) => {
135+ const vals = this.v[j][k];
136+ const jump = s.period === undefined ? Infinity : s.period / 2;
137+ ctx.strokeStyle = COLORS[k % COLORS.length];
138+ ctx.beginPath();
139+ // Current pixel column: its min and max, and whether the min came first.
140+ let cur = -1, cLo = 0, cHi = 0, loFirst = true, penDown = false;
141+ const emit = (v: number) => {
142+ if (penDown) ctx.lineTo(cur, py(v));
143+ else ctx.moveTo(cur, py(v));
144+ penDown = true;
145+ };
146+ const flush = () => {
147+ if (cur < 0) return;
148+ emit(loFirst ? cLo : cHi);
149+ if (cHi !== cLo) emit(loFirst ? cHi : cLo);
150+ };
151+ const liftPen = () => {
152+ flush();
153+ penDown = false;
154+ cur = -1;
155+ };
156+ let prev = NaN;
157+ for (let m = 0; m < n; m++) {
158+ const v = tv(vals[idx[m]]);
159+ if (!Number.isFinite(v)) {
160+ liftPen();
161+ prev = NaN;
162+ continue;
163+ }
164+ if (Math.abs(v - prev) > jump) liftPen(); // wrap
165+ prev = v;
166+ if (col[m] !== cur) {
167+ flush();
168+ cur = col[m];
169+ cLo = cHi = v;
170+ loFirst = true;
171+ } else if (v < cLo) {
172+ cLo = v;
173+ loFirst = false;
174+ } else if (v > cHi) {
175+ cHi = v;
176+ loFirst = true;
177+ }
178+ }
179+ flush();
180+ ctx.stroke();
181+ });
182+
183+ ctx.font = FONT;
184+ ctx.textBaseline = "top";
185+ let x = 4;
186+ g.series.forEach((s, k) => {
187+ const v = this.v[j][k][newest];
188+ const text = `${s.label} ${Number.isFinite(v) ? fmt(v) : "–"}`;
189+ ctx.fillStyle = COLORS[k % COLORS.length];
190+ ctx.fillText(text, x, 3);
191+ x += ctx.measureText(text).width + 12;
192+ });
193+ if (g.unit) {
194+ ctx.fillStyle = MUTED;
195+ ctx.fillText(g.unit, x, 3);
196+ }
197+ if (!empty) {
198+ const show = g.log ? (y: number) => fmt(10 ** y) : fmt;
199+ ctx.fillStyle = MUTED;
200+ ctx.textAlign = "right";
201+ ctx.fillText(`${show(lo)} … ${show(hi)}${g.log ? " (log)" : ""}`, WIDTH - 4, 3);
202+ ctx.textAlign = "left";
203+ }
204+ });
205+ }
206+}
207+
208+function fmt(x: number): string {
209+ const a = Math.abs(x);
210+ return a !== 0 && (a < 1e-3 || a >= 1e5) ? x.toExponential(3) : x.toFixed(4);
211+}
src/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
tsconfig.jsonadded+12−0View file
@@ -0,0 +1,12 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "lib": ["ES2022", "DOM"],
7+ "strict": true,
8+ "noEmit": true,
9+ "skipLibCheck": true
10+ },
11+ "include": ["src"]
12+}
↑↓ move↵openescclose