/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Beam step 4: ghost and Separation chart; ghost controls shared in src/common/ghost.ts, used by all three apps
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 8776c9382164 parent 407a5c8 Browse files
9 changed files+139−79
README.mdmodified+1−1View file
@@ -5,7 +5,7 @@ Interactive apps for "Listening for chaos": struck dynamical systems heard as so
55 - `src/double-pendulum/`: double pendulum ([README](src/double-pendulum/README.md)), served at `/double-pendulum/`.
66 - `src/fput-chain/`: FPUT-beta chain ([README](src/fput-chain/README.md)), served at `/fput-chain/`.
77 - `src/beam/`: beam with bending stiffness ([README](src/beam/README.md)), served at `/beam/`.
8-- `src/common/`: code shared by the apps (help dialogs, chart cursor, WAV export, note player, note render loop, mode bars and mode-energy heat maps, index colors, mute button, number formats, canvas sizing, λ vs E scatter, strip charts, whole-note spectrogram, Lyapunov exponent by the two-trajectory method with a per-app `Shadow`; Poincare section orbits, their worker, the precomputed atlas and the section panel, with a per-app `Section` and panel configuration) and `style.css`, the shared styles. Each app's `main.ts` imports `../common/style.css` and then its own `style.css`, so app rules come after the shared ones in dev and in the build.
8+- `src/common/`: code shared by the apps (help dialogs, chart cursor, WAV export, note player, ghost controls, note render loop, mode bars and mode-energy heat maps, index colors, mute button, number formats, canvas sizing, λ vs E scatter, strip charts, whole-note spectrogram, Lyapunov exponent by the two-trajectory method with a per-app `Shadow`; Poincare section orbits, their worker, the precomputed atlas and the section panel, with a per-app `Section` and panel configuration) and `style.css`, the shared styles. Each app's `main.ts` imports `../common/style.css` and then its own `style.css`, so app rules come after the shared ones in dev and in the build.
99 - `src/index.html`: landing page linking the apps.
1010
1111 Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/ (built and deployed by `.github/workflows/pages.yml` on every push to main).
src/beam/README.mdmodified+3−3View file
@@ -1,8 +1,8 @@
11 # beam
22
3-Struck beam with bending stiffness, step 3 of "Listening for chaos" (after double-pendulum and fput-chain). Work in progress: step 1 (physics, animation, sped-up note with spectrogram), step 2 (Lyapunov exponent), step 3 (mode bars and mode histories).
3+Struck beam with bending stiffness, step 3 of "Listening for chaos" (after double-pendulum and fput-chain). Work in progress: step 1 (physics, animation, sped-up note with spectrogram), step 2 (Lyapunov exponent), step 3 (mode bars and mode histories), step 4 (ghost).
44
5-Current state: N masses (2 to 64, default 8) on vertical rails, moving sideways only, with tension k, bending κ and a stretching nonlinearity k3 (below). Ends (a select next to Masses; changing it returns the beam to rest): clamped–clamped (default), pinned–pinned, or cantilever (clamped at the left, free at the right). Strike gives energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, on one mass alone (default mass 2) or in the shape of one normal mode. The card draws the beam from the side, displacements scaled to fit (scale given), with rails as faint lines, a hatched wall for a clamp and a triangle for a pin, plus the time, energy and relative energy error. A "Real time" card on the right has chips: Mode bars (on; the current harmonic energy of each mode relative to the strike, log scale over 12 decades, with the number of modes sharing the energy, 1 / sum (E_n / sum E)^2, and the mode frequency range; `../common/modebars.ts`), Mode history (off; each mode's share of the harmonic energy over the last 300 s as a heat map, mode 1 at the bottom; `LiveModeHistory` in `../common/modehistory.ts`), and charts (`../common/traces.ts`): Lyapunov (on; the largest Lyapunov exponent over the last 10 s and since the strike, last 60 s shown, the first 5 s after a strike left out of the y range) Mode energies (off; log, last 60 s, values below 1e-12 of the strike left out) and Energy error (off). The exponent is the two-trajectory method of `../common/lyapunov.ts` with the beam's shadow in `lyapunov.ts`: a hidden second beam 1e-8 away, displacements in units of A / sqrt(N) and momenta in units of m omega_1 A / sqrt(N), A the mode-1 amplitude at the nonlinear scale. Speed 0.1x to 20x real time; Pause or space. Parameters (m, k, κ, k3, a, τ, stretching form) fold out with derived values (mode frequencies, ratios to mode 1, the nonlinear scale, integrator step); Apply returns the beam to rest.
5+Current state: N masses (2 to 64, default 8) on vertical rails, moving sideways only, with tension k, bending κ and a stretching nonlinearity k3 (below). Ends (a select next to Masses; changing it returns the beam to rest): clamped–clamped (default), pinned–pinned, or cantilever (clamped at the left, free at the right). Strike gives energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, on one mass alone (default mass 2) or in the shape of one normal mode. The card draws the beam from the side, displacements scaled to fit (scale given), with rails as faint lines, a hatched wall for a clamp and a triangle for a pin, plus the time, energy and relative energy error. The Ghost chip (as in fput-chain; controls in `../common/ghost.ts`) adds a second beam started from the same state with every mass moved by 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost", when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started. Default strike: the separation stays near 1e-10 m for ~10 s, then grows to 1.7e-8 m by 39.5 s, e^5.1, matching λ since the strike (0.13 /s × 39.5 s). A "Real time" card on the right has chips: Mode bars (on; the current harmonic energy of each mode relative to the strike, log scale over 12 decades, with the number of modes sharing the energy, 1 / sum (E_n / sum E)^2, and the mode frequency range; `../common/modebars.ts`), Mode history (off; each mode's share of the harmonic energy over the last 300 s as a heat map, mode 1 at the bottom; `LiveModeHistory` in `../common/modehistory.ts`), and charts (`../common/traces.ts`): Lyapunov (on; the largest Lyapunov exponent over the last 10 s and since the strike, last 60 s shown, the first 5 s after a strike left out of the y range) Mode energies (off; log, last 60 s, values below 1e-12 of the strike left out), Energy error (off) and Separation (log, last 60 s; offered only with the ghost on). The exponent is the two-trajectory method of `../common/lyapunov.ts` with the beam's shadow in `lyapunov.ts`: a hidden second beam 1e-8 away, displacements in units of A / sqrt(N) and momenta in units of m omega_1 A / sqrt(N), A the mode-1 amplitude at the nonlinear scale. Speed 0.1x to 20x real time; Pause or space. Parameters (m, k, κ, k3, a, τ, stretching form) fold out with derived values (mode frequencies, ratios to mode 1, the nonlinear scale, integrator step); Apply returns the beam to rest.
66
77 "Sped up · listen" (as in fput-chain): each Strike renders the same strike in a worker (`render-worker.ts`), sped up so the lowest mode sounds at the Pitch (5 to 1000 Hz, default 100), and plays it; the page starts muted. A damped note is the whole decay to -60 dB. A red warning shows when the top mode is above half the sample rate (it would alias). Signals (`signals.ts`): velocity (default), displacement or acceleration of mass j (default 2), or the sum of accelerations (the net force of the supports). Replay, Save WAV (named e.g. beam-clamped-local-N8-mass2-2.51J-v2-100Hz.wav), spectrogram over the whole note with dashed lines at the mode pitches. Chips: Spectrogram (on), Modes (off; the note's mode-energy shares over the whole note, one column per 256 samples, from the worker; `ModeHistory` in `../common/modehistory.ts`), Lyapunov (on; the note's exponent over the last 10 s of physical time, from a shadow run in the worker; the first 5 s of physical time left out of the y range; the shadow runs only while this chart is shown, as it doubles the cost) and Energy (log). They span the whole note and share one cursor (playhead while playing; hover, click to pin). The render loop is shared with fput-chain (`../common/note-render.ts`). The status line gives the render time (default note 1.0 to 1.4 s with the shadow, 0.6 s without).
88
@@ -21,4 +21,4 @@ Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/beam
2121 - Default note (clamped, local, 2.51 J on mass 2, τ 540 s, 100 Hz): λ ~0.27 /s at the strike (λ T1 ~1.3), falling into the noise of the 10 s window (±0.05 /s) by ~1.6 s of sound (E ~0.14 J), as the spectrogram resolves into lines. Kirchhoff at the same strike: ~0.04 /s, then noise. A 50 mJ strike: noise throughout. Cantilever: ~0.2 /s for the first ~0.3 s of its 1.3 s note. On Modes, the shares shuffle while λ is up and freeze into steady stripes as it falls (~2 s of sound); on Mode bars about 6 to 7 of the 8 modes share the energy early on.
2222 - First look (offline, N = 8, hammer on mass 2, no damping, two-trajectory Lyapunov exponent times the mode-1 period T1): local stretching is chaotic from ~1 J (clamped 0.57 at 1 J, 3.3 at 10 J; pinned 1.8 at 1 J; cantilever 1.2 at 0.1 J). Kirchhoff is much less chaotic at the same energy (clamped 0.04 at 1 J, 0.24 at 10 J; pinned ~0 up to 1 J, 0.09 at 10 J).
2323
24-Code: `beam.ts` (physics, modes, strikes), `main.ts` (page), `signals.ts`, `render-worker.ts`, `lyapunov.ts` (shadow); shared: `../common/note-render.ts`, `../common/modebars.ts`, `../common/modehistory.ts`, `../common/lyapunov.ts`, `../common/traces.ts`, `../common/cursor.ts`, `../common/sound.ts`, `../common/spectrogram.ts`, `../common/wav.ts`, `../common/mute.ts`, `../common/format.ts`, `../common/canvas.ts`, `../common/help.ts`.
24+Code: `beam.ts` (physics, modes, strikes), `main.ts` (page), `signals.ts`, `render-worker.ts`, `lyapunov.ts` (shadow); shared: `../common/note-render.ts`, `../common/modebars.ts`, `../common/modehistory.ts`, `../common/lyapunov.ts`, `../common/traces.ts`, `../common/cursor.ts`, `../common/sound.ts`, `../common/spectrogram.ts`, `../common/wav.ts`, `../common/mute.ts`, `../common/ghost.ts`, `../common/format.ts`, `../common/canvas.ts`, `../common/help.ts`.
src/beam/index.htmlmodified+12−2View file
@@ -49,6 +49,16 @@
4949 </select>
5050 </div>
5151 </div>
52+ <div id="ghost-row" class="row">
53+ <label class="chip" title="A second beam started with every mass moved by a tiny offset"><input id="ghost-toggle" type="checkbox" /><span>Ghost</span></label>
54+ <span id="ghost-controls" class="row" hidden>
55+ <span class="muted">Offset</span>
56+ <input id="ghost-offset" type="range" min="-12" max="-1" step="1" value="-10" aria-label="Ghost offset (power of ten, m)" />
57+ <span id="ghost-offset-value" class="num"></span>
58+ <button id="ghost-reset" type="button" title="Restart the ghost from the beam's current state">Restart ghost</button>
59+ </span>
60+ <span id="ghost-sep" class="num muted"></span>
61+ </div>
5262 <details>
5363 <summary>Parameters <button type="button" class="help" data-help="params" title="About the parameters" aria-label="About the parameters">?</button></summary>
5464 <div id="param-form">
@@ -119,7 +129,7 @@
119129 <template id="help-beam">
120130 <h2>The beam</h2>
121131 <p>Equal masses on vertical rails, moving sideways only, shown in real time (displacements scaled to fit). Neighbours are joined by springs (tension k), and each mass resists bending (κ, on how far it sits off the line through its neighbours). The springs also stiffen as they stretch (k3): this is the nonlinearity. Strike gives one mass, or one normal mode, the energy on the slider.</p>
122- <p>The ends are clamped (fixed, and held level), pinned (fixed, free to turn), or a cantilever: clamped at the left and free at the right. Bending makes the overtones inharmonic, as in a bar or a cymbal. Weak strikes ring as independent normal modes; strong ones trade energy between them. The space bar pauses.</p>
132+ <p>The ends are clamped (fixed, and held level), pinned (fixed, free to turn), or a cantilever: clamped at the left and free at the right. Bending makes the overtones inharmonic, as in a bar or a cymbal. Weak strikes ring as independent normal modes; strong ones trade energy between them. Ghost adds a second beam started a tiny distance away, drawn in orange: in chaotic motion the two separate exponentially. The space bar pauses.</p>
123133 </template>
124134 <template id="help-params">
125135 <h2>Parameters</h2>
@@ -129,7 +139,7 @@
129139 <template id="help-realtime">
130140 <h2>Real time</h2>
131141 <p>Charts of the beam on the left, on the same clock. Click a chip to show or hide one; hover to read values, click to pin the cursor.</p>
132- <p>Lyapunov is the rate (1/s) at which nearby motions separate, measured with a hidden second beam kept a tiny distance away: positive for chaos, near 0 for regular motion. Shown over the last 10 s and since the strike; the first 5 s are left out of the y range. Energy error should stay tiny.</p>
142+ <p>Lyapunov is the rate (1/s) at which nearby motions separate, measured with a hidden second beam kept a tiny distance away: positive for chaos, near 0 for regular motion. Shown over the last 10 s and since the strike; the first 5 s are left out of the y range. Energy error should stay tiny. Separation (with the ghost on) is the ghost's distance from the beam, on a log scale.</p>
133143 <p>Mode bars show the energy in each normal mode now, relative to the strike, with the number of modes sharing it; Mode energies show the same over time, and Mode history each mode's share as a heat map over the last 300 s (mode 1 at the bottom).</p>
134144 </template>
135145 <template id="help-sound">
src/beam/main.tsmodified+56−15View file
@@ -7,6 +7,7 @@ import {
77 import { fit, indexColor } from "../common/canvas";
88 import { newCursor } from "../common/cursor";
99 import { fmtDuration, fmtEnergy, fmtLength } from "../common/format";
10+import { GhostControls } from "../common/ghost";
1011 import { setupHelp } from "../common/help";
1112 import { Lyapunov } from "../common/lyapunov";
1213 import { drawModeBars } from "../common/modebars";
@@ -90,6 +91,7 @@ document.getElementById("strike")!.addEventListener("click", () => {
9091 showDerived();
9192 t = 0;
9293 ampl = 0;
94+ resetGhost();
9395 lyap.start(shadowOf(P, modes, s), t);
9496 modes.energies(s, Ek);
9597 traces.clear();
@@ -105,6 +107,34 @@ document.getElementById("strike")!.addEventListener("click", () => {
105107 // Energy balance: energy plus what drag has dissipated, against the strike's.
106108 const relErr = () => (E0 > 0 ? (energy(P, s) + s.W - E0) / E0 : 0);
107109
110+// The ghost: a second beam started from the same state with every mass moved
111+// by the same tiny amount (total offset delta, so each by delta / sqrt(N)),
112+// run in lockstep (same steps, no renormalization), as in the FPUT chain.
113+// Its separation from the beam grows exponentially while the motion is
114+// chaotic and stays small while it is regular. Restarted on Strike, on
115+// "Restart ghost" (from the beam's current state), when switched on and when
116+// the offset changes; none while the beam is at rest.
117+let ghost: { s: State; verlet: Verlet } | null = null;
118+let ghostT0 = 0; // time of the last ghost start
119+// Distance between the beam's and the ghost's displacements (m).
120+function ghostSeparation(): number {
121+ if (!ghost) return NaN;
122+ let d2 = 0;
123+ for (let j = 0; j < P.N; j++) d2 += (s.u[j] - ghost.s.u[j]) ** 2;
124+ return Math.sqrt(d2);
125+}
126+function resetGhost() {
127+ ghost = null;
128+ if (!ghostUI.on || energy(P, s) === 0) return;
129+ const g = copyState(s);
130+ const each = ghostUI.offset / Math.sqrt(P.N);
131+ for (let j = 0; j < P.N; j++) g.u[j] += each;
132+ ghost = { s: g, verlet: new Verlet(P, g) };
133+ ghostT0 = t;
134+}
135+// The Separation chart is offered only with the ghost on.
136+const ghostUI = new GhostControls("m", resetGhost, (on) => traces.setAvailable("Separation", on));
137+
108138 // Largest Lyapunov exponent of the animated beam, measured from each strike
109139 // (two-trajectory method, shadow in lyapunov.ts): over the last 10 s and
110140 // since the strike.
@@ -138,8 +168,10 @@ function buildTraces(shown: string[]) {
138168 })),
139169 },
140170 { label: "Energy error", series: [{ label: "energy balance error", value: relErr }] },
171+ { label: "Separation", unit: "m", log: true, window: 60, series: [{ label: "ghost", value: ghostSeparation }] },
141172 ];
142173 traces = new TracePanel(togglesEl, chartsEl, g.map((x) => ({ ...x, shown: shown.includes(x.label) })), 10);
174+ traces.setAvailable("Separation", ghostUI.on);
143175 }
144176
145177 // Mode bars (chip ahead of the trace charts): the current energy of each
@@ -377,6 +409,7 @@ function setParams(next: Params) {
377409 t = 0;
378410 ampl = 0;
379411 lyap.stop();
412+ ghost = null;
380413 Ek = new Float64Array(P.N);
381414 buildTraces(shown);
382415 liveModes.clear(P.N);
@@ -422,7 +455,9 @@ const MONO = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
422455 // The beam seen from the side: masses on their rails (faint vertical lines),
423456 // the beam through them and the supports, displacements scaled to fit (the
424457 // scale is given). Supports: a clamp is a hatched wall, a pin a triangle; a
425-// free end has none.
458+// free end has none. The ghost, if on, is drawn the same way in translucent
459+// orange, underneath.
460+const GHOST = "rgba(214, 120, 40, 0.45)";
426461 function drawBeam() {
427462 const ctx = fit(beamCanvas);
428463 const W = beamCanvas.clientWidth, Ht = beamCanvas.clientHeight;
@@ -433,7 +468,6 @@ function drawBeam() {
433468 const x = (i: number) => m + (i + 1) * dx; // node i: masses 0..N-1, supports -1 and N
434469 const top = 34, bottom = Ht - 14, y0 = (top + bottom) / 2, half = 0.45 * (bottom - top);
435470 const scale = ampl > 0 ? half / ampl : 0; // px per m
436- const y = (i: number) => (i >= 0 && i < N ? y0 - scale * s.u[i] : y0);
437471
438472 // Rails and the rest line.
439473 ctx.strokeStyle = LINE;
@@ -481,21 +515,26 @@ function drawBeam() {
481515 }
482516
483517 // The beam and its masses.
484- ctx.strokeStyle = TEXT;
485- ctx.lineWidth = 2.5;
486- ctx.lineJoin = "round";
487- ctx.beginPath();
488- ctx.moveTo(x(-1), y0);
489- for (let j = 0; j < N; j++) ctx.lineTo(x(j), y(j));
490- if (!free) ctx.lineTo(x(N), y0);
491- ctx.stroke();
492518 const r = Math.max(2.5, Math.min(6, dx / 5));
493- for (let j = 0; j < N; j++) {
494- ctx.fillStyle = indexColor(j, N);
519+ const drawState = (st: State, ghostly: boolean) => {
520+ const y = (j: number) => y0 - scale * st.u[j];
521+ ctx.strokeStyle = ghostly ? GHOST : TEXT;
522+ ctx.lineWidth = 2.5;
523+ ctx.lineJoin = "round";
495524 ctx.beginPath();
496- ctx.arc(x(j), y(j), r, 0, 2 * Math.PI);
497- ctx.fill();
498- }
525+ ctx.moveTo(x(-1), y0);
526+ for (let j = 0; j < N; j++) ctx.lineTo(x(j), y(j));
527+ if (!free) ctx.lineTo(x(N), y0);
528+ ctx.stroke();
529+ for (let j = 0; j < N; j++) {
530+ ctx.fillStyle = ghostly ? GHOST : indexColor(j, N);
531+ ctx.beginPath();
532+ ctx.arc(x(j), y(j), r, 0, 2 * Math.PI);
533+ ctx.fill();
534+ }
535+ };
536+ if (ghost) drawState(ghost.s, true);
537+ drawState(s, false);
499538
500539 // Readouts.
501540 ctx.fillStyle = TEXT;
@@ -522,6 +561,7 @@ function frame(now: number) {
522561 carry += Number(speedSelect.value) * dt;
523562 while (carry >= H) {
524563 verlet.step(s, H);
564+ ghost?.verlet.step(ghost.s, H);
525565 lyap.step(H, t + H);
526566 carry -= H;
527567 t += H;
@@ -538,6 +578,7 @@ function frame(now: number) {
538578 for (const u of s.u) umax = Math.max(umax, Math.abs(u));
539579 ampl = Math.max(umax, 0.998 * ampl);
540580 drawBeam();
581+ ghostUI.show(ghost ? ghostSeparation() : null, t - ghostT0);
541582 if (!barsFig.hidden) drawModes();
542583 traces.draw();
543584 if (!liveModesFig.hidden) liveModes.draw();
src/common/ghost.tsadded+52−0View file
@@ -0,0 +1,52 @@
1+// Ghost controls: a "Ghost" chip that shows an offset slider (powers of ten),
2+// a Restart button and a readout of the ghost's separation. The app owns the
3+// ghost itself (a second copy of its system started a tiny offset away and
4+// run in lockstep); these controls tell it when to restart and how far off.
5+// Elements: #ghost-toggle, #ghost-controls, #ghost-offset (log10 of the
6+// offset), #ghost-offset-value, #ghost-reset, #ghost-sep.
7+
8+const SUP: Record<string, string> = { "-": "⁻", 0: "⁰", 1: "¹", 2: "²", 3: "³", 4: "⁴", 5: "⁵", 6: "⁶", 7: "⁷", 8: "⁸", 9: "⁹" };
9+
10+export class GhostControls {
11+ private toggle = document.getElementById("ghost-toggle") as HTMLInputElement;
12+ private offsetInput = document.getElementById("ghost-offset") as HTMLInputElement;
13+ private offsetValue = document.getElementById("ghost-offset-value")!;
14+ private readout = document.getElementById("ghost-sep")!;
15+ private shown = "";
16+ private unit: string;
17+
18+ // restart: called when the ghost should start again (switched on or off,
19+ // offset changed, Restart). onToggle: also called when switched.
20+ constructor(unit: string, restart: () => void, onToggle?: (on: boolean) => void) {
21+ this.unit = unit;
22+ const controls = document.getElementById("ghost-controls")!;
23+ const showOffset = () =>
24+ (this.offsetValue.textContent = `10${[...this.offsetInput.value].map((c) => SUP[c]).join("")} ${unit}`);
25+ showOffset();
26+ this.toggle.addEventListener("change", () => {
27+ controls.hidden = !this.on;
28+ onToggle?.(this.on);
29+ restart();
30+ });
31+ this.offsetInput.addEventListener("input", () => {
32+ showOffset();
33+ restart();
34+ });
35+ document.getElementById("ghost-reset")!.addEventListener("click", restart);
36+ }
37+
38+ get on(): boolean {
39+ return this.toggle.checked;
40+ }
41+
42+ // The offset set on the slider.
43+ get offset(): number {
44+ return 10 ** Number(this.offsetInput.value);
45+ }
46+
47+ // Show the separation and the time since the ghost started (null: no ghost).
48+ show(separation: number | null, elapsed: number): void {
49+ const text = separation === null ? "" : `separation ${separation.toExponential(1)} ${this.unit} · ${elapsed.toFixed(1)} s`;
50+ if (text !== this.shown) this.readout.textContent = this.shown = text;
51+ }
52+}
src/double-pendulum/README.mdmodified+1−1View 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 "Restart ghost", 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; its chip shows only with the ghost on) and "Lyapunov" (largest Lyapunov exponent over a 10 s window and since the strike, last 30 s; the first 5 s after a strike, where the values are large, are left out of the y range, here and in the sound's Lyapunov chart). Chart legends show 4 significant digits, ranges 3. Parameters (masses, lengths, g, decay time, presets) fold out underneath. The plots card is labelled "Real time" and the "Sped up · listen" panel's header says how much faster its run is (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 "real time" and "sped up". Toggle chips choose the plots: live traces of energies, energy balance error, angles, momenta and bob positions. Each chart shows its time span at the bottom right ("last 10 s" when scrolling; the note length in the sound panel). The Poincare section panel (closed by default) has its own energy slider (log scale, from E_top / 1000 to 2 E_top, 59 mJ to 117.7 J at the defaults, where E_top is the both-inverted energy; default 17 J; on new parameters it keeps its position) 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; the legend then says "pendulum at 48 J, not shown"), 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: "real time" makes the section's energy track the pendulum's as it decays (recomputed after a 3% change, at most every 2 s); "sped up" (which opens the "Sped up · listen" panel) 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 "sped up", until the atlas is complete) builds the atlas (`../common/atlas.ts`, configured in `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 "Sped up · listen" panel (above the section) starts closed; opening it switches the sound engine on (closing it switches it off) without playing anything; a line of text says to Strike (or click the section) until the first note. 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. Save WAV saves the current or last note as heard (as received from the audio worker, up to 120 s; 16-bit mono PCM, peak at 0.9; `../common/wav.ts`; named e.g. double-pendulum-50.0J-x2-100Hz.wav). The mute button (speaker icon; when muted it is tinted red and reads "Muted") starts muted and unmutes the first time the panel is opened; muted notes are still computed and shown, just silent. The panel also has "Signal" (which coordinate is heard), volume, pitch (where the small-amplitude lower mode lands, 30 to 1000 Hz, default 100 Hz; the readout gives both modes' pitches), 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 (frequency axis up to just above the highest frequency that comes within 40 dB of the peak at any time in the note, so the broadband start of a chaotic note stays in view), the largest Lyapunov exponent of the motion being heard (10 s window, physical time), its energy (log scale, so the exponential decay is a straight line), and a scatter of lambda against energy accumulated over notes (log energy axis, since a note spends most of its time at low energy; colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Each card and panel (and Parameters) has a small "?" button that opens a short help dialog (`../common/help.ts`; the texts are `<template>`s in `index.html`). Hovering over any chart (real time or sped up) 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 (controls in `../common/ghost.ts`) 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 "Restart ghost", 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; its chip shows only with the ghost on) and "Lyapunov" (largest Lyapunov exponent over a 10 s window and since the strike, last 30 s; the first 5 s after a strike, where the values are large, are left out of the y range, here and in the sound's Lyapunov chart). Chart legends show 4 significant digits, ranges 3. Parameters (masses, lengths, g, decay time, presets) fold out underneath. The plots card is labelled "Real time" and the "Sped up · listen" panel's header says how much faster its run is (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 "real time" and "sped up". Toggle chips choose the plots: live traces of energies, energy balance error, angles, momenta and bob positions. Each chart shows its time span at the bottom right ("last 10 s" when scrolling; the note length in the sound panel). The Poincare section panel (closed by default) has its own energy slider (log scale, from E_top / 1000 to 2 E_top, 59 mJ to 117.7 J at the defaults, where E_top is the both-inverted energy; default 17 J; on new parameters it keeps its position) 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; the legend then says "pendulum at 48 J, not shown"), 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: "real time" makes the section's energy track the pendulum's as it decays (recomputed after a 3% change, at most every 2 s); "sped up" (which opens the "Sped up · listen" panel) 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 "sped up", until the atlas is complete) builds the atlas (`../common/atlas.ts`, configured in `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 "Sped up · listen" panel (above the section) starts closed; opening it switches the sound engine on (closing it switches it off) without playing anything; a line of text says to Strike (or click the section) until the first note. 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. Save WAV saves the current or last note as heard (as received from the audio worker, up to 120 s; 16-bit mono PCM, peak at 0.9; `../common/wav.ts`; named e.g. double-pendulum-50.0J-x2-100Hz.wav). The mute button (speaker icon; when muted it is tinted red and reads "Muted") starts muted and unmutes the first time the panel is opened; muted notes are still computed and shown, just silent. The panel also has "Signal" (which coordinate is heard), volume, pitch (where the small-amplitude lower mode lands, 30 to 1000 Hz, default 100 Hz; the readout gives both modes' pitches), 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 (frequency axis up to just above the highest frequency that comes within 40 dB of the peak at any time in the note, so the broadband start of a chaotic note stays in view), the largest Lyapunov exponent of the motion being heard (10 s window, physical time), its energy (log scale, so the exponential decay is a straight line), and a scatter of lambda against energy accumulated over notes (log energy axis, since a note spends most of its time at low energy; colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Each card and panel (and Parameters) has a small "?" button that opens a short help dialog (`../common/help.ts`; the texts are `<template>`s in `index.html`). Hovering over any chart (real time or sped up) 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://chaos-apps--listening-for-chaos.vault1-sites.magland.org/double-pendulum/ (part of the chaos-apps site; see the top-level README).
88
src/double-pendulum/main.tsmodified+7−28View file
@@ -1,6 +1,7 @@
11 import "../common/style.css";
22 import "./style.css";
33 import { setupMute } from "../common/mute";
4+import { GhostControls } from "../common/ghost";
45 import { AudioOut } from "./audio";
56 import { END_RATIO, LAMBDA_EVERY } from "./audio-shared";
67 import { newCursor } from "../common/cursor";
@@ -420,38 +421,20 @@ energyInput.addEventListener("input", () => {
420421 // arm's angle offset by delta, run in lockstep (never pulled back), to show
421422 // how fast nearby motions separate. Restarted on Strike, on Reset, and when
422423 // switched on.
423-const ghostToggle = document.getElementById("ghost-toggle") as HTMLInputElement;
424-const ghostControls = document.getElementById("ghost-controls")!;
425-const ghostOffset = document.getElementById("ghost-offset") as HTMLInputElement; // log10 of delta
426-const ghostOffsetValue = document.getElementById("ghost-offset-value")!;
427-const ghostSep = document.getElementById("ghost-sep")!;
428-const SUP: Record<string, string> = { "-": "⁻", 0: "⁰", 1: "¹", 2: "²", 3: "³", 4: "⁴", 5: "⁵", 6: "⁶", 7: "⁷", 8: "⁸", 9: "⁹" };
429-const showGhostOffset = () =>
430- (ghostOffsetValue.textContent = `10${[...ghostOffset.value].map((c) => SUP[c]).join("")} rad`);
431-showGhostOffset();
424+// The Separation chart is offered only with the ghost on.
425+const ghostUI = new GhostControls("rad", () => resetGhost(), (on) => traces.setAvailable("Separation", on));
432426 function resetGhost() {
433427 ghost = null;
434- if (!ghostToggle.checked || energy(P, sim.y) === 0) return;
428+ if (!ghostUI.on || energy(P, sim.y) === 0) return;
435429 const y = Float64Array.from(sim.y);
436- y[1] += 10 ** Number(ghostOffset.value);
430+ y[1] += ghostUI.offset;
437431 y[4] = 0;
438432 const E = energy(P, y);
439433 ghost = new Sim(P, 0, ALPHA);
440434 ghost.setState(y, E, damped() ? E : sim.target);
441435 ghostT0 = sim.t;
442436 }
443-// The Separation chart is offered only with the ghost on.
444-ghostToggle.addEventListener("change", () => {
445- ghostControls.hidden = !ghostToggle.checked;
446- traces.setAvailable("Separation", ghostToggle.checked);
447- resetGhost();
448-});
449-traces.setAvailable("Separation", ghostToggle.checked);
450-ghostOffset.addEventListener("input", () => {
451- showGhostOffset();
452- resetGhost();
453-});
454-document.getElementById("ghost-reset")!.addEventListener("click", resetGhost);
437+traces.setAvailable("Separation", ghostUI.on);
455438 // Energy value; without damping, a down arrow while a lower target is not yet
456439 // reachable.
457440 let shownEnergy = "";
@@ -611,7 +594,6 @@ function drawPendulum(y: Float64Array, isGhost: boolean) {
611594 }
612595 }
613596
614-let shownSep = "";
615597 function draw() {
616598 fitCanvas();
617599 const w = canvas.width, dpr = window.devicePixelRatio || 1;
@@ -649,10 +631,7 @@ function draw() {
649631 ctx.fillText(`E ${formatEnergy(E)} J`, 4 * dpr, 4 * dpr);
650632 }
651633 showEnergy();
652- const sep = ghost
653- ? `separation ${ghostSeparation().toExponential(1)} rad · ${(sim.t - ghostT0).toFixed(1)} s`
654- : "";
655- if (sep !== shownSep) ghostSep.textContent = shownSep = sep;
634+ ghostUI.show(ghost ? ghostSeparation() : null, sim.t - ghostT0);
656635 }
657636
658637 let last = performance.now();
src/fput-chain/README.mdmodified+1−1View file
@@ -2,7 +2,7 @@
22
33 Struck FPUT-beta chain, step 2 of "Listening for chaos" (after double-pendulum-chaos). Work in progress.
44
5-Current state: N masses (2 to 64, default 2) between two fixed walls, joined by springs with tension k d + k3 d^3. The chain sits at rest until Strike, which gives it energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, either on one mass j alone (default mass 1, a hammer blow at one end) or in the shape of one normal mode n. The left card animates the chain (top: to scale, rest spacing a; below: the same displacements drawn sideways as a profile, scaled to fit, with the scale given) with the time and the relative energy error. Speed: 0.1x to 20x real time (default 1x). The time and current energy show at the top left. The Ghost chip (as in the pendulum app) adds a second chain started from the same state with every mass moved by the same amount, 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10; moving only the struck mass would put the offset along the motion itself, which does not grow), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost" (from the chain's current state), when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started, and a "Separation" chart (log, last 60 s; offered only with the ghost on) plots it. A "Lyapunov" chart (last 60 s; the first 5 s after a strike left out of its y range) shows the largest Lyapunov exponent over the last 10 s and since the strike (`../common/lyapunov.ts`, two-trajectory Benettin method shared with the pendulum app, with the chain's shadow in `lyapunov.ts`: a hidden shadow chain 1e-8 away, displacements in units of d0 and momenta in units of sqrt(m k) d0, renormalized every step). Checked (N = 4, mass-1 strike, 300 s): without damping 0.1 J ~0.05 to 0.1 /s (weak chaos; N = 4 onset is near 3 k d0^2 = 75 mJ), 0.5 J ~0.5 /s, 2.51 J ~0.9 /s, 25 J ~1.7 /s; with tau 540 s, 0.1 J falls to ~0.01 /s and 2.51 J falls from ~1.0 to ~0.3 /s by 300 s. The slider is labelled "Strike energy". Parameters (m, k, k3, a, tau) fold out underneath with derived values (mode frequencies, d0, nonlinear energy scale, integrator step); Apply or changing N returns the chain to rest. The "Real time" card on the right has toggle chips for its charts. "Mode history" (off by default) is a scrolling heat map of each mode's share of the harmonic energy, log10(E_n / sum E) over 6 decades (inferno colors, mode 1 at the bottom), over the last 300 s (`LiveModeHistory` in `../common/modehistory.ts`); the cascade of energy up the modes shows here (e.g. N = 32, mode-1 strike at 10 mJ: the top mode's share goes from ~1e-28 to ~1e-12 in 10 periods of mode 1 and ~3e-5 in 50 without damping; with tau 540 s the damping wins and the spread stalls near mode 17). "Mode bars" (`../common/modebars.ts`) shows the current harmonic energy of each normal mode relative to E on a log scale (12 decades), with the number of modes sharing the energy (inverse participation, 1 / sum (E_n / sum E)^2). The other chips are charts over time (from `../common/traces.ts` and `../common/cursor.ts`, with per-group colors and a legend capped at 8 series): displacements, velocities, spring stretches, mode energies (log, last 60 s; values below 1e-12 of E left out), energies (E, T, V, W dissipated) and the energy balance error ((E + W - E0) / E0); others last 10 s; sampled every 10 ms; hover for values, click to pin. Mode bars, displacements and mode energies are on by default. Masses, springs and modes share one color per index (four fixed colors for up to 4, a blue-to-red sweep beyond).
5+Current state: N masses (2 to 64, default 2) between two fixed walls, joined by springs with tension k d + k3 d^3. The chain sits at rest until Strike, which gives it energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, either on one mass j alone (default mass 1, a hammer blow at one end) or in the shape of one normal mode n. The left card animates the chain (top: to scale, rest spacing a; below: the same displacements drawn sideways as a profile, scaled to fit, with the scale given) with the time and the relative energy error. Speed: 0.1x to 20x real time (default 1x). The time and current energy show at the top left. The Ghost chip (as in the pendulum app; controls in `../common/ghost.ts`) adds a second chain started from the same state with every mass moved by the same amount, 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10; moving only the struck mass would put the offset along the motion itself, which does not grow), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost" (from the chain's current state), when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started, and a "Separation" chart (log, last 60 s; offered only with the ghost on) plots it. A "Lyapunov" chart (last 60 s; the first 5 s after a strike left out of its y range) shows the largest Lyapunov exponent over the last 10 s and since the strike (`../common/lyapunov.ts`, two-trajectory Benettin method shared with the pendulum app, with the chain's shadow in `lyapunov.ts`: a hidden shadow chain 1e-8 away, displacements in units of d0 and momenta in units of sqrt(m k) d0, renormalized every step). Checked (N = 4, mass-1 strike, 300 s): without damping 0.1 J ~0.05 to 0.1 /s (weak chaos; N = 4 onset is near 3 k d0^2 = 75 mJ), 0.5 J ~0.5 /s, 2.51 J ~0.9 /s, 25 J ~1.7 /s; with tau 540 s, 0.1 J falls to ~0.01 /s and 2.51 J falls from ~1.0 to ~0.3 /s by 300 s. The slider is labelled "Strike energy". Parameters (m, k, k3, a, tau) fold out underneath with derived values (mode frequencies, d0, nonlinear energy scale, integrator step); Apply or changing N returns the chain to rest. The "Real time" card on the right has toggle chips for its charts. "Mode history" (off by default) is a scrolling heat map of each mode's share of the harmonic energy, log10(E_n / sum E) over 6 decades (inferno colors, mode 1 at the bottom), over the last 300 s (`LiveModeHistory` in `../common/modehistory.ts`); the cascade of energy up the modes shows here (e.g. N = 32, mode-1 strike at 10 mJ: the top mode's share goes from ~1e-28 to ~1e-12 in 10 periods of mode 1 and ~3e-5 in 50 without damping; with tau 540 s the damping wins and the spread stalls near mode 17). "Mode bars" (`../common/modebars.ts`) shows the current harmonic energy of each normal mode relative to E on a log scale (12 decades), with the number of modes sharing the energy (inverse participation, 1 / sum (E_n / sum E)^2). The other chips are charts over time (from `../common/traces.ts` and `../common/cursor.ts`, with per-group colors and a legend capped at 8 series): displacements, velocities, spring stretches, mode energies (log, last 60 s; values below 1e-12 of E left out), energies (E, T, V, W dissipated) and the energy balance error ((E + W - E0) / E0); others last 10 s; sampled every 10 ms; hover for values, click to pin. Mode bars, displacements and mode energies are on by default. Masses, springs and modes share one color per index (four fixed colors for up to 4, a blue-to-red sweep beyond).
66
77 "Sped up · listen" (open by default, below the cards; otherwise as in the pendulum app): the header says how much faster it runs ("the whole decay, 321× faster: 62 min of motion in 12 s"; without damping "321× faster than the chain above"). The page starts muted (the mute button is tinted red and reads "Muted"; muted notes are still computed and shown); the audio context is created at the first Strike; closing the panel stops the note. While it is open each Strike plays a note, the same strike rendered in a Web Worker (`render-worker.ts`, on the loop shared with the beam app in `../common/note-render.ts`) sped up so the lowest linear mode sounds at the Pitch (5 to 1000 Hz, default 100; below ~20 Hz the lowest modes are below hearing but show in the spectrogram; the readout gives the lowest and highest mode pitches). A damped note is the whole decay, ending when its energy has fallen to 1e-6 of the strike's (-60 dB; predicted length (tau/2) ln 1e6 of physical time, 11.6 s of sound by default, measured 11.3 s; capped at twice that); undamped notes last 10 s. "Strike to hear the whole decay." shows until the first note; the status line gives the signal and note length ("computing…" while rendering). Replay plays the last strike again with the current settings (to compare signals). Signal (`signals.ts`): velocity (default), displacement or acceleration of mass j (default 1, the end mass, which is never at a node), force on the left wall (the first spring's tension), or sum of accelerations (the net wall force, in which even modes cancel). In one mass's motion mode n is weighted by sqrt(E_n) / omega_n, sqrt(E_n) or sqrt(E_n) omega_n. Each note is made zero-mean, scaled to a 0.9 peak (all signals equally loud; Volume sets the level) and faded out over its last 20 ms (`../common/sound.ts`). Save WAV saves the last note as heard (16-bit mono PCM, peak at 0.9; `../common/wav.ts`; named e.g. fput-N4-mass1-2.51J-v1-100Hz.wav). Chips (as in the pendulum app, plus Modes): Spectrogram (on; `NoteSpectrogram` in `../common/spectrogram.ts`: 2048-sample Hann, zero-pad 4096, hop 512, for pitches down to 30 Hz; below, the window lengthens in proportion (up to 16384 samples at 5 Hz, zero-pad 2x, hop a quarter window) so neighbouring low modes stay resolved; dashed lines at the linear mode pitches, drawn only while at least 6 px apart, since crowded together their dashes merged into a solid on-off bar at the bottom), Modes (off by default; the note's mode-energy history, each mode's share of the energy over the whole note, from the render worker, one column per 256 samples; `ModeHistory` in `../common/modehistory.ts`; a cascade that takes tens of mode-1 periods occupies only the first few percent of a whole-decay note, so Mode history in the Real time panel shows it better), Lyapunov (the note's largest Lyapunov exponent over the last 10 s of physical time, from a shadow run in the worker alongside the note; the first 5 s of physical time left out of the y range), Energy (log scale, so the decay is a straight line) and λ vs E (`../common/scatter.ts`: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note on a log scale, earlier notes faded, hover for values, Clear button). The first three span the whole note (strike at left, end at right) and fill in as the audio clock reaches each sample; one shared cursor (playhead while playing; hover for values, click to pin). Pause (or space) pauses the animation and the sound (audio context suspended). The note's Lyapunov shadow runs only while the Lyapunov chart or λ vs E is shown (it doubles the computation). Render time in the browser pane (with the Lyapunov shadow): ~0.6 s for N = 4, ~2 s for N = 32 (default strike). First look (N = 4, 2.51 J): λ ~1.3 /s at the strike, falling to ~0 by ~4 s of sound as the spectrogram resolves into lines; on λ vs E the note's points sit near 0 below ~0.1 J and rise to ~1.4 /s at the top.
88
src/fput-chain/main.tsmodified+6−28View file
@@ -17,6 +17,7 @@ import { ScatterPlot } from "../common/scatter";
1717 import { SIGNALS, signalByKey } from "./signals";
1818 import { Player } from "../common/sound";
1919 import { setupMute } from "../common/mute";
20+import { GhostControls } from "../common/ghost";
2021 import { fmtDuration, fmtEnergy, fmtLength } from "../common/format";
2122 import { fit, indexColor } from "../common/canvas";
2223 import { NoteSpectrogram } from "../common/spectrogram";
@@ -216,42 +217,19 @@ function buildTraces(shown: string[]) {
216217 togglesEl.replaceChildren();
217218 chartsEl.replaceChildren();
218219 traces = new TracePanel(togglesEl, chartsEl, groups(shown), 10);
219- traces.setAvailable("Separation", ghostToggle.checked); // offered only with the ghost on
220+ traces.setAvailable("Separation", ghostUI.on); // offered only with the ghost on
220221 }
221222
222-const ghostToggle = document.getElementById("ghost-toggle") as HTMLInputElement;
223-const ghostControls = document.getElementById("ghost-controls")!;
224-const ghostOffset = document.getElementById("ghost-offset") as HTMLInputElement; // log10 of the offset (m)
225-const ghostOffsetValue = document.getElementById("ghost-offset-value")!;
226-const ghostSep = document.getElementById("ghost-sep")!;
227-const SUP: Record<string, string> = { "-": "⁻", "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹" };
228-const showGhostOffset = () =>
229- (ghostOffsetValue.textContent = `10${[...ghostOffset.value].map((c) => SUP[c]).join("")} m`);
230-showGhostOffset();
223+const ghostUI = new GhostControls("m", () => resetGhost(), (on) => traces.setAvailable("Separation", on));
231224 function resetGhost() {
232225 ghost = null;
233- if (!ghostToggle.checked || energy(P, s) === 0) return;
226+ if (!ghostUI.on || energy(P, s) === 0) return;
234227 const g = copyState(s);
235- const each = 10 ** Number(ghostOffset.value) / Math.sqrt(P.N);
228+ const each = ghostUI.offset / Math.sqrt(P.N);
236229 for (let j = 0; j < P.N; j++) g.u[j] += each;
237230 ghost = { s: g, verlet: new Verlet(P, g) };
238231 ghostT0 = t;
239232 }
240-ghostToggle.addEventListener("change", () => {
241- ghostControls.hidden = !ghostToggle.checked;
242- traces.setAvailable("Separation", ghostToggle.checked);
243- resetGhost();
244-});
245-ghostOffset.addEventListener("input", () => {
246- showGhostOffset();
247- resetGhost();
248-});
249-document.getElementById("ghost-reset")!.addEventListener("click", resetGhost);
250-let shownSep = "";
251-function showSeparation() {
252- const sep = ghost ? `separation ${ghostSeparation().toExponential(1)} m · ${(t - ghostT0).toFixed(1)} s` : "";
253- if (sep !== shownSep) ghostSep.textContent = shownSep = sep;
254-}
255233
256234 // Mode bars: the current energy of each mode (chip ahead of the trace chips).
257235 const barsToggle = document.getElementById("bars-toggle") as HTMLInputElement;
@@ -691,7 +669,7 @@ function frame(now: number) {
691669 drawChain();
692670 if (running) poincare?.follow(energy(P, s));
693671 poincare?.draw();
694- showSeparation();
672+ ghostUI.show(ghost ? ghostSeparation() : null, t - ghostT0);
695673 if (!barsFig.hidden) drawModes();
696674 traces.draw();
697675 if (!liveModesFig.hidden) liveModes.draw();
↑↓ move↵openescclose