Tidier chart numbers, Lyapunov range skips the strike transient; Restart ghost; Separation only with the ghost
4 changed files+51−11
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 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.
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 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; 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, 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
index.htmlmodified+1−1View file
@@ -133,7 +133,7 @@
133133 <span class="label">Offset</span>
134134 <input id="ghost-offset" type="range" min="-12" max="-1" step="1" value="-10" aria-label="Ghost offset (power of ten)" />
135135 <span id="ghost-offset-value" class="num"></span>
136- <button id="ghost-reset" type="button" title="Restart the ghost from the pendulum's current state">Reset</button>
136+ <button id="ghost-reset" type="button" title="Restart the ghost from the pendulum's current state">Restart ghost</button>
137137 </span>
138138 <span id="ghost-sep" class="num muted"></span>
139139 </div>
src/main.tsmodified+14−4View file
@@ -94,6 +94,9 @@ const relErr = () => {
9494 // Largest Lyapunov exponent of the animated pendulum, measured from each
9595 // Strike (10 s window for the finite-time value).
9696 const lyap = new Lyapunov(10);
97+// Physical time after a strike left out of the Lyapunov charts' y range: the
98+// first values (separation not yet aligned, window not yet full) are large.
99+const LYAP_SKIP = 5;
97100 // The ghost (defined below) and its separation, for the Separation chart.
98101 let ghost: Sim | null = null;
99102 let ghostT0 = 0; // time of the last ghost start
@@ -157,6 +160,7 @@ const traces = new TracePanel(
157160 label: "Lyapunov",
158161 unit: "1/s",
159162 window: 30,
163+ skip: () => LYAP_SKIP,
160164 series: [
161165 { label: "λ 10 s", value: () => lyap.windowed() },
162166 { label: "λ since strike", value: () => lyap.mean() },
@@ -192,9 +196,7 @@ const audioModeHz = () => modeHz.map((f) => (f * pitchHz()) / modeHz[0]);
192196 const showPitch = () => {
193197 const [lo, hi] = audioModeHz();
194198 const S = lo / modeHz[0];
195- pitchValue.textContent =
196- `${lo.toFixed(0)} Hz · upper ${hi.toFixed(0)} Hz · ×${S.toFixed(0)}` +
197- (damped() ? ` · decay ${(P.tau / S).toFixed(1)} s` : "");
199+ pitchValue.textContent = `lower mode ${lo.toFixed(0)} Hz · upper ${hi.toFixed(0)} Hz`;
198200 // The sound runs on its own clock: the same strike, sped up by S.
199201 const note = predictedNoteLength();
200202 audioClock.textContent = damped()
@@ -238,7 +240,12 @@ const audioTraces = new TracePanel(
238240 document.getElementById("audio-chips")!,
239241 document.getElementById("audio-traces")!,
240242 [
241- { label: "Lyapunov", unit: "1/s", series: [{ label: "λ 10 s", value: () => noteLambda }] },
243+ {
244+ label: "Lyapunov",
245+ unit: "1/s",
246+ skip: () => (LYAP_SKIP * modeHz[0]) / pitchHz(), // in seconds of sound
247+ series: [{ label: "λ 10 s", value: () => noteLambda }],
248+ },
242249 { label: "Energy", unit: "J", zero: true, series: [{ label: "E", value: () => noteEnergy }] },
243250 ],
244251 10,
@@ -412,10 +419,13 @@ function resetGhost() {
412419 ghost.setState(y, E, damped() ? E : sim.target);
413420 ghostT0 = sim.t;
414421 }
422+// The Separation chart is offered only with the ghost on.
415423 ghostToggle.addEventListener("change", () => {
416424 ghostControls.hidden = !ghostToggle.checked;
425+ traces.setAvailable("Separation", ghostToggle.checked);
417426 resetGhost();
418427 });
428+traces.setAvailable("Separation", ghostToggle.checked);
419429 ghostOffset.addEventListener("input", () => {
420430 showGhostOffset();
421431 resetGhost();
src/traces.tsmodified+35−5View file
@@ -1,7 +1,7 @@
11 // Live scrolling time traces. Quantities are arranged in groups; each group is
22 // one chart (shared y axis, one line per series) shown or hidden by a
33 // 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
4+// log scale), optionally leaving out an initial transient (`skip`). Each pixel column draws only the min and max of its samples, so
55 // cost does not grow with the sample rate and short spikes are kept.
66 // Non-finite values (NaN) leave a gap. Hovering shows a time cursor (shared by
77 // the panel's charts, and optionally with other charts) and the values there;
@@ -23,6 +23,7 @@ export interface Group {
2323 window?: number; // visible time span for this chart (default: the panel's)
2424 log?: boolean; // log10 y axis (non-positive values leave a gap); range in whole decades
2525 zero?: boolean; // non-negative quantity: the y range starts at 0
26+ skip?: () => number; // time after the first sample (since clear) left out of the y range
2627 }
2728
2829 const CAPACITY = 16384; // samples kept per series (ring buffer)
@@ -39,7 +40,10 @@ export class TracePanel {
3940 private v: Float64Array[][]; // [group][series]
4041 private count = 0;
4142 private head = 0; // next write index
43+ private tFirst = 0; // time of the first sample since clear
4244 private canvases: HTMLCanvasElement[];
45+ private chips: HTMLElement[] = [];
46+ private boxes: HTMLInputElement[] = [];
4347 private dpr = window.devicePixelRatio || 1;
4448 private width: number; // chart width, CSS pixels
4549 private fixed: { t0: number; span: number } | null = null; // fixed x range instead of scrolling
@@ -73,12 +77,14 @@ export class TracePanel {
7377 box.type = "checkbox";
7478 box.checked = !!g.shown;
7579 box.addEventListener("change", () => (canvas.hidden = !box.checked));
80+ this.boxes.push(box);
7681 const text = document.createElement("span");
7782 text.textContent = g.label;
7883 const chip = document.createElement("label");
7984 chip.className = "chip";
8085 chip.append(box, text);
8186 toggles.appendChild(chip);
87+ this.chips.push(chip);
8288 attachCursor(canvas, cursor, (f) => this.ranges[j].t0 + f * this.ranges[j].win);
8389 return canvas;
8490 });
@@ -89,6 +95,13 @@ export class TracePanel {
8995 return this.canvases.some((c) => !c.hidden);
9096 }
9197
98+ // Offer a chart or not: when unavailable its chip and chart are hidden.
99+ setAvailable(label: string, available: boolean): void {
100+ const j = this.groups.findIndex((g) => g.label === label);
101+ this.chips[j].hidden = !available;
102+ this.canvases[j].hidden = !available || !this.boxes[j].checked;
103+ }
104+
92105 setWindow(window: number): void {
93106 this.window = window;
94107 }
@@ -105,6 +118,7 @@ export class TracePanel {
105118
106119 // Record the current value of every series at time t.
107120 sample(t: number): void {
121+ if (this.count === 0) this.tFirst = t;
108122 this.t[this.head] = t;
109123 this.groups.forEach((g, j) =>
110124 g.series.forEach((s, k) => (this.v[j][k][this.head] = s.value())),
@@ -156,9 +170,17 @@ export class TracePanel {
156170 const tv = g.log ? (x: number) => (x > 0 ? Math.log10(x) : NaN) : (x: number) => x;
157171 const ctx = canvas.getContext("2d")!;
158172 ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
173+ // Range from the samples after the transient (all of them while
174+ // there are none yet).
175+ let m0 = 0;
176+ if (g.skip) {
177+ const tSkip = this.tFirst + g.skip();
178+ while (m0 < n && this.t[idx[m0]] < tSkip) m0++;
179+ if (m0 === n) m0 = 0;
180+ }
159181 let lo = Infinity, hi = -Infinity;
160182 for (const vals of this.v[j]) {
161- for (let m = 0; m < n; m++) {
183+ for (let m = m0; m < n; m++) {
162184 const x = tv(vals[idx[m]]);
163185 if (x < lo) lo = x;
164186 if (x > hi) hi = x;
@@ -174,6 +196,11 @@ export class TracePanel {
174196 const py = (x: number) => HEIGHT - ((x - yMin) / (yMax - yMin)) * (HEIGHT - TOP);
175197
176198 ctx.clearRect(0, 0, this.width, HEIGHT);
199+ // Lines stay below the legend row (a skipped transient can go off scale).
200+ ctx.save();
201+ ctx.beginPath();
202+ ctx.rect(0, TOP - 2, this.width, HEIGHT - TOP + 2);
203+ ctx.clip();
177204 ctx.lineWidth = 1.25;
178205 ctx.lineJoin = "round";
179206 g.series.forEach((s, k) => {
@@ -224,6 +251,7 @@ export class TracePanel {
224251 flush();
225252 ctx.stroke();
226253 });
254+ ctx.restore();
227255
228256 if (cm >= 0) {
229257 const cx = ((ct! - this.tStart) / win) * this.width;
@@ -255,7 +283,7 @@ export class TracePanel {
255283 if (cm >= 0) {
256284 ctx.fillText(`at ${this.t[at].toFixed(2)} s${this.cursor.pinned ? " (pinned)" : ""}`, this.width - 4, 3);
257285 } else if (!empty) {
258- const show = g.log ? (y: number) => `1e${y}` : (y: number) => (y === 0 ? "0" : fmt(y));
286+ const show = g.log ? (y: number) => `1e${y}` : (y: number) => (y === 0 ? "0" : fmt(y, 3));
259287 ctx.fillText(`${show(lo)} … ${show(hi)}${g.log ? " (log)" : ""}`, this.width - 4, 3);
260288 }
261289 ctx.textAlign = "left";
@@ -263,7 +291,9 @@ export class TracePanel {
263291 }
264292 }
265293
266-function fmt(x: number): string {
294+// x to `digits` significant digits (exponential when very small or large).
295+function fmt(x: number, digits = 4): string {
267296 const a = Math.abs(x);
268- return a !== 0 && (a < 1e-3 || a >= 1e5) ? x.toExponential(3) : x.toFixed(4);
297+ if (a !== 0 && (a < 1e-3 || a >= 1e5)) return x.toExponential(digits - 1);
298+ return a >= 10 ** digits ? x.toFixed(0) : x.toPrecision(digits);
269299 }