Strike energy label and current energy; tidier section controls
4 changed files+40−19
src/double-pendulum/README.mdmodified+2−2View file
@@ -2,7 +2,7 @@
22
33 Interactive double pendulum, step 1 of "Listening for chaos". Work in progress.
44
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), plus a random relative jitter of up to 1e-9 in each angle and momentum (so chaotic strikes differ from one another while regular ones sound alike); Pause (or the space bar) pauses everything: the animation and the sound (the audio clock is suspended, so the note and every display fed by it stop where they are); Resume or Strike continues. 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. The plots card is labelled "Real time" and the Sound panel's header says how much faster the sound runs (e.g. the whole decay, 262x faster: 62 min of motion in 14 s), since the two run on different clocks; the Follow options read "pendulum (real time)" and "sound (sped up)". 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 Poincare section panel (closed by default) has its own energy slider and uses the same masses and lengths, without damping: each time the inner arm swings down through vertical moving right, the outer arm's angle and momentum are plotted. 42 orbits start on a grid inside the allowed region (outlined) and leave 300 dots each, computed in a Web Worker and drawn as they arrive; regular orbits trace closed curves, chaotic ones scatter. Hover to pick out an orbit (with its Lyapunov exponent), click inside the outline to start a new orbit there (with "Click to strike" on, the default, this also strikes the pendulum above from that exact state, at the section's energy, without jitter). The animated pendulum's own crossings are drawn as dark rings (the latest larger), so you can see which orbit it is on; only those within 5% of the section's energy are drawn (others would fall outside the allowed region), and they clear on each strike. Follow stays selected; setting the section's energy by hand pauses everything, clears the recent crossings (rings and note trail) and shows the section at that energy, and Follow takes over again on resume. Follow: "pendulum" makes the section's energy track the pendulum's as it decays (recomputed after a 3% change, at most every 2 s); "sound" shows the playing note's crossings (sent by the audio worker with the samples, drawn as the audio clock reaches them) as a fading trail of the last 150, with the axes fitted to the note's current energy (zooming as it decays) and nothing computed live. Without an atlas only the outline is drawn behind the trail; with one, the atlas section nearest the current energy, fitted to the current allowed region (each point scaled by the ratio of the regions' extents in th2 and p2: exact at low energy, where sections are scaled copies, an approximation over the <~15% gaps between atlas energies). Trail dots are fitted the same way from their own energies. "Precompute" builds the atlas (`src/atlas.ts`): sections at 24 energies log-spaced from 0.4% of to twice the both-inverted energy, 42 orbits x 150 crossings each, highest energy first, in its own worker (~80 s in the desktop app's browser pane), stored in IndexedDB per parameter set and deleted after 7 days. "Color by λ" colors orbits by Lyapunov exponent (log scale, blue regular to red chaotic). The Sound panel (above the section) starts closed; opening it switches the sound engine on (closing it switches it off) without playing anything. While it is open, 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. The mute button (speaker icon) starts muted: notes are still computed and shown, just silent. The panel also has "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 optional whole-note displays that do not scroll (strike at the left edge, end of the note at the right): a spectrogram of the sound, the largest Lyapunov exponent of the motion being heard (10 s window, physical time), its energy (from 0), and a scatter of lambda against energy accumulated over notes (colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Hovering over any chart (animation or sound) shows a time cursor and the values there; a click pins it. Charts in the same card share the cursor. The time axis spans the predicted note length ((tau/2) ln 1e6 of physical time, divided by the speedup), is extended if the note runs longer, and is fitted to the note when it ends.
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), plus a random relative jitter of up to 1e-9 in each angle and momentum (so chaotic strikes differ from one another while regular ones sound alike); Pause (or the space bar) pauses everything: the animation and the sound (the audio clock is suspended, so the note and every display fed by it stop where they are); Resume or Strike continues. 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. The plots card is labelled "Real time" and the Sound panel's header says how much faster the sound runs (e.g. the whole decay, 262x faster: 62 min of motion in 14 s), since the two run on different clocks; the Follow options read "pendulum (real time)" and "sound (sped up)". 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 Poincare section panel (closed by default) has its own energy slider and uses the same masses and lengths, without damping: each time the inner arm swings down through vertical moving right, the outer arm's angle and momentum are plotted. 42 orbits start on a grid inside the allowed region (outlined) and leave 300 dots each, computed in a Web Worker and drawn as they arrive; regular orbits trace closed curves, chaotic ones scatter. Hover to pick out an orbit (with its Lyapunov exponent), click inside the outline to start a new orbit there (with "Click to strike" on, the default, this also strikes the pendulum above from that exact state, at the section's energy, without jitter). The animated pendulum's own crossings are drawn as dark rings (the latest larger), so you can see which orbit it is on; only those within 5% of the section's energy are drawn (others would fall outside the allowed region), and they clear on each strike. Follow stays selected; setting the section's energy by hand pauses everything, clears the recent crossings (rings and note trail) and shows the section at that energy, and Follow takes over again on resume. Follow: "pendulum" makes the section's energy track the pendulum's as it decays (recomputed after a 3% change, at most every 2 s); "sound" shows the playing note's crossings (sent by the audio worker with the samples, drawn as the audio clock reaches them) as a fading trail of the last 150, with the axes fitted to the note's current energy (zooming as it decays) and nothing computed live. Without an atlas only the outline is drawn behind the trail; with one, the atlas section nearest the current energy, fitted to the current allowed region (each point scaled by the ratio of the regions' extents in th2 and p2: exact at low energy, where sections are scaled copies, an approximation over the <~15% gaps between atlas energies). Trail dots are fitted the same way from their own energies. "Precompute sections" (shown only with Follow "sound", until the atlas is complete) builds the atlas (`src/atlas.ts`): sections at 24 energies log-spaced from 0.4% of to twice the both-inverted energy, 42 orbits x 150 crossings each, highest energy first, in its own worker (~80 s in the desktop app's browser pane), stored in IndexedDB per parameter set and deleted after 7 days. "Color by λ" colors orbits by Lyapunov exponent (log scale, blue regular to red chaotic). The Sound panel (above the section) starts closed; opening it switches the sound engine on (closing it switches it off) without playing anything. While it is open, 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. The mute button (speaker icon) starts muted: notes are still computed and shown, just silent. The panel also has "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 optional whole-note displays that do not scroll (strike at the left edge, end of the note at the right): a spectrogram of the sound, the largest Lyapunov exponent of the motion being heard (10 s window, physical time), its energy (from 0), and a scatter of lambda against energy accumulated over notes (colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Hovering over any chart (animation or sound) shows a time cursor and the values there; a click pins it. Charts in the same card share the cursor. The time axis spans the predicted note length ((tau/2) ln 1e6 of physical time, divided by the speedup), is extended if the note runs longer, and is fitted to the note when it ends.
66
77 Live site: https://double-pendulum--listening-for-chaos.vault1-sites.magland.org/ (built and deployed by `.github/workflows/pages.yml` on every push to main).
88
@@ -16,7 +16,7 @@ npm run dev
1616 - Point masses on massless rods, absolute angles from the downward vertical, canonical momenta.
1717 - 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.
1818 - Energy is zero at hanging rest.
19-- 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.
19+- 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 (labelled "Strike energy") sets the strike energy and the current energy is shown at the top left of the animation; with tau = 0 it is labelled "Energy" and sets the energy live. The section's controls are in two rows (energy and Follow; Color by λ and Click to strike), and "computing…" shows in the plot's top-right legend.
2020 - 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.
2121 - 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.
2222 - Poincare section (`src/poincare-core.ts`): on the section th1 = 0, a point (th2, p2) is reachable at energy E exactly when p2^2 <= 2 m2 l2^2 (E - V(th2)), V(th2) = m2 g l2 (1 - cos th2); p1 is the root with the inner arm moving right. Crossings are located by linear interpolation within a step. The step is 4x the animation's: over 300 crossings the energy error stays below ~2e-5, the regular/chaotic classification is unchanged, and regular orbits move by ~1e-4. Each orbit's exponent is the shadow method's mean over its run (regular orbits ~0.01/s or less).
src/double-pendulum/index.htmlmodified+10−7View file
@@ -61,8 +61,9 @@
6161 button.mute.audible { background: var(--accent-soft); border-color: #b9cde0; color: var(--accent); }
6262
6363 #controls { margin-top: 14px; }
64- #energy { min-width: 70px; }
65- #energy-value { min-width: 5.5em; text-align: right; }
64+ #controls { gap: 8px; }
65+ #energy { min-width: 40px; }
66+ #energy-value { min-width: 4.8em; text-align: right; }
6667 #ghost-row { margin-top: 10px; min-height: 28px; }
6768 #ghost-controls { flex: 1; }
6869 #ghost-offset { max-width: 140px; min-width: 80px; }
@@ -78,6 +79,7 @@
7879 details.panel summary .label { font-size: 12px; color: var(--text); }
7980 details.panel[open] summary { margin-bottom: 14px; }
8081 #pc-energy { max-width: 360px; }
82+ #poincare .row + .row { margin-top: 10px; }
8183 #param-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px 16px; margin-top: 10px; }
8284 #param-form label { display: grid; grid-template-columns: 1.6em 5.5em auto; align-items: center; gap: 6px; font-size: 13px; }
8385 #param-form .unit { color: var(--muted); font-size: 12px; }
@@ -119,7 +121,7 @@
119121 <section class="card">
120122 <canvas id="canvas"></canvas>
121123 <form id="controls" class="row">
122- <span class="label">Energy</span>
124+ <span id="energy-label" class="label">Strike energy</span>
123125 <input id="energy" type="range" value="50" min="0" max="120" step="0.1" aria-label="Energy" />
124126 <span id="energy-value" class="num">50.0 J</span>
125127 <button type="submit" class="primary" title="Start from hanging rest with this energy">Strike</button>
@@ -205,15 +207,16 @@
205207 <span class="label">Energy</span>
206208 <input id="pc-energy" type="range" value="20" min="0" max="120" step="0.1" aria-label="Section energy" />
207209 <span id="pc-energy-value" class="num">20.0 J</span>
208- <label class="chip"><input id="pc-lambda" type="checkbox" /><span>Color by λ</span></label>
209- <label class="chip" title="Clicking a point also strikes the pendulum from that exact state, at this energy"><input id="pc-strike" type="checkbox" checked /><span>Click to strike</span></label>
210210 <label title="Pendulum: the section's energy follows the pendulum's as it decays. Sound: the playing note's crossings are shown, on the section at the note's strike energy."><span class="label">Follow</span> <select id="pc-follow">
211211 <option value="off">off</option>
212212 <option value="pendulum">pendulum (real time)</option>
213213 <option value="sound">sound (sped up)</option>
214214 </select></label>
215- <button id="pc-atlas" type="button" title="Compute sections at 24 energies (about a minute or two), so Follow sound can show the section matching the decaying note. Kept in this browser for 7 days.">Precompute</button>
216- <span id="pc-status" class="muted"></span>
215+ <button id="pc-atlas" type="button" title="Compute sections at 24 energies (about a minute or two), so Follow sound can show the orbits behind the decaying note's trail. Kept in this browser for 7 days." hidden>Precompute sections</button>
216+ </div>
217+ <div class="row">
218+ <label class="chip"><input id="pc-lambda" type="checkbox" /><span>Color by λ</span></label>
219+ <label class="chip" title="Clicking a point also strikes the pendulum from that exact state, at this energy"><input id="pc-strike" type="checkbox" checked /><span>Click to strike</span></label>
217220 </div>
218221 <figure>
219222 <div id="pc-plot"></div>
src/double-pendulum/main.tsmodified+14−1View file
@@ -26,6 +26,7 @@ const canvas = document.getElementById("canvas") as HTMLCanvasElement;
2626 const ctx = canvas.getContext("2d")!;
2727 const energyInput = document.getElementById("energy") as HTMLInputElement;
2828 const energyValue = document.getElementById("energy-value")!;
29+const energyLabel = document.getElementById("energy-label")!;
2930
3031 // The animated pendulum, in real time.
3132 // It hangs at rest until the first Strike; Pause freezes it.
@@ -375,8 +376,11 @@ signalSelect.addEventListener("change", () => {
375376 volumeInput.addEventListener("input", () => audio.setVolume(Number(volumeInput.value)));
376377
377378 // Without damping the slider sets the energy live; with damping it sets the
378-// energy of the next strike.
379+// energy of the next strike (and the current energy is shown on the canvas).
379380 const sliderEnergy = () => Number(energyInput.value);
381+function showEnergyLabel() {
382+ energyLabel.textContent = damped() ? "Strike energy" : "Energy";
383+}
380384 energyInput.addEventListener("input", () => {
381385 if (damped()) return;
382386 sim.target = sliderEnergy();
@@ -526,6 +530,7 @@ function applyParams() {
526530 spectrogram = makeSpectrogram();
527531 traces.clear();
528532 showPitch();
533+ showEnergyLabel();
529534 audioSpec.setRefFreqs(audioModeHz());
530535 audio.setParams(P);
531536 audio.stopNote();
@@ -605,6 +610,14 @@ function draw() {
605610 ctx.beginPath();
606611 ctx.arc(x0, y0, 3 * dpr, 0, 2 * Math.PI);
607612 ctx.fill();
613+ // Current energy (with damping it falls below the strike energy).
614+ const E = energy(P, sim.y);
615+ if (damped() && E > 0) {
616+ ctx.font = `${12 * dpr}px ui-monospace, SFMono-Regular, Menlo, monospace`;
617+ ctx.fillStyle = "#7a7d82";
618+ ctx.textBaseline = "top";
619+ ctx.fillText(`E ${E >= 10 ? E.toFixed(1) : E.toPrecision(2)} J`, 4 * dpr, 4 * dpr);
620+ }
608621 showEnergy();
609622 const sep = ghost
610623 ? `separation ${ghostSeparation().toExponential(1)} rad · ${(sim.t - ghostT0).toFixed(1)} s`
src/double-pendulum/poincare.tsmodified+14−9View file
@@ -9,7 +9,8 @@
99 // track the pendulum's as it decays.
1010 // Follow "Sound" shows the playing note's crossings instead, as a short
1111 // fading trail, with the axes fitted to the note's current energy and, if an
12-// atlas has been computed ("Precompute"), the atlas section nearest that
12+// atlas has been computed ("Precompute sections", shown only with Follow
13+// "Sound"), the atlas section nearest that
1314 // energy behind it (see showSound).
1415
1516 import { topEnergy, type Params } from "./pendulum";
@@ -56,7 +57,6 @@ export class PoincarePanel {
5657 private open = false;
5758 private energyInput: HTMLInputElement;
5859 private energyValue: HTMLElement;
59- private status: HTMLElement;
6060 private debounce = 0;
6161 private strikeBox: HTMLInputElement;
6262 private followSelect: HTMLSelectElement;
@@ -85,13 +85,13 @@ export class PoincarePanel {
8585 this.followSelect.addEventListener("change", () => {
8686 // Leaving the sound view: back to the section at the slider's energy.
8787 if (this.followMode() !== "sound" && this.soundView && this.open) this.run();
88+ this.showAtlasButton();
8889 this.changed = true;
8990 });
9091 this.atlasButton = document.getElementById("pc-atlas") as HTMLButtonElement;
9192 this.atlasButton.addEventListener("click", () => this.computeAtlas());
9293 this.energyInput = document.getElementById("pc-energy") as HTMLInputElement;
9394 this.energyValue = document.getElementById("pc-energy-value")!;
94- this.status = document.getElementById("pc-status")!;
9595 this.E = Number(this.energyInput.value);
9696 this.canvas = document.createElement("canvas");
9797 this.canvas.width = WIDTH * this.dpr;
@@ -185,11 +185,15 @@ export class PoincarePanel {
185185 if (this.soundView) this.showSound(this.soundE, true);
186186 }
187187
188+ // The atlas only matters for Follow "Sound": the button is shown there
189+ // until the atlas is complete.
188190 private showAtlasButton(): void {
189191 const n = this.atlasEntries.length;
190- this.atlasButton.disabled = this.atlasComputing || n === ATLAS_SIZE;
191- this.atlasButton.textContent =
192- n === ATLAS_SIZE ? "Atlas ready" : this.atlasComputing ? `Atlas ${n}/${ATLAS_SIZE}` : n ? `Precompute (${n}/${ATLAS_SIZE})` : "Precompute";
192+ this.atlasButton.hidden = this.followMode() !== "sound" || n === ATLAS_SIZE;
193+ this.atlasButton.disabled = this.atlasComputing;
194+ this.atlasButton.textContent = this.atlasComputing
195+ ? `Computing sections ${n}/${ATLAS_SIZE}`
196+ : n ? `Precompute sections (${n}/${ATLAS_SIZE})` : "Precompute sections";
193197 }
194198
195199 private computeAtlas(): void {
@@ -487,9 +491,10 @@ export class PoincarePanel {
487491 ctx.fillText(legend, 4, 3);
488492 ctx.fillStyle = "#999";
489493 ctx.textAlign = "right";
490- ctx.fillText(`θ2 ±${this.thMax.toFixed(2)} · p2 ±${this.pMax.toFixed(2)}`, WIDTH - 4, 3);
494+ ctx.fillText(
495+ `${this.finished || this.soundView ? "" : "computing… · "}θ2 ±${this.thMax.toFixed(2)} · p2 ±${this.pMax.toFixed(2)}`,
496+ WIDTH - 4, 3,
497+ );
491498 ctx.textAlign = "left";
492-
493- this.status.textContent = this.finished ? "" : "computing…";
494499 }
495500 }