Section orbits, worker and atlas in src/common/; each app supplies sectionOf(P)
14 changed files+452−565
README.mdmodified+1−1View file
@@ -4,7 +4,7 @@ Interactive apps for "Listening for chaos": struck dynamical systems heard as so
44
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/`.
7-- `src/common/`: code shared by the apps (help dialogs, chart cursor, WAV export, λ vs E scatter, strip charts, whole-note spectrogram, Lyapunov exponent by the two-trajectory method with a per-app `Shadow`) 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.
7+- `src/common/`: code shared by the apps (help dialogs, chart cursor, WAV export, λ 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 and the precomputed atlas, with a per-app `Section`) 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.
88 - `src/index.html`: landing page linking the apps.
99
1010 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/common/atlas.tsadded+140−0View file
@@ -0,0 +1,140 @@
1+// Atlas of Poincare sections at a ladder of energies, so the section can
2+// follow a decaying note faster than sections can be computed. Computed on
3+// request in its own worker (highest energy first) and kept in the browser
4+// (IndexedDB) per parameter set, deleted after MAX_AGE. Falls back to memory
5+// only if storage is unavailable. Each app configures it (AtlasConfig).
6+
7+import { seedGrid, type Section } from "./section";
8+import type { FromPoincare, ToPoincare } from "./section-worker";
9+
10+const ATLAS_CROSSINGS = 150; // per orbit
11+const GRID = [6, 7];
12+const MAX_AGE = 7 * 24 * 3600 * 1000; // ms
13+const VERSION = "atlas-v1"; // change when the computation changes
14+const STORE = "atlas";
15+
16+export interface AtlasConfig<P> {
17+ db: string; // IndexedDB database name
18+ energies: (P: P) => number[]; // the ladder of energies
19+ key: (P: P) => string; // the parameters the sections depend on
20+ section: (P: P) => Section; // for the seeds
21+ makeWorker: () => Worker; // the app's section worker
22+}
23+
24+export interface AtlasOrbit {
25+ points: Float32Array; // x, p pairs
26+ lambda: number;
27+}
28+export interface AtlasEntry {
29+ E: number;
30+ orbits: AtlasOrbit[];
31+}
32+interface Record {
33+ key: string;
34+ created: number;
35+ entries: AtlasEntry[];
36+}
37+
38+function openDb(name: string): Promise<IDBDatabase> {
39+ return new Promise((resolve, reject) => {
40+ const req = indexedDB.open(name, 1);
41+ req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "key" });
42+ req.onsuccess = () => resolve(req.result);
43+ req.onerror = () => reject(req.error);
44+ });
45+}
46+
47+function tx<T>(db: IDBDatabase, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
48+ return new Promise((resolve, reject) => {
49+ const req = fn(db.transaction(STORE, mode).objectStore(STORE));
50+ req.onsuccess = () => resolve(req.result);
51+ req.onerror = () => reject(req.error);
52+ });
53+}
54+
55+export class Atlas<P> {
56+ private config: AtlasConfig<P>;
57+ private db: Promise<IDBDatabase | null>;
58+ private memory = new Map<string, Record>();
59+ private worker: Worker | null = null;
60+ private job = 0; // increments to cancel a computation
61+
62+ constructor(config: AtlasConfig<P>) {
63+ this.config = config;
64+ this.db = openDb(config.db)
65+ .then(async (db) => {
66+ // Delete expired atlases.
67+ const all = await tx<Record[]>(db, "readonly", (s) => s.getAll());
68+ for (const r of all) if (Date.now() - r.created > MAX_AGE) await tx(db, "readwrite", (s) => s.delete(r.key));
69+ return db;
70+ })
71+ .catch(() => null);
72+ }
73+
74+ private keyOf(P: P): string {
75+ return `${VERSION}|${this.config.key(P)}`;
76+ }
77+
78+ // Cached entries for P (possibly incomplete), sorted by energy.
79+ async load(P: P): Promise<AtlasEntry[]> {
80+ const key = this.keyOf(P);
81+ let r = this.memory.get(key);
82+ const db = await this.db;
83+ if (!r && db) r = await tx<Record | undefined>(db, "readonly", (s) => s.get(key)).catch(() => undefined);
84+ if (!r || Date.now() - r.created > MAX_AGE) return [];
85+ this.memory.set(key, r);
86+ return [...r.entries].sort((a, b) => a.E - b.E);
87+ }
88+
89+ cancel(): void {
90+ this.job++;
91+ }
92+
93+ // Compute the missing entries for P, highest energy first, calling onEntry
94+ // after each one is computed and stored.
95+ async compute(P: P, onEntry: (entries: AtlasEntry[]) => void): Promise<void> {
96+ const job = ++this.job;
97+ const key = this.keyOf(P);
98+ const have = await this.load(P);
99+ const record: Record = this.memory.get(key) ?? { key, created: Date.now(), entries: have };
100+ this.memory.set(key, record);
101+ const todo = this.config
102+ .energies(P)
103+ .filter((E) => !record.entries.some((e) => Math.abs(e.E - E) < 1e-9 * E))
104+ .sort((a, b) => b - a);
105+ for (const E of todo) {
106+ const entry = await this.section(P, E, job);
107+ if (!entry || job !== this.job) return;
108+ record.entries.push(entry);
109+ const db = await this.db;
110+ if (db) await tx(db, "readwrite", (s) => s.put(record)).catch(() => undefined);
111+ onEntry([...record.entries].sort((a, b) => a.E - b.E));
112+ }
113+ }
114+
115+ // One section at energy E, from the worker (null if cancelled).
116+ private section(P: P, E: number, job: number): Promise<AtlasEntry | null> {
117+ if (!this.worker) this.worker = this.config.makeWorker();
118+ const worker = this.worker;
119+ const seeds = seedGrid(this.config.section(P), E, GRID[0], GRID[1]);
120+ const points: number[][] = seeds.map(() => []);
121+ const lambdas: number[] = seeds.map(() => NaN);
122+ return new Promise((resolve) => {
123+ worker.onmessage = (e: MessageEvent<FromPoincare>) => {
124+ if (job !== this.job) {
125+ worker.postMessage({ type: "stop" } satisfies ToPoincare<P>);
126+ resolve(null);
127+ return;
128+ }
129+ for (const o of e.data.orbits) {
130+ for (const v of o.points) points[o.index].push(v);
131+ lambdas[o.index] = o.lambda;
132+ }
133+ if (e.data.finished) {
134+ resolve({ E, orbits: points.map((p, k) => ({ points: Float32Array.from(p), lambda: lambdas[k] })) });
135+ }
136+ };
137+ worker.postMessage({ type: "run", id: job, P, E, seeds, crossings: ATLAS_CROSSINGS } satisfies ToPoincare<P>);
138+ });
139+ }
140+}
src/common/section-worker.tsadded+83−0View file
@@ -0,0 +1,83 @@
1+// Computes Poincare section orbits in a Web Worker (see section.ts). Each
2+// app's worker module calls serveSections with its sectionOf. Orbits are
3+// advanced round-robin, one crossing at a time, and new points are posted
4+// every BATCH_MS so the whole picture fills in together. A new "run" (energy
5+// or parameters changed) replaces the current one; "add" appends an orbit
6+// started at a clicked point.
7+
8+import { CROSSINGS, SectionOrbit, type Section } from "./section";
9+
10+const BATCH_MS = 40;
11+
12+export type ToPoincare<P = unknown> =
13+ | { type: "run"; id: number; P: P; E: number; seeds: [number, number][]; crossings?: number }
14+ | { type: "add"; id: number; seed: [number, number] }
15+ | { type: "stop" };
16+
17+export type FromPoincare = {
18+ type: "points";
19+ id: number;
20+ orbits: { index: number; points: Float32Array; lambda: number; done: boolean }[];
21+ finished: boolean;
22+};
23+
24+export function serveSections<P>(sectionOf: (P: P) => Section | null): void {
25+ let runId = -1;
26+ let section: Section | null = null;
27+ let E = 0;
28+ let orbits: SectionOrbit[] = [];
29+ let sent: number[] = []; // points (pairs) already posted, per orbit
30+ let doneSent: boolean[] = []; // whether the orbit's end has been posted
31+ let running = false;
32+ let limit = CROSSINGS; // crossings per orbit in this run
33+
34+ const active = (o: SectionOrbit) => o.crossings < limit && !o.done;
35+
36+ onmessage = (e: MessageEvent<ToPoincare<P>>) => {
37+ const msg = e.data;
38+ if (msg.type === "run") {
39+ runId = msg.id;
40+ limit = msg.crossings ?? CROSSINGS;
41+ section = sectionOf(msg.P);
42+ E = msg.E;
43+ const S = section;
44+ orbits = S ? msg.seeds.map(([x, p]) => new SectionOrbit(S, E, x, p)) : [];
45+ sent = orbits.map(() => 0);
46+ doneSent = orbits.map(() => false);
47+ } else if (msg.type === "add") {
48+ if (msg.id !== runId || !section) return;
49+ orbits.push(new SectionOrbit(section, E, msg.seed[0], msg.seed[1]));
50+ sent.push(0);
51+ doneSent.push(false);
52+ } else {
53+ orbits = [];
54+ sent = [];
55+ doneSent = [];
56+ }
57+ if (!running && orbits.length) {
58+ running = true;
59+ setTimeout(work, 0);
60+ }
61+ };
62+
63+ function work(): void {
64+ const t0 = performance.now();
65+ while (performance.now() - t0 < BATCH_MS && orbits.some(active)) {
66+ for (const o of orbits) if (active(o)) o.advance();
67+ }
68+ const out: FromPoincare["orbits"] = [];
69+ orbits.forEach((o, index) => {
70+ const n = o.points.length / 2;
71+ const done = !active(o);
72+ if (n > sent[index] || (done && !doneSent[index])) {
73+ out.push({ index, points: Float32Array.from(o.points.slice(2 * sent[index])), lambda: o.lambda(), done });
74+ sent[index] = n;
75+ doneSent[index] = done;
76+ }
77+ });
78+ const finished = !orbits.some(active);
79+ postMessage({ type: "points", id: runId, orbits: out, finished } satisfies FromPoincare);
80+ if (finished) running = false;
81+ else setTimeout(work, 0);
82+ }
83+}
src/common/section.tsadded+96−0View file
@@ -0,0 +1,96 @@
1+// Poincare sections at fixed energy E, without damping, for motion (or a
2+// family of motions) with two degrees of freedom. Each app describes its
3+// section with a Section: the allowed region in the plotted coordinates
4+// (x, p), the full state at a point of it, and a Flow, the orbit started
5+// there. The orbit is recorded each time it crosses the section (Flow.side
6+// going from negative to non-negative within a step), at the point found by
7+// linear interpolation in that step, and its Lyapunov exponent is measured
8+// over the whole run.
9+
10+export const CROSSINGS = 300; // crossings recorded per orbit
11+
12+export interface Section<S = unknown> {
13+ // Largest |x| on the section at energy E.
14+ xMax(E: number): number;
15+ // Largest |p| on the section at x (0 outside the allowed range).
16+ pMax(E: number, x: number): number;
17+ // Full state at (x, p), or null if it is not reachable at E.
18+ state(E: number, x: number, p: number): S | null;
19+ // The undamped orbit from (x, p), which must be reachable.
20+ flow(E: number, x: number, p: number): Flow;
21+ // x is an angle in (-pi, pi] (interpolated across the wrap).
22+ periodicX?: boolean;
23+ // s; an orbit that stops crossing ends here.
24+ maxTime: number;
25+}
26+
27+export interface Flow {
28+ readonly t: number;
29+ // One integrator step of the orbit (and of its Lyapunov shadow).
30+ step(): void;
31+ // Signed distance from the section; NaN where it is not continuous (e.g.
32+ // an angle near its wrap), so no crossing is counted there.
33+ side(): number;
34+ x(): number;
35+ p(): number;
36+ // Largest Lyapunov exponent since the start, 1/s.
37+ lambda(): number;
38+}
39+
40+const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a));
41+
42+// Starting points spread over the allowed region: an nx-by-ny grid in
43+// coordinates normalized to the region (across x, then across p at that x).
44+export function seedGrid(S: Section, E: number, nx: number, ny: number): [number, number][] {
45+ const xm = S.xMax(E);
46+ const seeds: [number, number][] = [];
47+ for (let i = 0; i < nx; i++) {
48+ const x = xm * (-1 + (2 * (i + 0.5)) / nx);
49+ const pm = S.pMax(E, x);
50+ for (let j = 0; j < ny; j++) seeds.push([x, pm * (-1 + (2 * (j + 0.5)) / ny)]);
51+ }
52+ return seeds;
53+}
54+
55+// One orbit followed from a point on the section, recording its crossings.
56+export class SectionOrbit {
57+ private flow: Flow;
58+ private periodicX: boolean;
59+ private maxTime: number;
60+ points: number[]; // x, p pairs (the start point first)
61+ crossings = 0;
62+
63+ constructor(S: Section, E: number, x: number, p: number) {
64+ this.flow = S.flow(E, x, p);
65+ this.periodicX = S.periodicX ?? false;
66+ this.maxTime = S.maxTime;
67+ this.points = [x, p];
68+ }
69+
70+ get done(): boolean {
71+ return this.flow.t >= this.maxTime;
72+ }
73+
74+ // Integrate until the next crossing (or the time limit); returns whether a
75+ // crossing was recorded.
76+ advance(): boolean {
77+ const F = this.flow;
78+ while (F.t < this.maxTime) {
79+ const c = F.side(), x = F.x(), p = F.p();
80+ F.step();
81+ const c2 = F.side();
82+ if (c < 0 && c2 >= 0) {
83+ const f = -c / (c2 - c);
84+ if (this.periodicX) this.points.push(wrap(x + f * wrap(F.x() - x)), p + f * (F.p() - p));
85+ else this.points.push(x + f * (F.x() - x), p + f * (F.p() - p));
86+ this.crossings++;
87+ return true;
88+ }
89+ }
90+ return false;
91+ }
92+
93+ lambda(): number {
94+ return this.flow.lambda();
95+ }
96+}
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 "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 (default 17 J) 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 (`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 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 (default 17 J) 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
@@ -20,4 +20,4 @@ Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/doub
2020
2121 Audio: a Web Worker (`audio-worker.ts`) integrates the audio copy with substeps of at most 3.2 ms of simulated time per sample, high-passes at 20 Hz, and streams 1024-sample chunks through a MessageChannel to an AudioWorklet (`audio-worklet.ts`) that plays them from a ring buffer, keeping about 0.25 s queued. The page shows how many times faster than real time the worker computes, and any dropouts. The worker also sends each chunk to the page, which feeds it to the audio spectrogram as the audio clock reaches it (2048-sample window, new column every 512 samples). The worker runs the same Lyapunov shadow on the note and sends one exponent and one energy value per 256 samples with each chunk; the page plots them in step with the spectrogram. The whole-note spectrogram keeps every column (pairs are averaged past 4096 columns) and draws only new pixel columns unless the axis or color scale changes.
2222
23-Code: `pendulum.ts` (physics), `sim.ts` (running simulation with energy target), `audio.ts` (audio setup), `signals.ts` (audible quantities), `lyapunov.ts` (the Lyapunov shadow; the method is `../common/lyapunov.ts`), `../common/scatter.ts` (lambda vs energy), `../common/cursor.ts` (shared hover/pin cursor), `poincare-core.ts`, `poincare-worker.ts`, `poincare.ts` (Poincare section), `atlas.ts` (cached sections for following the sound), `audio-shared.ts` (constants shared with the worker), `../common/traces.ts` (strip charts), `../common/spectrogram.ts` (whole-note spectrogram), `main.ts` (page).
23+Code: `pendulum.ts` (physics), `sim.ts` (running simulation with energy target), `audio.ts` (audio setup), `signals.ts` (audible quantities), `lyapunov.ts` (the Lyapunov shadow; the method is `../common/lyapunov.ts`), `../common/scatter.ts` (lambda vs energy), `../common/cursor.ts` (shared hover/pin cursor), `poincare-core.ts` (the section's physics; the orbits, worker and atlas are in `../common/section.ts`, `../common/section-worker.ts`, `../common/atlas.ts`), `poincare-worker.ts`, `poincare.ts` (Poincare section), `atlas.ts` (cached sections for following the sound), `audio-shared.ts` (constants shared with the worker), `../common/traces.ts` (strip charts), `../common/spectrogram.ts` (whole-note spectrogram), `main.ts` (page).
src/double-pendulum/atlas.tsmodified+11−124View file
@@ -1,136 +1,23 @@
1-// Atlas of Poincare sections at a ladder of energies, so the section can
2-// follow a decaying note faster than sections can be computed. Computed on
3-// request in its own worker (highest energy first) and kept in the browser
4-// (IndexedDB) per parameter set, deleted after MAX_AGE. Falls back to memory
5-// only if storage is unavailable.
1+// The pendulum's atlas of sections (see ../common/atlas.ts).
62
3+import { Atlas } from "../common/atlas";
74 import { topEnergy, type Params } from "./pendulum";
8-import { seedGrid } from "./poincare-core";
9-import type { FromPoincare, ToPoincare } from "./poincare-worker";
5+import { sectionOf } from "./poincare-core";
106
117 export const ATLAS_SIZE = 24; // energies
12-const ATLAS_CROSSINGS = 150; // per orbit
13-const GRID = [6, 7];
14-const MAX_AGE = 7 * 24 * 3600 * 1000; // ms
15-const VERSION = "atlas-v1"; // change when the computation changes
16-const DB = "double-pendulum", STORE = "atlas";
17-
18-export interface AtlasOrbit {
19- points: Float32Array; // th2, p2 pairs
20- lambda: number;
21-}
22-export interface AtlasEntry {
23- E: number;
24- orbits: AtlasOrbit[];
25-}
26-interface Record {
27- key: string;
28- created: number;
29- entries: AtlasEntry[];
30-}
318
329 // Energies: log-spaced from 0.4% of the both-inverted energy to twice it
3310 // (the top of the energy sliders).
34-export function atlasEnergies(P: Params): number[] {
11+function atlasEnergies(P: Params): number[] {
3512 const lo = 0.004 * topEnergy(P), hi = 2 * topEnergy(P);
3613 return Array.from({ length: ATLAS_SIZE }, (_, i) => lo * (hi / lo) ** (i / (ATLAS_SIZE - 1)));
3714 }
3815
39-const keyOf = (P: Params) => `${VERSION}|${P.m1}|${P.m2}|${P.l1}|${P.l2}|${P.g}`;
40-
41-function openDb(): Promise<IDBDatabase> {
42- return new Promise((resolve, reject) => {
43- const req = indexedDB.open(DB, 1);
44- req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "key" });
45- req.onsuccess = () => resolve(req.result);
46- req.onerror = () => reject(req.error);
47- });
48-}
49-
50-function tx<T>(db: IDBDatabase, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
51- return new Promise((resolve, reject) => {
52- const req = fn(db.transaction(STORE, mode).objectStore(STORE));
53- req.onsuccess = () => resolve(req.result);
54- req.onerror = () => reject(req.error);
16+export const newAtlas = () =>
17+ new Atlas<Params>({
18+ db: "double-pendulum",
19+ energies: atlasEnergies,
20+ key: (P) => `${P.m1}|${P.m2}|${P.l1}|${P.l2}|${P.g}`,
21+ section: sectionOf,
22+ makeWorker: () => new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" }),
5523 });
56-}
57-
58-export class Atlas {
59- private db: Promise<IDBDatabase | null>;
60- private memory = new Map<string, Record>();
61- private worker: Worker | null = null;
62- private job = 0; // increments to cancel a computation
63-
64- constructor() {
65- this.db = openDb()
66- .then(async (db) => {
67- // Delete expired atlases.
68- const all = await tx<Record[]>(db, "readonly", (s) => s.getAll());
69- for (const r of all) if (Date.now() - r.created > MAX_AGE) await tx(db, "readwrite", (s) => s.delete(r.key));
70- return db;
71- })
72- .catch(() => null);
73- }
74-
75- // Cached entries for P (possibly incomplete), sorted by energy.
76- async load(P: Params): Promise<AtlasEntry[]> {
77- const key = keyOf(P);
78- let r = this.memory.get(key);
79- const db = await this.db;
80- if (!r && db) r = await tx<Record | undefined>(db, "readonly", (s) => s.get(key)).catch(() => undefined);
81- if (!r || Date.now() - r.created > MAX_AGE) return [];
82- this.memory.set(key, r);
83- return [...r.entries].sort((a, b) => a.E - b.E);
84- }
85-
86- cancel(): void {
87- this.job++;
88- }
89-
90- // Compute the missing entries for P, highest energy first, calling onEntry
91- // after each one is computed and stored.
92- async compute(P: Params, onEntry: (entries: AtlasEntry[]) => void): Promise<void> {
93- const job = ++this.job;
94- const key = keyOf(P);
95- const have = await this.load(P);
96- const record: Record = this.memory.get(key) ?? { key, created: Date.now(), entries: have };
97- this.memory.set(key, record);
98- const todo = atlasEnergies(P)
99- .filter((E) => !record.entries.some((e) => Math.abs(e.E - E) < 1e-9 * E))
100- .sort((a, b) => b - a);
101- for (const E of todo) {
102- const entry = await this.section(P, E, job);
103- if (!entry || job !== this.job) return;
104- record.entries.push(entry);
105- const db = await this.db;
106- if (db) await tx(db, "readwrite", (s) => s.put(record)).catch(() => undefined);
107- onEntry([...record.entries].sort((a, b) => a.E - b.E));
108- }
109- }
110-
111- // One section at energy E, from the worker (null if cancelled).
112- private section(P: Params, E: number, job: number): Promise<AtlasEntry | null> {
113- if (!this.worker) this.worker = new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" });
114- const worker = this.worker;
115- const seeds = seedGrid(P, E, GRID[0], GRID[1]);
116- const points: number[][] = seeds.map(() => []);
117- const lambdas: number[] = seeds.map(() => NaN);
118- return new Promise((resolve) => {
119- worker.onmessage = (e: MessageEvent<FromPoincare>) => {
120- if (job !== this.job) {
121- worker.postMessage({ type: "stop" } satisfies ToPoincare);
122- resolve(null);
123- return;
124- }
125- for (const o of e.data.orbits) {
126- for (const v of o.points) points[o.index].push(v);
127- lambdas[o.index] = o.lambda;
128- }
129- if (e.data.finished) {
130- resolve({ E, orbits: points.map((p, k) => ({ points: Float32Array.from(p), lambda: lambdas[k] })) });
131- }
132- };
133- worker.postMessage({ type: "run", id: job, P, E, seeds, crossings: ATLAS_CROSSINGS } satisfies ToPoincare);
134- });
135- }
136-}
src/double-pendulum/poincare-core.tsmodified+45−62View file
@@ -9,13 +9,12 @@
99 // p2^2 <= 2 b (E - V(th2)) (the outline of the allowed region), and the root
1010 // with th1dot = sqrt(disc)/det > 0 is p1 = (c p2 + sqrt(disc)) / b.
1111
12-import { energy, type Params } from "./pendulum";
1312 import { Lyapunov } from "../common/lyapunov";
13+import type { Flow, Section } from "../common/section";
1414 import { shadowOf } from "./lyapunov";
15+import { energy, stepSize, topEnergy, type Params } from "./pendulum";
1516 import { Sim } from "./sim";
1617
17-export const CROSSINGS = 300; // crossings recorded per orbit
18-
1918 const outerV = (P: Params, th2: number) => P.m2 * P.g * P.l2 * (1 - Math.cos(th2));
2019
2120 // Largest |th2| on the section (pi if the outer arm can go over the top).
@@ -39,67 +38,51 @@ export function sectionState(P: Params, E: number, th2: number, p2: number): Flo
3938 return new Float64Array([0, th2, (c * p2 + Math.sqrt(disc)) / b, p2, 0]);
4039 }
4140
42-// Starting points spread over the allowed region: an nx-by-ny grid in
43-// coordinates normalized to the region (u across th2, v across p2 at that th2).
44-export function seedGrid(P: Params, E: number, nx: number, ny: number): [number, number][] {
45- const tm = thetaMax(P, E);
46- const seeds: [number, number][] = [];
47- for (let i = 0; i < nx; i++) {
48- const th2 = tm * (-1 + (2 * (i + 0.5)) / nx);
49- const pm = p2Max(P, E, th2);
50- for (let j = 0; j < ny; j++) seeds.push([th2, pm * (-1 + (2 * (j + 0.5)) / ny)]);
51- }
52- return seeds;
41+// Integrator step for section orbits: 4x the animation's (which keeps the
42+// energy error below 1e-6). Over 300 crossings the energy error stays below
43+// ~2e-5 and the regular/chaotic classification is unchanged; regular orbits
44+// move by ~1e-4, far below a pixel. Calibrated once per parameter set.
45+const STEP_FACTOR = 4;
46+let stepCache = { key: "", h: 0 };
47+function sectionStep(P: Params): number {
48+ const key = JSON.stringify([P.m1, P.m2, P.l1, P.l2, P.g]);
49+ if (key !== stepCache.key) stepCache = { key, h: STEP_FACTOR * stepSize(P, 2 * topEnergy(P)) };
50+ return stepCache.h;
5351 }
5452
55-// One orbit followed from a point on the section, without damping, recording
56-// its crossings and measuring its Lyapunov exponent over the whole run.
57-export class SectionOrbit {
58- readonly sim: Sim;
59- readonly lyap = new Lyapunov(Infinity);
60- points: number[]; // th2, p2 pairs (the start point first)
61- crossings = 0;
62- private h: number;
63- private maxTime: number;
64-
65- constructor(P: Params, E: number, h: number, th2: number, p2: number, maxTime: number) {
66- const y = sectionState(P, E, th2, p2)!;
67- this.sim = new Sim({ ...P, tau: 0 }, 0, 0.5);
68- const E0 = energy(this.sim.P, y);
69- this.sim.setState(y, E0, E0);
70- this.lyap.start(shadowOf(this.sim), this.sim.t);
71- this.h = h;
72- this.maxTime = maxTime;
73- this.points = [th2, p2];
74- }
75-
76- get done(): boolean {
77- return this.sim.t >= this.maxTime;
78- }
53+// The section for parameters P (damping ignored), plotted as (th2, p2).
54+export function sectionOf(P: Params): Section<Float64Array> {
55+ P = { ...P, tau: 0 };
56+ return {
57+ xMax: (E) => thetaMax(P, E),
58+ pMax: (E, th2) => p2Max(P, E, th2),
59+ state: (E, th2, p2) => sectionState(P, E, th2, p2),
60+ flow: (E, th2, p2) => flowFrom(P, sectionState(P, E, th2, p2)!),
61+ periodicX: true,
62+ maxTime: 2000, // an orbit that stops crossing (e.g. inner arm rotating left) ends here
63+ };
64+}
7965
80- // Integrate until the next crossing (or the time limit); returns whether a
81- // crossing was recorded. The crossing is located by linear interpolation in
82- // the step where th1 changes sign from negative to positive.
83- advance(): boolean {
84- const { sim, h } = this;
85- const y = sim.y;
86- while (sim.t < this.maxTime) {
87- const th1 = y[0], th2 = y[1], p2 = y[3];
66+// The undamped pendulum from state y, with its Lyapunov shadow. The side of
67+// the section is th1, except near its wrap (where it jumps from -pi to pi).
68+function flowFrom(P: Params, y: Float64Array): Flow {
69+ const sim = new Sim(P, 0, 0.5);
70+ const E0 = energy(sim.P, y);
71+ sim.setState(y, E0, E0);
72+ const lyap = new Lyapunov(Infinity);
73+ lyap.start(shadowOf(sim), sim.t);
74+ const h = sectionStep(P);
75+ return {
76+ get t() {
77+ return sim.t;
78+ },
79+ step: () => {
8880 sim.step(h);
89- this.lyap.step(h, sim.t);
90- if (th1 < 0 && y[0] >= 0 && y[0] - th1 < 1) {
91- const f = -th1 / (y[0] - th1);
92- const d2 = Math.atan2(Math.sin(y[1] - th2), Math.cos(y[1] - th2));
93- const t2 = th2 + f * d2;
94- this.points.push(Math.atan2(Math.sin(t2), Math.cos(t2)), p2 + f * (y[3] - p2));
95- this.crossings++;
96- return true;
97- }
98- }
99- return false;
100- }
101-
102- lambda(): number {
103- return this.lyap.mean();
104- }
81+ lyap.step(h, sim.t);
82+ },
83+ side: () => (Math.cos(sim.y[0]) > 0 ? sim.y[0] : NaN),
84+ x: () => sim.y[1],
85+ p: () => sim.y[3],
86+ lambda: () => lyap.mean(),
87+ };
10588 }
src/double-pendulum/poincare-worker.tsmodified+4−90View file
@@ -1,92 +1,6 @@
1-// Computes Poincare section orbits in a Web Worker (see poincare-core.ts).
2-// Orbits are advanced round-robin, one crossing at a time, and new points are
3-// posted every BATCH_MS so the whole picture fills in together. A new "run"
4-// (energy or parameters changed) replaces the current one; "add" appends an
5-// orbit started at a clicked point.
1+// The pendulum's section worker (see ../common/section-worker.ts).
62
7-import { stepSize, topEnergy, type Params } from "./pendulum";
8-import { CROSSINGS, SectionOrbit } from "./poincare-core";
3+import { serveSections } from "../common/section-worker";
4+import { sectionOf } from "./poincare-core";
95
10-const BATCH_MS = 40;
11-// Step: 4x the animation's (which keeps the energy error below 1e-6). Over
12-// 300 crossings the energy error stays below ~2e-5 and the regular/chaotic
13-// classification is unchanged; regular orbits move by ~1e-4, far below a pixel.
14-const STEP_FACTOR = 4;
15-const MAX_TIME = 2000; // s; an orbit that stops crossing (e.g. inner arm rotating left) ends here
16-
17-export type ToPoincare =
18- | { type: "run"; id: number; P: Params; E: number; seeds: [number, number][]; crossings?: number }
19- | { type: "add"; id: number; seed: [number, number] }
20- | { type: "stop" };
21-
22-export type FromPoincare = {
23- type: "points";
24- id: number;
25- orbits: { index: number; points: Float32Array; lambda: number; done: boolean }[];
26- finished: boolean;
27-};
28-
29-let runId = -1;
30-let P: Params;
31-let E = 0;
32-let h = 0;
33-let hKey = "";
34-let orbits: SectionOrbit[] = [];
35-let sent: number[] = []; // points (pairs) already posted, per orbit
36-let doneSent: boolean[] = []; // whether the orbit's end has been posted
37-let running = false;
38-let limit = CROSSINGS; // crossings per orbit in this run
39-
40-onmessage = (e: MessageEvent<ToPoincare>) => {
41- const msg = e.data;
42- if (msg.type === "run") {
43- runId = msg.id;
44- limit = msg.crossings ?? CROSSINGS;
45- P = { ...msg.P, tau: 0 };
46- E = msg.E;
47- const key = JSON.stringify([P.m1, P.m2, P.l1, P.l2, P.g]);
48- if (key !== hKey) {
49- h = STEP_FACTOR * stepSize(P, 2 * topEnergy(P));
50- hKey = key;
51- }
52- orbits = msg.seeds.map(([a, b]) => new SectionOrbit(P, E, h, a, b, MAX_TIME));
53- sent = orbits.map(() => 0);
54- doneSent = orbits.map(() => false);
55- } else if (msg.type === "add") {
56- if (msg.id !== runId) return;
57- orbits.push(new SectionOrbit(P, E, h, msg.seed[0], msg.seed[1], MAX_TIME));
58- sent.push(0);
59- doneSent.push(false);
60- } else {
61- orbits = [];
62- sent = [];
63- doneSent = [];
64- }
65- if (!running && orbits.length) {
66- running = true;
67- setTimeout(work, 0);
68- }
69-};
70-
71-const active = (o: SectionOrbit) => o.crossings < limit && !o.done;
72-
73-function work(): void {
74- const t0 = performance.now();
75- while (performance.now() - t0 < BATCH_MS && orbits.some(active)) {
76- for (const o of orbits) if (active(o)) o.advance();
77- }
78- const out: FromPoincare["orbits"] = [];
79- orbits.forEach((o, index) => {
80- const n = o.points.length / 2;
81- const done = !active(o);
82- if (n > sent[index] || (done && !doneSent[index])) {
83- out.push({ index, points: Float32Array.from(o.points.slice(2 * sent[index])), lambda: o.lambda(), done });
84- sent[index] = n;
85- doneSent[index] = done;
86- }
87- });
88- const finished = !orbits.some(active);
89- postMessage({ type: "points", id: runId, orbits: out, finished } satisfies FromPoincare);
90- if (finished) running = false;
91- else setTimeout(work, 0);
92-}
6+serveSections(sectionOf);
src/double-pendulum/poincare.tsmodified+8−6View file
@@ -14,9 +14,11 @@
1414 // energy behind it (see showSound).
1515
1616 import { topEnergy, type Params } from "./pendulum";
17-import { Atlas, ATLAS_SIZE, type AtlasEntry } from "./atlas";
18-import { p2Max, sectionState, seedGrid, thetaMax } from "./poincare-core";
19-import type { FromPoincare, ToPoincare } from "./poincare-worker";
17+import type { AtlasEntry } from "../common/atlas";
18+import { seedGrid } from "../common/section";
19+import type { FromPoincare, ToPoincare } from "../common/section-worker";
20+import { ATLAS_SIZE, newAtlas } from "./atlas";
21+import { p2Max, sectionOf, sectionState, thetaMax } from "./poincare-core";
2022
2123 const WIDTH = 960, HEIGHT = 480; // CSS pixels
2224 const TOP = 18; // legend row
@@ -61,7 +63,7 @@ export class PoincarePanel {
6163 private strikeBox: HTMLInputElement;
6264 private followSelect: HTMLSelectElement;
6365 private noteDots: number[] = []; // th2, p2 pairs of the playing note
64- private atlas = new Atlas();
66+ private atlas = newAtlas();
6567 private atlasEntries: AtlasEntry[] = [];
6668 private soundView = false; // showing the sound view (Follow "Sound" during a note)
6769 private soundE = 0; // energy the sound view is fitted to
@@ -294,7 +296,7 @@ export class PoincarePanel {
294296 this.energyValue.textContent = `${formatEnergy(E)} J`;
295297 }
296298
297- private send(msg: ToPoincare): void {
299+ private send(msg: ToPoincare<Params>): void {
298300 if (!this.worker) {
299301 this.worker = new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" });
300302 this.worker.onmessage = (e: MessageEvent<FromPoincare>) => this.receive(e.data);
@@ -311,7 +313,7 @@ export class PoincarePanel {
311313 this.finished = false;
312314 this.thMax = thetaMax(this.P, this.E);
313315 this.pMax = p2Max(this.P, this.E, 0);
314- const seeds = this.E > 0 ? seedGrid(this.P, this.E, GRID[0], GRID[1]) : [];
316+ const seeds = this.E > 0 ? seedGrid(sectionOf(this.P), this.E, GRID[0], GRID[1]) : [];
315317 for (let i = 0; i < seeds.length; i++) this.orbits.push({ points: [], lambda: NaN, done: false, clicked: false });
316318 if (seeds.length) this.send({ type: "run", id: this.runId, P: this.P, E: this.E, seeds });
317319 this.changed = true;
src/fput-chain/README.mdmodified+1−1View file
@@ -6,7 +6,7 @@ Current state: N masses (2 to 64, default 2) between two fixed walls, joined by
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`) 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 (`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 `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
9-Poincaré section (shown only for N = 2 and N = 3, below the sped-up panel, open; adapted from the pendulum app): its own energy slider (log, 10 µJ to 100 J, default 2.51 J), same parameters, no damping. N = 2: each time mass 1 passes its rest position moving right, mass 2's (u2, p2) is plotted. N = 3: only the symmetric family (u1 = u3, p1 = p3; only modes 1 and 3 move, since the cubic force couples odd modes only to odd modes), which is two-dimensional: each time mass 2 passes its rest position moving right, mass 1's (u1, p1) is plotted. On either section the springs are stretched by 0 or +-x, so V = vFactor (k x^2 / 2 + k3 x^4 / 4) (vFactor 2 or 4), the plotted momentum counts once (N = 2) or twice (N = 3) in the kinetic energy, the outline is where the crossing mass's momentum vanishes, and that momentum follows from E. 42 orbits seeded on a grid, 300 crossings each, in a worker (`poincare-core.ts`, `poincare-worker.ts`, `poincare.ts`; integrator step stepFor(E), energy error < 5e-5; ~1 s per section offline, a few s in the pane), drawn as they arrive; per-orbit colors or Color by λ (each orbit's Lyapunov exponent over its run, log scale); hover to pick out an orbit with its λ; click inside the outline to add an orbit there, and with Click to strike (on) also strike the chain from that exact state (and play it, if the sound panel is open). Follow (as in the pendulum app): "real time" makes the section's energy track the chain's as it decays (recomputed after a 3% change, at most every 2 s, and at each strike); "sped up" (which opens the sped-up panel) shows the playing note's crossings (found in the render worker, for N = 3 only while exactly symmetric, and 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 (refit every 2%) and each dot fitted from its own energy; nothing is computed live. "Precompute sections" (shown only with Follow "sped up" until complete) builds an atlas (`atlas.ts`): sections at 32 energies log-spaced from 1 mJ to 100 J (a factor ~1.45 apart), 42 orbits x 150 crossings, highest first, in its own worker (~22 s in the pane for N = 3), stored in IndexedDB per N, m, k, k3 and deleted after 7 days; the sound view shows the atlas section nearest the current energy, fitted to its allowed region (exact in the linear regime, below ~25 mJ, where sections are scaled copies; an approximation across the gaps above). Without an atlas only the outline is drawn behind the trail. Setting the section's energy by hand pauses everything and clears the rings and the trail; Follow takes over again on Resume. The chain's own crossings show as dark rings when within 5% of the section's energy and, for N = 3, only while the chain is exactly symmetric (else the legend says "chain not symmetric, not shown"; strikes on mass 2 or on mode 1 or 3 are symmetric, mode shapes are made exactly mirror-symmetric for this). Findings: the two-mass chain appears regular at every energy tried (10 mJ to 100 J): nested closed curves and island chains, no chaotic sea; the grid orbits' λ falls as the run lengthens (at 10 J, median 0.045 /s over 300 crossings, 0.007 /s over 3000), as for regular orbits. A likely reason: in mode coordinates (u = S Q) the potential is (k/2) rho^2 + (k3/8) rho^4 with rho^2 = Q1^2 + 3 Q2^2, a function of one combination of the two modes. The N = 3 symmetric family is chaotic: grid orbits with λ > 0.1 /s: 0 of 42 at 25 mJ, 10 at 0.25 J (islands in a chaotic sea), 37 at 2.51 J. It is unstable to breaking the symmetry: an exactly symmetric start stays symmetric (the arithmetic is exactly mirror-symmetric), but an asymmetry of 1e-12 grows to full size within ~100 s at 0.25 to 25 J. The ghost and the Lyapunov shadow offset all masses equally, so after a symmetric strike they too stay symmetric and measure the exponent within the family.
9+Poincaré section (shown only for N = 2 and N = 3, below the sped-up panel, open; adapted from the pendulum app): its own energy slider (log, 10 µJ to 100 J, default 2.51 J), same parameters, no damping. N = 2: each time mass 1 passes its rest position moving right, mass 2's (u2, p2) is plotted. N = 3: only the symmetric family (u1 = u3, p1 = p3; only modes 1 and 3 move, since the cubic force couples odd modes only to odd modes), which is two-dimensional: each time mass 2 passes its rest position moving right, mass 1's (u1, p1) is plotted. On either section the springs are stretched by 0 or +-x, so V = vFactor (k x^2 / 2 + k3 x^4 / 4) (vFactor 2 or 4), the plotted momentum counts once (N = 2) or twice (N = 3) in the kinetic energy, the outline is where the crossing mass's momentum vanishes, and that momentum follows from E. 42 orbits seeded on a grid, 300 crossings each, in a worker (`poincare-core.ts`, the chain's section, on `../common/section.ts` and `../common/section-worker.ts`; `poincare.ts`; integrator step stepFor(E), energy error < 5e-5; ~1 s per section offline, a few s in the pane), drawn as they arrive; per-orbit colors or Color by λ (each orbit's Lyapunov exponent over its run, log scale); hover to pick out an orbit with its λ; click inside the outline to add an orbit there, and with Click to strike (on) also strike the chain from that exact state (and play it, if the sound panel is open). Follow (as in the pendulum app): "real time" makes the section's energy track the chain's as it decays (recomputed after a 3% change, at most every 2 s, and at each strike); "sped up" (which opens the sped-up panel) shows the playing note's crossings (found in the render worker, for N = 3 only while exactly symmetric, and 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 (refit every 2%) and each dot fitted from its own energy; nothing is computed live. "Precompute sections" (shown only with Follow "sped up" until complete) builds an atlas (`../common/atlas.ts`, configured in `atlas.ts`): sections at 32 energies log-spaced from 1 mJ to 100 J (a factor ~1.45 apart), 42 orbits x 150 crossings, highest first, in its own worker (~22 s in the pane for N = 3), stored in IndexedDB per N, m, k, k3 and deleted after 7 days; the sound view shows the atlas section nearest the current energy, fitted to its allowed region (exact in the linear regime, below ~25 mJ, where sections are scaled copies; an approximation across the gaps above). Without an atlas only the outline is drawn behind the trail. Setting the section's energy by hand pauses everything and clears the rings and the trail; Follow takes over again on Resume. The chain's own crossings show as dark rings when within 5% of the section's energy and, for N = 3, only while the chain is exactly symmetric (else the legend says "chain not symmetric, not shown"; strikes on mass 2 or on mode 1 or 3 are symmetric, mode shapes are made exactly mirror-symmetric for this). Findings: the two-mass chain appears regular at every energy tried (10 mJ to 100 J): nested closed curves and island chains, no chaotic sea; the grid orbits' λ falls as the run lengthens (at 10 J, median 0.045 /s over 300 crossings, 0.007 /s over 3000), as for regular orbits. A likely reason: in mode coordinates (u = S Q) the potential is (k/2) rho^2 + (k3/8) rho^4 with rho^2 = Q1^2 + 3 Q2^2, a function of one combination of the two modes. The N = 3 symmetric family is chaotic: grid orbits with λ > 0.1 /s: 0 of 42 at 25 mJ, 10 at 0.25 J (islands in a chaotic sea), 37 at 2.51 J. It is unstable to breaking the symmetry: an exactly symmetric start stays symmetric (the arithmetic is exactly mirror-symmetric), but an asymmetry of 1e-12 grows to full size within ~100 s at 0.25 to 25 J. The ghost and the Lyapunov shadow offset all masses equally, so after a symmetric strike they too stay symmetric and measure the exponent within the family.
1010
1111 Each card and panel (chain, Parameters, Real time, Sped up · listen, Poincaré section) has a small "?" button that opens a short help dialog (`../common/help.ts`; the texts are `<template>`s in `index.html`).
1212
src/fput-chain/atlas.tsmodified+11−124View file
@@ -1,138 +1,25 @@
1-// Atlas of Poincare sections at a ladder of energies, so the section can
2-// follow a decaying note faster than sections can be computed (as in the
3-// double pendulum app). Computed on request in its own worker (highest
4-// energy first) and kept in the browser (IndexedDB) per parameter set,
5-// deleted after MAX_AGE. Falls back to memory only if storage is unavailable.
1+// The chain's atlas of sections (see ../common/atlas.ts).
62
3+import { Atlas } from "../common/atlas";
74 import type { Params } from "./chain";
8-import { sectionDef, seedGrid } from "./poincare-core";
9-import type { FromPoincare, ToPoincare } from "./poincare-worker";
5+import { sectionOf } from "./poincare-core";
106
117 export const ATLAS_SIZE = 32; // energies
12-const ATLAS_CROSSINGS = 150; // per orbit
13-const GRID = [6, 7];
14-const MAX_AGE = 7 * 24 * 3600 * 1000; // ms
15-const VERSION = "atlas-v1"; // change when the computation changes
16-const DB = "fput-chain-chaos", STORE = "atlas";
17-
18-export interface AtlasOrbit {
19- points: Float32Array; // x, q pairs
20- lambda: number;
21-}
22-export interface AtlasEntry {
23- E: number;
24- orbits: AtlasOrbit[];
25-}
26-interface Record {
27- key: string;
28- created: number;
29- entries: AtlasEntry[];
30-}
318
329 // Energies: log-spaced from 1 mJ to 100 J (the top of the energy sliders),
3310 // a factor of ~1.45 apart. Below ~25 mJ (k d0^2 for the defaults) the motion
3411 // is nearly linear and sections at different energies are scaled copies, so
3512 // the lowest entry serves for any lower energy.
36-export function atlasEnergies(): number[] {
13+function atlasEnergies(): number[] {
3714 const lo = 1e-3, hi = 100;
3815 return Array.from({ length: ATLAS_SIZE }, (_, i) => lo * (hi / lo) ** (i / (ATLAS_SIZE - 1)));
3916 }
4017
41-const keyOf = (P: Params) => `${VERSION}|${P.N}|${P.m}|${P.k}|${P.k3}`;
42-
43-function openDb(): Promise<IDBDatabase> {
44- return new Promise((resolve, reject) => {
45- const req = indexedDB.open(DB, 1);
46- req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "key" });
47- req.onsuccess = () => resolve(req.result);
48- req.onerror = () => reject(req.error);
49- });
50-}
51-
52-function tx<T>(db: IDBDatabase, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
53- return new Promise((resolve, reject) => {
54- const req = fn(db.transaction(STORE, mode).objectStore(STORE));
55- req.onsuccess = () => resolve(req.result);
56- req.onerror = () => reject(req.error);
18+export const newAtlas = () =>
19+ new Atlas<Params>({
20+ db: "fput-chain-chaos",
21+ energies: atlasEnergies,
22+ key: (P) => `${P.N}|${P.m}|${P.k}|${P.k3}`,
23+ section: (P) => sectionOf(P)!,
24+ makeWorker: () => new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" }),
5725 });
58-}
59-
60-export class Atlas {
61- private db: Promise<IDBDatabase | null>;
62- private memory = new Map<string, Record>();
63- private worker: Worker | null = null;
64- private job = 0; // increments to cancel a computation
65-
66- constructor() {
67- this.db = openDb()
68- .then(async (db) => {
69- // Delete expired atlases.
70- const all = await tx<Record[]>(db, "readonly", (s) => s.getAll());
71- for (const r of all) if (Date.now() - r.created > MAX_AGE) await tx(db, "readwrite", (s) => s.delete(r.key));
72- return db;
73- })
74- .catch(() => null);
75- }
76-
77- // Cached entries for P (possibly incomplete), sorted by energy.
78- async load(P: Params): Promise<AtlasEntry[]> {
79- const key = keyOf(P);
80- let r = this.memory.get(key);
81- const db = await this.db;
82- if (!r && db) r = await tx<Record | undefined>(db, "readonly", (s) => s.get(key)).catch(() => undefined);
83- if (!r || Date.now() - r.created > MAX_AGE) return [];
84- this.memory.set(key, r);
85- return [...r.entries].sort((a, b) => a.E - b.E);
86- }
87-
88- cancel(): void {
89- this.job++;
90- }
91-
92- // Compute the missing entries for P, highest energy first, calling onEntry
93- // after each one is computed and stored.
94- async compute(P: Params, onEntry: (entries: AtlasEntry[]) => void): Promise<void> {
95- const job = ++this.job;
96- const key = keyOf(P);
97- const have = await this.load(P);
98- const record: Record = this.memory.get(key) ?? { key, created: Date.now(), entries: have };
99- this.memory.set(key, record);
100- const todo = atlasEnergies()
101- .filter((E) => !record.entries.some((e) => Math.abs(e.E - E) < 1e-9 * E))
102- .sort((a, b) => b - a);
103- for (const E of todo) {
104- const entry = await this.section(P, E, job);
105- if (!entry || job !== this.job) return;
106- record.entries.push(entry);
107- const db = await this.db;
108- if (db) await tx(db, "readwrite", (s) => s.put(record)).catch(() => undefined);
109- onEntry([...record.entries].sort((a, b) => a.E - b.E));
110- }
111- }
112-
113- // One section at energy E, from the worker (null if cancelled).
114- private section(P: Params, E: number, job: number): Promise<AtlasEntry | null> {
115- if (!this.worker) this.worker = new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" });
116- const worker = this.worker;
117- const seeds = seedGrid(P, sectionDef(P.N)!, E, GRID[0], GRID[1]);
118- const points: number[][] = seeds.map(() => []);
119- const lambdas: number[] = seeds.map(() => NaN);
120- return new Promise((resolve) => {
121- worker.onmessage = (e: MessageEvent<FromPoincare>) => {
122- if (job !== this.job) {
123- worker.postMessage({ type: "stop" } satisfies ToPoincare);
124- resolve(null);
125- return;
126- }
127- for (const o of e.data.orbits) {
128- for (const v of o.points) points[o.index].push(v);
129- lambdas[o.index] = o.lambda;
130- }
131- if (e.data.finished) {
132- resolve({ E, orbits: points.map((p, k) => ({ points: Float32Array.from(p), lambda: lambdas[k] })) });
133- }
134- };
135- worker.postMessage({ type: "run", id: job, P, E, seeds, crossings: ATLAS_CROSSINGS } satisfies ToPoincare);
136- });
137- }
138-}
src/fput-chain/poincare-core.tsmodified+38−70View file
@@ -21,12 +21,11 @@
2121 // c^2 / 2m = E - vFactor v(x) - mult q^2 / 2m. A point is reachable exactly
2222 // when mult q^2 <= 2 m (E - vFactor v(x)) (the outline); c is the positive root.
2323
24-import { Verlet, energy, stepFor, type Params, type State } from "./chain";
2524 import { Lyapunov } from "../common/lyapunov";
25+import type { Flow, Section } from "../common/section";
26+import { Verlet, energy, stepFor, type Params, type State } from "./chain";
2627 import { shadowOf } from "./lyapunov";
2728
28-export const CROSSINGS = 300; // crossings recorded per orbit
29-
3029 export interface SectionDef {
3130 cross: number; // index of the mass whose rest crossing (moving right) defines the section
3231 plot: number; // index of the mass whose (u, p) is plotted
@@ -65,73 +64,42 @@ export function sectionState(P: Params, D: SectionDef, E: number, x: number, q:
6564 return { u: new Float64Array([x, 0, x]), p: new Float64Array([q, c, q]), W: 0 };
6665 }
6766
68-// Starting points spread over the allowed region: an nx-by-ny grid in
69-// coordinates normalized to the region (across x, then across q at that x).
70-export function seedGrid(P: Params, D: SectionDef, E: number, nx: number, ny: number): [number, number][] {
71- const um = uMax(P, D, E);
72- const seeds: [number, number][] = [];
73- for (let i = 0; i < nx; i++) {
74- const x = um * (-1 + (2 * (i + 0.5)) / nx);
75- const pm = pMax(P, D, E, x);
76- for (let j = 0; j < ny; j++) seeds.push([x, pm * (-1 + (2 * (j + 0.5)) / ny)]);
77- }
78- return seeds;
67+// The section for parameters P (damping ignored), or null if there is none
68+// (N other than 2 and 3).
69+export function sectionOf(P: Params): Section<State> | null {
70+ const D = sectionDef(P.N);
71+ if (!D) return null;
72+ P = { ...P, tau: 0 };
73+ return {
74+ xMax: (E) => uMax(P, D, E),
75+ pMax: (E, x) => pMax(P, D, E, x),
76+ state: (E, x, q) => sectionState(P, D, E, x, q),
77+ flow: (E, x, q) => flowFrom(P, D, sectionState(P, D, E, x, q)!),
78+ maxTime: 5000, // an orbit that stops crossing ends here
79+ };
7980 }
8081
81-// One orbit followed from a point on the section, without damping, recording
82-// its crossings and measuring its Lyapunov exponent over the whole run. For
83-// N = 3 the orbit stays exactly symmetric (the shadow's offset is symmetric
84-// too), so its exponent is the one within the symmetric family.
85-export class SectionOrbit {
86- private P: Params;
87- private D: SectionDef;
88- private s: State;
89- private verlet: Verlet;
90- readonly lyap = new Lyapunov(Infinity);
91- points: number[]; // x, q pairs (the start point first)
92- crossings = 0;
93- private t = 0;
94- private h: number;
95- private maxTime: number;
96-
97- constructor(P: Params, E: number, x: number, q: number, maxTime: number) {
98- this.P = { ...P, tau: 0 };
99- this.D = sectionDef(P.N)!;
100- this.s = sectionState(this.P, this.D, E, x, q)!;
101- this.verlet = new Verlet(this.P, this.s);
102- this.h = stepFor(this.P, energy(this.P, this.s));
103- this.lyap.start(shadowOf(this.P, this.s), 0);
104- this.maxTime = maxTime;
105- this.points = [x, q];
106- }
107-
108- get done(): boolean {
109- return this.t >= this.maxTime;
110- }
111-
112- // Integrate until the next crossing (or the time limit); returns whether a
113- // crossing was recorded. The crossing is located by linear interpolation in
114- // the step where the crossing mass's displacement changes sign from
115- // negative to positive.
116- advance(): boolean {
117- const { s, h } = this;
118- const { cross, plot } = this.D;
119- while (this.t < this.maxTime) {
120- const c = s.u[cross], x = s.u[plot], q = s.p[plot];
121- this.verlet.step(s, h);
122- this.t += h;
123- this.lyap.step(h, this.t);
124- if (c < 0 && s.u[cross] >= 0) {
125- const f = -c / (s.u[cross] - c);
126- this.points.push(x + f * (s.u[plot] - x), q + f * (s.p[plot] - q));
127- this.crossings++;
128- return true;
129- }
130- }
131- return false;
132- }
133-
134- lambda(): number {
135- return this.lyap.mean();
136- }
82+// The undamped chain from state s, with its Lyapunov shadow. For N = 3 the
83+// orbit stays exactly symmetric (the shadow's offset is symmetric too), so
84+// its exponent is the one within the symmetric family.
85+function flowFrom(P: Params, D: SectionDef, s: State): Flow {
86+ const verlet = new Verlet(P, s);
87+ const h = stepFor(P, energy(P, s));
88+ const lyap = new Lyapunov(Infinity);
89+ lyap.start(shadowOf(P, s), 0);
90+ let t = 0;
91+ return {
92+ get t() {
93+ return t;
94+ },
95+ step: () => {
96+ verlet.step(s, h);
97+ t += h;
98+ lyap.step(h, t);
99+ },
100+ side: () => s.u[D.cross],
101+ x: () => s.u[D.plot],
102+ p: () => s.p[D.plot],
103+ lambda: () => lyap.mean(),
104+ };
137105 }
src/fput-chain/poincare-worker.tsmodified+4−79View file
@@ -1,81 +1,6 @@
1-// Computes Poincare section orbits in a Web Worker (see poincare-core.ts).
2-// Orbits are advanced round-robin, one crossing at a time, and new points are
3-// posted every BATCH_MS so the whole picture fills in together. A new "run"
4-// (energy or parameters changed) replaces the current one; "add" appends an
5-// orbit started at a clicked point.
1+// The chain's section worker (see ../common/section-worker.ts).
62
7-import type { Params } from "./chain";
8-import { CROSSINGS, SectionOrbit } from "./poincare-core";
3+import { serveSections } from "../common/section-worker";
4+import { sectionOf } from "./poincare-core";
95
10-const BATCH_MS = 40;
11-const MAX_TIME = 5000; // s; an orbit that stops crossing ends here
12-
13-export type ToPoincare =
14- | { type: "run"; id: number; P: Params; E: number; seeds: [number, number][]; crossings?: number }
15- | { type: "add"; id: number; seed: [number, number] }
16- | { type: "stop" };
17-
18-export type FromPoincare = {
19- type: "points";
20- id: number;
21- orbits: { index: number; points: Float32Array; lambda: number; done: boolean }[];
22- finished: boolean;
23-};
24-
25-let runId = -1;
26-let P: Params;
27-let E = 0;
28-let orbits: SectionOrbit[] = [];
29-let sent: number[] = []; // points (pairs) already posted, per orbit
30-let doneSent: boolean[] = []; // whether the orbit's end has been posted
31-let running = false;
32-let limit = CROSSINGS; // crossings per orbit in this run
33-
34-onmessage = (e: MessageEvent<ToPoincare>) => {
35- const msg = e.data;
36- if (msg.type === "run") {
37- runId = msg.id;
38- limit = msg.crossings ?? CROSSINGS;
39- P = msg.P;
40- E = msg.E;
41- orbits = msg.seeds.map(([a, b]) => new SectionOrbit(P, E, a, b, MAX_TIME));
42- sent = orbits.map(() => 0);
43- doneSent = orbits.map(() => false);
44- } else if (msg.type === "add") {
45- if (msg.id !== runId) return;
46- orbits.push(new SectionOrbit(P, E, msg.seed[0], msg.seed[1], MAX_TIME));
47- sent.push(0);
48- doneSent.push(false);
49- } else {
50- orbits = [];
51- sent = [];
52- doneSent = [];
53- }
54- if (!running && orbits.length) {
55- running = true;
56- setTimeout(work, 0);
57- }
58-};
59-
60-const active = (o: SectionOrbit) => o.crossings < limit && !o.done;
61-
62-function work(): void {
63- const t0 = performance.now();
64- while (performance.now() - t0 < BATCH_MS && orbits.some(active)) {
65- for (const o of orbits) if (active(o)) o.advance();
66- }
67- const out: FromPoincare["orbits"] = [];
68- orbits.forEach((o, index) => {
69- const n = o.points.length / 2;
70- const done = !active(o);
71- if (n > sent[index] || (done && !doneSent[index])) {
72- out.push({ index, points: Float32Array.from(o.points.slice(2 * sent[index])), lambda: o.lambda(), done });
73- sent[index] = n;
74- doneSent[index] = done;
75- }
76- });
77- const finished = !orbits.some(active);
78- postMessage({ type: "points", id: runId, orbits: out, finished } satisfies FromPoincare);
79- if (finished) running = false;
80- else setTimeout(work, 0);
81-}
6+serveSections(sectionOf);
src/fput-chain/poincare.tsmodified+8−6View file
@@ -17,9 +17,11 @@
1717 // everything (onUserEnergy), so Follow does not override it until Resume.
1818
1919 import type { Params, State } from "./chain";
20-import { Atlas, ATLAS_SIZE, type AtlasEntry } from "./atlas";
21-import { pMax, sectionDef, sectionState, seedGrid, uMax, type SectionDef } from "./poincare-core";
22-import type { FromPoincare, ToPoincare } from "./poincare-worker";
20+import type { AtlasEntry } from "../common/atlas";
21+import { seedGrid } from "../common/section";
22+import type { FromPoincare, ToPoincare } from "../common/section-worker";
23+import { ATLAS_SIZE, newAtlas } from "./atlas";
24+import { pMax, sectionDef, sectionOf, sectionState, uMax, type SectionDef } from "./poincare-core";
2325
2426 const WIDTH = 960, HEIGHT = 480; // CSS pixels
2527 const TOP = 18; // legend row
@@ -68,7 +70,7 @@ export class PoincarePanel {
6870 private followSelect: HTMLSelectElement;
6971 private lastFollow = 0;
7072 private noteDots: number[] = []; // x, q, E triples of the playing note
71- private atlas = new Atlas();
73+ private atlas = newAtlas();
7274 private atlasEntries: AtlasEntry[] = [];
7375 private atlasComputing = false;
7476 private atlasButton: HTMLButtonElement;
@@ -310,7 +312,7 @@ export class PoincarePanel {
310312 this.energyValue.textContent = this.fmtEnergy(this.E);
311313 }
312314
313- private send(msg: ToPoincare): void {
315+ private send(msg: ToPoincare<Params>): void {
314316 if (!this.worker) {
315317 if (msg.type === "stop") return;
316318 this.worker = new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" });
@@ -330,7 +332,7 @@ export class PoincarePanel {
330332 const D = this.D!;
331333 this.uM = uMax(this.P, D, this.E);
332334 this.pM = pMax(this.P, D, this.E, 0);
333- const seeds = seedGrid(this.P, D, this.E, GRID[0], GRID[1]);
335+ const seeds = seedGrid(sectionOf(this.P)!, this.E, GRID[0], GRID[1]);
334336 for (let i = 0; i < seeds.length; i++) this.orbits.push({ points: [], lambda: NaN, done: false, clicked: false });
335337 this.send({ type: "run", id: this.runId, P: this.P, E: this.E, seeds });
336338 this.changed = true;