/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Section panel in src/common/section-panel.ts; each app configures it. Pendulum section slider on a log scale (E_top/1000 to 2 E_top)
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 8874f6ddd85f parent d063b02 Browse files
9 changed files+648−1045
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`; 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.
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, the precomputed atlas and the section panel, with a per-app `Section` and panel configuration) and `style.css`, the shared styles. Each app's `main.ts` imports `../common/style.css` and then its own `style.css`, so app rules come after the shared ones in dev and in the build.
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/section-panel.tsadded+569−0View file
@@ -0,0 +1,569 @@
1+// Poincare section panel (see section.ts): its own energy slider (log scale;
2+// same parameters as the system above, no damping), the allowed region's
3+// outline, orbits seeded on a grid and computed in a worker, click to add an
4+// orbit (or, with "Click to strike" on, also strike the system above from
5+// that exact state), hover to highlight one, and coloring per orbit or by
6+// Lyapunov exponent. The animated system's own crossings are drawn as dark
7+// rings ("you are here"), only those within LIVE_TOLERANCE of the section's
8+// energy (others would fall outside the allowed region), and only while the
9+// system is in the section's family of motions (addLive's `inside`). Follow
10+// "real time" makes the section's energy track the system's as it decays.
11+// Follow "sped up" shows the playing note's crossings instead, as a short
12+// fading trail, with the axes fitted to the note's current energy and, if an
13+// atlas has been computed ("Precompute sections", shown only with Follow
14+// "sped up"), the atlas section nearest that energy behind it (see
15+// showSound). Setting the section's energy by hand pauses everything
16+// (onUserEnergy), so Follow does not override it until Resume.
17+//
18+// Each app configures it (PanelConfig); the page provides the elements
19+// #poincare (a <details>), #pc-plot, #pc-energy (a range input, log10 of the
20+// energy), #pc-energy-value, #pc-follow (off / realtime / sound), #pc-lambda,
21+// #pc-strike and #pc-atlas.
22+
23+import type { Atlas, AtlasEntry } from "./atlas";
24+import { seedGrid, type Section } from "./section";
25+import type { FromPoincare, ToPoincare } from "./section-worker";
26+
27+const WIDTH = 960, HEIGHT = 480; // CSS pixels
28+const TOP = 18; // legend row
29+const PAD = 10;
30+const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
31+const GRID = [6, 7]; // seed grid: across x, across p
32+const LIVE_MAX = 400; // the system's crossings kept
33+const LIVE_TOLERANCE = 0.05; // relative energy difference for drawing the system's crossing
34+const FOLLOW_CHANGE = 0.03; // relative energy change that triggers a recompute
35+const FOLLOW_MIN_MS = 2000; // and at most this often
36+const NOTE_TRAIL = 150; // note crossings shown (each fitted from its own energy)
37+const RESCALE_STEP = 0.02; // sound view: refit after this relative energy change
38+// Lambda coloring is logarithmic between these (1/s): regular orbits measure
39+// ~0.05 or less over a run (lambda -> 0 as the run grows), weakly chaotic ones
40+// ~0.2 to 0.3, strongly chaotic ones ~1 to 1.5.
41+const LAMBDA_LO = 0.01, LAMBDA_HI = 1.5;
42+
43+// One plotted coordinate: values are shown times `scale` in `unit`.
44+export interface Axis {
45+ label: string;
46+ unit: string;
47+ scale: number;
48+ digits: number; // decimals at the cursor
49+ rangeDigits: number; // decimals of the axis range
50+}
51+
52+export interface PanelConfig<P, S> {
53+ section: (P: P) => Section<S> | null; // null: none for these parameters (panel hidden)
54+ axes: (P: P) => [Axis, Axis];
55+ energyRange: (P: P) => [number, number]; // the slider's range (J)
56+ fmtEnergy: (E: number) => string; // with its unit
57+ noun: string; // the system, in the legend ("pendulum at 48 J, not shown")
58+ family?: (P: P) => string | null; // the motions shown, if not all (legend)
59+ outside?: string; // legend when the system has left the family
60+ atlas: Atlas<P>;
61+ atlasSize: number;
62+ makeWorker: () => Worker; // the app's section worker
63+}
64+
65+interface Orbit {
66+ points: number[]; // x, p pairs
67+ lambda: number;
68+ done: boolean;
69+ clicked: boolean;
70+}
71+
72+export class SectionPanel<P, S> {
73+ private config: PanelConfig<P, S>;
74+ private P!: P;
75+ private section: Section<S> | null = null;
76+ private range: [number, number] | null = null; // the slider's, for P
77+ private E: number;
78+ private panel: HTMLDetailsElement;
79+ private canvas: HTMLCanvasElement;
80+ private dpr = window.devicePixelRatio || 1;
81+ private worker: Worker | null = null;
82+ private runId = 0;
83+ private orbits: Orbit[] = [];
84+ private finished = false;
85+ private changed = true;
86+ private hoverOrbit = -1;
87+ private hoverPoint: [number, number] | null = null;
88+ private byLambda = false;
89+ private energyInput: HTMLInputElement;
90+ private energyValue: HTMLElement;
91+ private debounce = 0;
92+ private strikeBox: HTMLInputElement;
93+ private live: number[] = []; // the system's crossings: x, p, E triples
94+ private outside = false; // the system has left the section's family
95+ private followSelect: HTMLSelectElement;
96+ private lastFollow = 0;
97+ private noteDots: number[] = []; // x, p, E triples of the playing note
98+ private atlasEntries: AtlasEntry[] = [];
99+ private atlasComputing = false;
100+ private atlasButton: HTMLButtonElement;
101+ private soundView = false; // showing the sound view (Follow "sped up" during a note)
102+ private soundE = 0; // energy the sound view is fitted to
103+ private onStrike: (s: S, E: number) => void;
104+ private onUserEnergy: () => void;
105+ // Axes (data ranges), set from E.
106+ private xM = 1;
107+ private pM = 1;
108+
109+ // onUserEnergy: the section's energy was set by hand (the page pauses).
110+ constructor(config: PanelConfig<P, S>, P: P, onStrike: (s: S, E: number) => void, onUserEnergy: () => void) {
111+ this.config = config;
112+ this.onStrike = onStrike;
113+ this.onUserEnergy = onUserEnergy;
114+ this.followSelect = document.getElementById("pc-follow") as HTMLSelectElement;
115+ this.followSelect.addEventListener("change", () => {
116+ // Leaving the sound view: back to the section at the slider's energy.
117+ if (this.followMode() !== "sound" && this.soundView) this.run();
118+ this.showAtlasButton();
119+ this.changed = true;
120+ });
121+ this.atlasButton = document.getElementById("pc-atlas") as HTMLButtonElement;
122+ this.atlasButton.addEventListener("click", () => this.computeAtlas());
123+ this.panel = document.getElementById("poincare") as HTMLDetailsElement;
124+ this.strikeBox = document.getElementById("pc-strike") as HTMLInputElement;
125+ this.energyInput = document.getElementById("pc-energy") as HTMLInputElement;
126+ this.energyValue = document.getElementById("pc-energy-value")!;
127+ // The initial energy as written in the page (the input's value is
128+ // already snapped to its step).
129+ this.E = 10 ** Number(this.energyInput.getAttribute("value") ?? this.energyInput.value);
130+ this.canvas = document.createElement("canvas");
131+ this.canvas.width = WIDTH * this.dpr;
132+ this.canvas.height = HEIGHT * this.dpr;
133+ this.canvas.style.cursor = "crosshair";
134+ document.getElementById("pc-plot")!.appendChild(this.canvas);
135+
136+ this.panel.addEventListener("toggle", () => this.update());
137+ this.energyInput.addEventListener("input", () => {
138+ this.onUserEnergy(); // pauses, so Follow does not override this energy
139+ // The recent crossings (rings, note trail) belong to other energies.
140+ this.live = [];
141+ this.noteDots = [];
142+ this.E = 10 ** Number(this.energyInput.value);
143+ this.showEnergy();
144+ // Recompute shortly after the slider stops moving.
145+ clearTimeout(this.debounce);
146+ this.debounce = window.setTimeout(() => this.run(), 150);
147+ });
148+ const lambdaBox = document.getElementById("pc-lambda") as HTMLInputElement;
149+ lambdaBox.addEventListener("change", () => {
150+ this.byLambda = lambdaBox.checked;
151+ this.changed = true;
152+ });
153+ this.canvas.addEventListener("mousemove", (e) => this.onHover(e));
154+ this.canvas.addEventListener("mouseleave", () => {
155+ this.hoverOrbit = -1;
156+ this.hoverPoint = null;
157+ this.changed = true;
158+ });
159+ this.canvas.addEventListener("click", (e) => this.onClick(e));
160+ this.setParams(P);
161+ }
162+
163+ // Offered only where there is a section; computed only while shown and open.
164+ private get active(): boolean {
165+ return this.section !== null && this.panel.open;
166+ }
167+
168+ private update(): void {
169+ this.panel.hidden = this.section === null;
170+ if (this.active) this.run();
171+ else this.send({ type: "stop" });
172+ }
173+
174+ // New parameters: the slider's range follows them, and the slider keeps its
175+ // position (its energy relative to the bottom of the range).
176+ setParams(P: P): void {
177+ const [lo, hi] = this.config.energyRange(P);
178+ if (this.range) this.E = (this.E / this.range[0]) * lo;
179+ this.E = Math.min(hi, Math.max(lo, this.E));
180+ this.range = [lo, hi];
181+ // Steps of about 0.01 in log10 E that fit the range, so both ends can be
182+ // reached (a hair under span / n, so that rounding cannot leave the top
183+ // one step out of reach).
184+ const span = Math.log10(hi) - Math.log10(lo);
185+ this.energyInput.min = String(Math.log10(lo));
186+ this.energyInput.max = String(Math.log10(hi));
187+ this.energyInput.step = String((span / Math.max(1, Math.round(span / 0.01))) * (1 - 1e-12));
188+ this.energyInput.value = String(Math.log10(this.E));
189+ this.showEnergy();
190+ this.P = P;
191+ this.section = this.config.section(P);
192+ this.live = [];
193+ this.noteDots = [];
194+ this.outside = false;
195+ this.config.atlas.cancel();
196+ this.atlasComputing = false;
197+ this.setAtlas([]);
198+ if (this.section) {
199+ this.config.atlas.load(P).then((entries) => {
200+ if (this.P === P) this.setAtlas(entries);
201+ });
202+ }
203+ this.update();
204+ }
205+
206+ followMode(): string {
207+ return this.followSelect.value;
208+ }
209+
210+ // A note started at energy E: with Follow "sped up", switch to the sound view.
211+ noteStarted(E: number): void {
212+ this.noteDots = [];
213+ this.changed = true;
214+ if (this.section && this.followMode() === "sound") this.showSound(E, true);
215+ }
216+
217+ // The playing note's current energy (Follow "sped up").
218+ setNoteEnergy(E: number): void {
219+ if (this.section && this.followMode() === "sound" && E > 0) this.showSound(E);
220+ }
221+
222+ // A crossing of the playing note, as the audio clock reaches it, with the
223+ // note's energy E then (the dot is fitted to the current energy's region
224+ // when drawn, like the section).
225+ addNoteDot(x: number, p: number, E: number): void {
226+ this.noteDots.push(x, p, E);
227+ if (this.noteDots.length > 3 * NOTE_TRAIL) this.noteDots.splice(0, this.noteDots.length - 3 * NOTE_TRAIL);
228+ if (this.followMode() === "sound") this.changed = true;
229+ }
230+
231+ // The system's current energy: with Follow "real time", recompute the
232+ // section at it once it has changed by FOLLOW_CHANGE (at most every
233+ // FOLLOW_MIN_MS), or at once when `now` is set (a new strike).
234+ follow(E: number, now = false): void {
235+ if (!this.section || this.followMode() !== "realtime" || !(E > 0) || Math.abs(E - this.E) < 1e-9 * E) return;
236+ const t = performance.now();
237+ if (!now && (Math.abs(E - this.E) < FOLLOW_CHANGE * this.E || t - this.lastFollow < FOLLOW_MIN_MS)) return;
238+ this.lastFollow = t;
239+ this.E = E;
240+ this.energyInput.value = String(Math.log10(E));
241+ this.showEnergy();
242+ this.run();
243+ }
244+
245+ // A crossing of the animated system at energy E (inside: whether the
246+ // system is in the section's family of motions; crossings outside it are
247+ // not drawn).
248+ addLive(x: number, p: number, E: number, inside = true): void {
249+ this.outside = !inside;
250+ this.changed = true;
251+ if (!inside) return;
252+ this.live.push(x, p, E);
253+ if (this.live.length > 3 * LIVE_MAX) this.live.splice(0, this.live.length - 3 * LIVE_MAX);
254+ }
255+
256+ clearLive(): void {
257+ this.live = [];
258+ this.outside = false;
259+ this.changed = true;
260+ }
261+
262+ private setAtlas(entries: AtlasEntry[]): void {
263+ this.atlasEntries = entries;
264+ this.showAtlasButton();
265+ if (this.soundView) this.showSound(this.soundE, true);
266+ }
267+
268+ // The atlas only matters for Follow "sped up": the button is shown there
269+ // until the atlas is complete.
270+ private showAtlasButton(): void {
271+ const n = this.atlasEntries.length, size = this.config.atlasSize;
272+ this.atlasButton.hidden = this.followMode() !== "sound" || n === size;
273+ this.atlasButton.disabled = this.atlasComputing;
274+ this.atlasButton.textContent = this.atlasComputing
275+ ? `Computing sections ${n}/${size}`
276+ : n ? `Precompute sections (${n}/${size})` : "Precompute sections";
277+ }
278+
279+ private computeAtlas(): void {
280+ this.atlasComputing = true;
281+ this.showAtlasButton();
282+ const P = this.P;
283+ this.config.atlas
284+ .compute(P, (entries) => {
285+ if (this.P === P) this.setAtlas(entries);
286+ })
287+ .finally(() => {
288+ if (this.P !== P) return;
289+ this.atlasComputing = false;
290+ this.showAtlasButton();
291+ });
292+ }
293+
294+ // Extent of the allowed region at energy E: largest |x| and |p|.
295+ private extent(E: number): [number, number] {
296+ return [this.section!.xMax(E), this.section!.pMax(E, 0)];
297+ }
298+
299+ // Sound view at the note's energy E (no computation): axes fitted to E's
300+ // allowed region, with the atlas section nearest E (if any) fitted to it,
301+ // each point (x, p) of the section at E_k drawn at
302+ // (x xmax(E)/xmax(E_k), p pmax(E)/pmax(E_k)). In the linear regime (low
303+ // energy) sections at different energies are scaled copies, so this is
304+ // exact; above it, it is an approximation over the gap between atlas
305+ // energies. Without an atlas only the outline is drawn. Refitted after a
306+ // RESCALE_STEP change.
307+ private showSound(E: number, force = false): void {
308+ if (!(E > 0) || !this.panel.open) return;
309+ if (!force && this.soundView && Math.abs(Math.log(E / this.soundE)) < RESCALE_STEP) return;
310+ if (!this.soundView) {
311+ this.runId++; // ignore the interactive computation's remaining results
312+ this.send({ type: "stop" });
313+ this.soundView = true;
314+ }
315+ this.soundE = E;
316+ const [xm, pm] = this.extent(E);
317+ this.orbits = [];
318+ if (this.atlasEntries.length) {
319+ let k = 0;
320+ for (let i = 1; i < this.atlasEntries.length; i++) {
321+ if (Math.abs(Math.log(this.atlasEntries[i].E / E)) < Math.abs(Math.log(this.atlasEntries[k].E / E))) k = i;
322+ }
323+ const entry = this.atlasEntries[k];
324+ const [xk, pk] = this.extent(entry.E);
325+ const fx = xm / xk, fp = pm / pk;
326+ this.orbits = entry.orbits.map((o) => {
327+ const points = new Array<number>(o.points.length);
328+ for (let i = 0; i < o.points.length; i += 2) {
329+ points[i] = o.points[i] * fx;
330+ points[i + 1] = o.points[i + 1] * fp;
331+ }
332+ return { points, lambda: o.lambda, done: true, clicked: false };
333+ });
334+ }
335+ this.E = E;
336+ this.energyInput.value = String(Math.log10(E));
337+ this.showEnergy();
338+ this.xM = xm;
339+ this.pM = pm;
340+ this.finished = true;
341+ this.changed = true;
342+ }
343+
344+ private showEnergy(): void {
345+ this.energyValue.textContent = this.config.fmtEnergy(this.E);
346+ }
347+
348+ private send(msg: ToPoincare<P>): void {
349+ if (!this.worker) {
350+ if (msg.type === "stop") return;
351+ this.worker = this.config.makeWorker();
352+ this.worker.onmessage = (e: MessageEvent<FromPoincare>) => this.receive(e.data);
353+ }
354+ this.worker.postMessage(msg);
355+ }
356+
357+ // Start computing the section at the current energy.
358+ private run(): void {
359+ if (!this.active) return;
360+ this.soundView = false;
361+ this.soundE = 0;
362+ this.runId++;
363+ this.orbits = [];
364+ this.finished = false;
365+ [this.xM, this.pM] = this.extent(this.E);
366+ const seeds = seedGrid(this.section!, this.E, GRID[0], GRID[1]);
367+ for (let i = 0; i < seeds.length; i++) this.orbits.push({ points: [], lambda: NaN, done: false, clicked: false });
368+ this.send({ type: "run", id: this.runId, P: this.P, E: this.E, seeds });
369+ this.changed = true;
370+ }
371+
372+ private receive(msg: FromPoincare): void {
373+ if (msg.id !== this.runId) return; // from an earlier energy
374+ for (const o of msg.orbits) {
375+ const orbit = this.orbits[o.index];
376+ if (!orbit) continue;
377+ for (const v of o.points) orbit.points.push(v);
378+ orbit.lambda = o.lambda;
379+ orbit.done = o.done;
380+ }
381+ this.finished = msg.finished;
382+ this.changed = true;
383+ }
384+
385+ // Data <-> CSS pixel coordinates.
386+ private px(x: number): number {
387+ return PAD + ((x + this.xM * 1.04) / (2 * this.xM * 1.04)) * (WIDTH - 2 * PAD);
388+ }
389+ private py(p: number): number {
390+ return TOP + PAD + ((this.pM * 1.06 - p) / (2 * this.pM * 1.06)) * (HEIGHT - TOP - 2 * PAD);
391+ }
392+ private fromPx(x: number, y: number): [number, number] {
393+ const u = ((x - PAD) / (WIDTH - 2 * PAD)) * 2 * this.xM * 1.04 - this.xM * 1.04;
394+ const p = this.pM * 1.06 - ((y - TOP - PAD) / (HEIGHT - TOP - 2 * PAD)) * 2 * this.pM * 1.06;
395+ return [u, p];
396+ }
397+ private mouse(e: MouseEvent): [number, number] {
398+ const r = this.canvas.getBoundingClientRect();
399+ return [((e.clientX - r.left) / r.width) * WIDTH, ((e.clientY - r.top) / r.height) * HEIGHT];
400+ }
401+
402+ private onHover(e: MouseEvent): void {
403+ const [x, y] = this.mouse(e);
404+ this.hoverPoint = this.fromPx(x, y);
405+ let best = 8 * 8; // within 8 px
406+ this.hoverOrbit = -1;
407+ this.orbits.forEach((o, k) => {
408+ for (let i = 0; i < o.points.length; i += 2) {
409+ const dx = this.px(o.points[i]) - x, dy = this.py(o.points[i + 1]) - y;
410+ if (dx * dx + dy * dy < best) {
411+ best = dx * dx + dy * dy;
412+ this.hoverOrbit = k;
413+ }
414+ }
415+ });
416+ this.changed = true;
417+ }
418+
419+ // Add an orbit starting at the clicked point, if it is reachable, and with
420+ // "Click to strike" on, strike the system from that state.
421+ private onClick(e: MouseEvent): void {
422+ if (!this.section) return;
423+ const [x, y] = this.mouse(e);
424+ const [u, p] = this.fromPx(x, y);
425+ const state = this.section.state(this.E, u, p);
426+ if (!state) return;
427+ if (this.strikeBox.checked) this.onStrike(state, this.E);
428+ if (!this.worker || this.soundView) return; // the sound view is not computed here
429+ this.orbits.push({ points: [], lambda: NaN, done: false, clicked: true });
430+ this.finished = false;
431+ this.send({ type: "add", id: this.runId, seed: [u, p] });
432+ this.changed = true;
433+ }
434+
435+ private color(k: number, alpha: number): string {
436+ const o = this.orbits[k];
437+ if (this.byLambda) {
438+ if (!Number.isFinite(o.lambda)) return `rgba(150, 150, 150, ${alpha})`;
439+ // Blue (regular) -> amber -> red (chaotic).
440+ // (A regular orbit's lambda can come out slightly negative.)
441+ const u = o.lambda <= LAMBDA_LO ? 0 : Math.min(1, Math.log(o.lambda / LAMBDA_LO) / Math.log(LAMBDA_HI / LAMBDA_LO));
442+ const ramp = [[47, 93, 138], [227, 162, 26], [194, 65, 45]];
443+ const s = u * 2, i = Math.min(1, Math.floor(s)), w = s - i;
444+ const c = ramp[i].map((a, j) => Math.round(a + (ramp[i + 1][j] - a) * w));
445+ return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha})`;
446+ }
447+ const hue = (k * 137.508) % 360; // golden angle: neighbouring orbits differ
448+ return `hsla(${hue}, 45%, 42%, ${alpha})`;
449+ }
450+
451+ draw(): void {
452+ if (!this.active || !this.changed) return;
453+ this.changed = false;
454+ const S = this.section!;
455+ const [ax, ap] = this.config.axes(this.P);
456+ const ctx = this.canvas.getContext("2d")!;
457+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
458+ ctx.clearRect(0, 0, WIDTH, HEIGHT);
459+
460+ // Axes through 0 and the allowed region's outline at this energy (inside
461+ // the axes while the slider moves, until the recompute).
462+ ctx.strokeStyle = "#ecece6";
463+ ctx.lineWidth = 1;
464+ ctx.beginPath();
465+ ctx.moveTo(this.px(0), TOP);
466+ ctx.lineTo(this.px(0), HEIGHT);
467+ ctx.moveTo(0, this.py(0));
468+ ctx.lineTo(WIDTH, this.py(0));
469+ ctx.stroke();
470+ ctx.strokeStyle = "#b9b8b0";
471+ ctx.beginPath();
472+ const xm = S.xMax(this.E);
473+ for (const sign of [1, -1]) {
474+ for (let i = 0; i <= 200; i++) {
475+ const u = -xm + (2 * xm * i) / 200;
476+ const x = this.px(u), y = this.py(sign * S.pMax(this.E, u));
477+ if (i === 0) ctx.moveTo(x, y);
478+ else ctx.lineTo(x, y);
479+ }
480+ }
481+ ctx.stroke();
482+
483+ // Orbits; the hovered one on top and the others faded while hovering.
484+ const order = this.orbits.map((_, k) => k).filter((k) => k !== this.hoverOrbit);
485+ if (this.hoverOrbit >= 0) order.push(this.hoverOrbit);
486+ for (const k of order) {
487+ const o = this.orbits[k];
488+ const hovered = k === this.hoverOrbit;
489+ ctx.fillStyle = this.color(k, this.hoverOrbit >= 0 && !hovered ? 0.25 : 0.85);
490+ const size = hovered || o.clicked ? 2.5 : 1.8;
491+ for (let i = 0; i < o.points.length; i += 2) {
492+ ctx.fillRect(this.px(o.points[i]) - size / 2, this.py(o.points[i + 1]) - size / 2, size, size);
493+ }
494+ }
495+
496+ if (this.followMode() === "sound") {
497+ // The playing note's latest crossings: a fading trail, the newest
498+ // larger, each fitted from its own energy to the current one.
499+ const n = this.noteDots.length / 3;
500+ for (let k = 0; k < n; k++) {
501+ const latest = k === n - 1;
502+ const [xk, pk] = this.extent(this.noteDots[3 * k + 2]);
503+ ctx.globalAlpha = 0.2 + (0.8 * (k + 1)) / n;
504+ ctx.beginPath();
505+ ctx.arc(
506+ this.px((this.noteDots[3 * k] * this.xM) / xk),
507+ this.py((this.noteDots[3 * k + 1] * this.pM) / pk),
508+ latest ? 6 : 3.5, 0, 2 * Math.PI,
509+ );
510+ ctx.fillStyle = "#fff";
511+ ctx.fill();
512+ ctx.strokeStyle = "#c2412d";
513+ ctx.lineWidth = latest ? 2.5 : 1.5;
514+ ctx.stroke();
515+ }
516+ ctx.globalAlpha = 1;
517+ } else {
518+ // The system's own crossings at about this energy: dark rings on
519+ // white, the latest larger.
520+ ctx.strokeStyle = "#23262b";
521+ ctx.fillStyle = "#fff";
522+ for (let i = 0; i < this.live.length; i += 3) {
523+ if (Math.abs(this.live[i + 2] - this.E) > LIVE_TOLERANCE * this.E) continue;
524+ const latest = i === this.live.length - 3;
525+ ctx.beginPath();
526+ ctx.arc(this.px(this.live[i]), this.py(this.live[i + 1]), latest ? 6 : 3.5, 0, 2 * Math.PI);
527+ ctx.fill();
528+ ctx.lineWidth = latest ? 2.5 : 1.5;
529+ ctx.stroke();
530+ }
531+ }
532+
533+ // Legend row.
534+ const val = (a: Axis, v: number, digits: number) => `${(v * a.scale).toFixed(digits)} ${a.unit}`;
535+ ctx.font = FONT;
536+ ctx.textBaseline = "top";
537+ ctx.fillStyle = "#2f5d8a";
538+ let legend: string;
539+ if (this.hoverOrbit >= 0) {
540+ const o = this.orbits[this.hoverOrbit];
541+ const kind = !Number.isFinite(o.lambda) ? "" : o.lambda > 0.1 ? "chaotic" : "regular";
542+ legend = `orbit ${this.hoverOrbit + 1} · λ ${Number.isFinite(o.lambda) ? o.lambda.toFixed(3) : "–"} 1/s ${kind ? `(${kind})` : ""}`;
543+ } else if (this.hoverPoint) {
544+ const [u, p] = this.hoverPoint;
545+ legend = `${ax.label} ${val(ax, u, ax.digits)} · ${ap.label} ${val(ap, p, ap.digits)}`;
546+ if (S.state(this.E, u, p)) legend += " · click to add an orbit here";
547+ } else {
548+ legend = `${ax.label} (${ax.unit}) across · ${ap.label} (${ap.unit}) up · ${this.orbits.length} orbits`;
549+ const family = this.config.family?.(this.P);
550+ if (family) legend += ` · ${family}`;
551+ // Say why the system's own crossings are missing.
552+ const lastE = this.live[this.live.length - 1];
553+ if (this.followMode() === "sound") {
554+ if (this.soundView && !this.atlasEntries.length) legend += " · no precomputed sections";
555+ } else if (this.outside) legend += ` · ${this.config.outside ?? `${this.config.noun} outside the family`}, not shown`;
556+ else if (lastE > 0 && Math.abs(lastE - this.E) > LIVE_TOLERANCE * this.E) {
557+ legend += ` · ${this.config.noun} at ${this.config.fmtEnergy(lastE)}, not shown`;
558+ }
559+ }
560+ ctx.fillText(legend, 4, 3);
561+ ctx.fillStyle = "#999";
562+ ctx.textAlign = "right";
563+ ctx.fillText(
564+ `${this.finished || this.soundView ? "" : "computing… · "}±${val(ax, this.xM, ax.rangeDigits)} · ±${val(ap, this.pM, ap.rangeDigits)}`,
565+ WIDTH - 4, 3,
566+ );
567+ ctx.textAlign = "left";
568+ }
569+}
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 (`../common/atlas.ts`, configured in `atlas.ts`): sections at 24 energies log-spaced from 0.4% of to twice the both-inverted energy, 42 orbits x 150 crossings each, highest energy first, in its own worker (~80 s in the desktop app's browser pane), stored in IndexedDB per parameter set and deleted after 7 days. "Color by λ" colors orbits by Lyapunov exponent (log scale, blue regular to red chaotic). The "Sped up · listen" panel (above the section) starts closed; opening it switches the sound engine on (closing it switches it off) without playing anything; a line of text says to Strike (or click the section) until the first note. While it is open, every Strike plays a note: a separate, sped-up copy of the motion from the same strike; a damped note ends once it has decayed by 60 dB. Save WAV saves the current or last note as heard (as received from the audio worker, up to 120 s; 16-bit mono PCM, peak at 0.9; `../common/wav.ts`; named e.g. double-pendulum-50.0J-x2-100Hz.wav). The mute button (speaker icon; when muted it is tinted red and reads "Muted") starts muted and unmutes the first time the panel is opened; muted notes are still computed and shown, just silent. The panel also has "Signal" (which coordinate is heard), volume, pitch (where the small-amplitude lower mode lands, 30 to 1000 Hz, default 100 Hz; the readout gives both modes' pitches), and optional whole-note displays that do not scroll (strike at the left edge, end of the note at the right): a spectrogram of the sound (frequency axis up to just above the highest frequency that comes within 40 dB of the peak at any time in the note, so the broadband start of a chaotic note stays in view), the largest Lyapunov exponent of the motion being heard (10 s window, physical time), its energy (log scale, so the exponential decay is a straight line), and a scatter of lambda against energy accumulated over notes (log energy axis, since a note spends most of its time at low energy; colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Each card and panel (and Parameters) has a small "?" button that opens a short help dialog (`../common/help.ts`; the texts are `<template>`s in `index.html`). Hovering over any chart (real time or sped up) shows a time cursor and the values there; a click pins it. Charts in the same card share the cursor. The time axis spans the predicted note length ((tau/2) ln 1e6 of physical time, divided by the speedup), is extended if the note runs longer, and is fitted to the note when it ends.
5+Current state: the pendulum hangs at rest until Strike, which starts it from hanging rest with the energy on the slider (default 50 J), plus a random relative jitter of up to 1e-9 in each angle and momentum (so chaotic strikes differ from one another while regular ones sound alike); Pause (or the space bar) pauses everything: the animation and the sound (the audio clock is suspended, so the note and every display fed by it stop where they are); Resume or Strike continues. The Ghost toggle adds a second pendulum (translucent orange) started from the same state with the outer arm's angle offset by 10^-12 to 10^-1 rad (default 10^-10), restarted on Strike or "Restart ghost", with a readout of the angular separation and the time since the ghost started: it grows exponentially when the motion is chaotic and stays tiny when it is regular. Charts "Separation" (ghost separation, log scale, last 30 s; its chip shows only with the ghost on) and "Lyapunov" (largest Lyapunov exponent over a 10 s window and since the strike, last 30 s; the first 5 s after a strike, where the values are large, are left out of the y range, here and in the sound's Lyapunov chart). Chart legends show 4 significant digits, ranges 3. Parameters (masses, lengths, g, decay time, presets) fold out underneath. The plots card is labelled "Real time" and the "Sped up · listen" panel's header says how much faster its run is (e.g. the whole decay, 262x faster: 62 min of motion in 14 s), since the two run on different clocks; the Follow options read "real time" and "sped up". Toggle chips choose the plots: live traces of energies, energy balance error, angles, momenta and bob positions. Each chart shows its time span at the bottom right ("last 10 s" when scrolling; the note length in the sound panel). The Poincare section panel (closed by default) has its own energy slider (log scale, from E_top / 1000 to 2 E_top, 59 mJ to 117.7 J at the defaults, where E_top is the both-inverted energy; default 17 J; on new parameters it keeps its position) and uses the same masses and lengths, without damping: each time the inner arm swings down through vertical moving right, the outer arm's angle and momentum are plotted. 42 orbits start on a grid inside the allowed region (outlined) and leave 300 dots each, computed in a Web Worker and drawn as they arrive; regular orbits trace closed curves, chaotic ones scatter. Hover to pick out an orbit (with its Lyapunov exponent), click inside the outline to start a new orbit there (with "Click to strike" on, the default, this also strikes the pendulum above from that exact state, at the section's energy, without jitter). The animated pendulum's own crossings are drawn as dark rings (the latest larger), so you can see which orbit it is on; only those within 5% of the section's energy are drawn (others would fall outside the allowed region; the legend then says "pendulum at 48 J, not shown"), and they clear on each strike. Follow stays selected; setting the section's energy by hand pauses everything, clears the recent crossings (rings and note trail) and shows the section at that energy, and Follow takes over again on resume. Follow: "real time" makes the section's energy track the pendulum's as it decays (recomputed after a 3% change, at most every 2 s); "sped up" (which opens the "Sped up · listen" panel) shows the playing note's crossings (sent by the audio worker with the samples, drawn as the audio clock reaches them) as a fading trail of the last 150, with the axes fitted to the note's current energy (zooming as it decays) and nothing computed live. Without an atlas only the outline is drawn behind the trail; with one, the atlas section nearest the current energy, fitted to the current allowed region (each point scaled by the ratio of the regions' extents in th2 and p2: exact at low energy, where sections are scaled copies, an approximation over the <~15% gaps between atlas energies). Trail dots are fitted the same way from their own energies. "Precompute sections" (shown only with Follow "sped up", until the atlas is complete) builds the atlas (`../common/atlas.ts`, configured in `atlas.ts`): sections at 24 energies log-spaced from 0.4% of to twice the both-inverted energy, 42 orbits x 150 crossings each, highest energy first, in its own worker (~80 s in the desktop app's browser pane), stored in IndexedDB per parameter set and deleted after 7 days. "Color by λ" colors orbits by Lyapunov exponent (log scale, blue regular to red chaotic). The "Sped up · listen" panel (above the section) starts closed; opening it switches the sound engine on (closing it switches it off) without playing anything; a line of text says to Strike (or click the section) until the first note. While it is open, every Strike plays a note: a separate, sped-up copy of the motion from the same strike; a damped note ends once it has decayed by 60 dB. Save WAV saves the current or last note as heard (as received from the audio worker, up to 120 s; 16-bit mono PCM, peak at 0.9; `../common/wav.ts`; named e.g. double-pendulum-50.0J-x2-100Hz.wav). The mute button (speaker icon; when muted it is tinted red and reads "Muted") starts muted and unmutes the first time the panel is opened; muted notes are still computed and shown, just silent. The panel also has "Signal" (which coordinate is heard), volume, pitch (where the small-amplitude lower mode lands, 30 to 1000 Hz, default 100 Hz; the readout gives both modes' pitches), and optional whole-note displays that do not scroll (strike at the left edge, end of the note at the right): a spectrogram of the sound (frequency axis up to just above the highest frequency that comes within 40 dB of the peak at any time in the note, so the broadband start of a chaotic note stays in view), the largest Lyapunov exponent of the motion being heard (10 s window, physical time), its energy (log scale, so the exponential decay is a straight line), and a scatter of lambda against energy accumulated over notes (log energy axis, since a note spends most of its time at low energy; colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Each card and panel (and Parameters) has a small "?" button that opens a short help dialog (`../common/help.ts`; the texts are `<template>`s in `index.html`). Hovering over any chart (real time or sped up) shows a time cursor and the values there; a click pins it. Charts in the same card share the cursor. The time axis spans the predicted note length ((tau/2) ln 1e6 of physical time, divided by the speedup), is extended if the note runs longer, and is fitted to the note when it ends.
66
77 Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/double-pendulum/ (part of the chaos-apps site; see the top-level README).
88
@@ -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` (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).
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` (the section panel's configuration; the panel is `../common/section-panel.ts`), `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/index.htmlmodified+2−2View file
@@ -97,11 +97,11 @@
9797 <summary><span class="label">Poincaré section</span> <button type="button" class="help" data-help="section" title="About the Poincaré section" aria-label="About the Poincaré section">?</button></summary>
9898 <div class="row">
9999 <span class="label">Energy</span>
100- <input id="pc-energy" type="range" value="17" min="0" max="120" step="0.1" aria-label="Section energy" />
100+ <input id="pc-energy" type="range" min="-1.23" max="2.07" step="0.01" value="1.2304489213782739" aria-label="Section energy (log scale)" />
101101 <span id="pc-energy-value" class="num">17.0 J</span>
102102 <label title="Real time: the section's energy follows the pendulum's as it decays. Sped up: the sped-up run's crossings are shown as it plays, on the section at its current energy."><span class="label">Follow</span> <select id="pc-follow">
103103 <option value="off">off</option>
104- <option value="pendulum">real time</option>
104+ <option value="realtime">real time</option>
105105 <option value="sound">sped up</option>
106106 </select></label>
107107 <button id="pc-atlas" type="button" title="Compute sections at 24 energies (about a minute or two), so Follow sped up can show the orbits behind the decaying note's trail. Kept in this browser for 7 days." hidden>Precompute sections</button>
src/double-pendulum/main.tsmodified+4−4View file
@@ -4,7 +4,7 @@ import { AudioOut } from "./audio";
44 import { END_RATIO, LAMBDA_EVERY } from "./audio-shared";
55 import { newCursor } from "../common/cursor";
66 import { setupHelp } from "../common/help";
7-import { PoincarePanel, formatEnergy } from "./poincare";
7+import { formatEnergy, newPoincarePanel } from "./poincare";
88 import { Lyapunov } from "../common/lyapunov";
99 import { shadowOf } from "./lyapunov";
1010 import { ScatterPlot } from "../common/scatter";
@@ -497,7 +497,7 @@ function strikeFrom(y: Float64Array, E: number) {
497497 // Restart everything that follows a strike.
498498 function afterStrike() {
499499 poincare.clearLive();
500- if (poincare.followMode() === "pendulum") poincare.follow(energy(P, sim.y), true);
500+ if (poincare.followMode() === "realtime") poincare.follow(energy(P, sim.y), true);
501501 resetGhost();
502502 lyap.start(shadowOf(sim), sim.t);
503503 setRunning(true);
@@ -543,7 +543,7 @@ document.getElementById("param-form")!.addEventListener("submit", (e) => {
543543 // Poincare section panel (own energy; same parameters, no damping).
544544 // Setting the section's energy by hand pauses everything (Follow resumes
545545 // tracking when the simulation resumes).
546-const poincare = new PoincarePanel(P, strikeFrom, () => {
546+const poincare = newPoincarePanel(P, strikeFrom, () => {
547547 if (running) setRunning(false);
548548 });
549549
@@ -691,7 +691,7 @@ function frame(now: number) {
691691 pending -= H;
692692 traces.sample(sim.t);
693693 }
694- if (running && poincare.followMode() === "pendulum") poincare.follow(energy(P, sim.y));
694+ if (running && poincare.followMode() === "realtime") poincare.follow(energy(P, sim.y));
695695 draw();
696696 traces.draw();
697697 feedAudioSpec();
src/double-pendulum/poincare.tsmodified+31−504View file
@@ -1,512 +1,39 @@
1-// Poincare section panel: its own energy slider (same parameters as the
2-// pendulum above, no damping), the allowed region's outline, orbits seeded on
3-// a grid and computed in a worker, click to add an orbit (or, with "Click to
4-// strike" on, also strike the pendulum above from that exact state), hover to
5-// highlight one, and coloring per orbit or by Lyapunov exponent. The animated
6-// pendulum's own crossings are drawn as dark rings ("you are here"), only
7-// those within LIVE_TOLERANCE of the section's energy (others would fall
8-// outside the allowed region); Follow "Pendulum" makes the section's energy
9-// track the pendulum's as it decays.
10-// Follow "Sound" shows the playing note's crossings instead, as a short
11-// fading trail, with the axes fitted to the note's current energy and, if an
12-// atlas has been computed ("Precompute sections", shown only with Follow
13-// "Sound"), the atlas section nearest that
14-// energy behind it (see showSound).
1+// The pendulum's Poincare section panel (see ../common/section-panel.ts):
2+// plotted as the outer arm's (th2, p2) each time the inner arm passes
3+// straight down moving right (see poincare-core.ts).
154
5+import { SectionPanel } from "../common/section-panel";
6+import { newAtlas, ATLAS_SIZE } from "./atlas";
167 import { topEnergy, type Params } from "./pendulum";
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";
8+import { sectionOf } from "./poincare-core";
229
23-const WIDTH = 960, HEIGHT = 480; // CSS pixels
24-const TOP = 18; // legend row
25-const PAD = 10;
26-const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
27-const GRID = [6, 7]; // seed grid: across th2, across p2
28-const LIVE_MAX = 400; // pendulum crossings kept
29-const LIVE_TOLERANCE = 0.05; // relative energy difference for drawing a pendulum crossing
30-const FOLLOW_CHANGE = 0.03; // relative energy change that triggers a recompute
31-const FOLLOW_MIN_MS = 2000; // and at most this often
32-const NOTE_TRAIL = 150; // note crossings shown (each fitted from its own energy)
33-const RESCALE_STEP = 0.02; // sound view: refit after this relative energy change
34-// Lambda coloring is logarithmic between these (1/s): regular orbits measure
35-// ~0.01 or less over a run (lambda -> 0 as the run grows), weakly chaotic ones
36-// ~0.2 to 0.3, strongly chaotic ones ~1 to 1.5.
37-const LAMBDA_LO = 0.01, LAMBDA_HI = 1.5;
38-
39-interface Orbit {
40- points: number[]; // th2, p2 pairs
41- lambda: number;
42- done: boolean;
43- clicked: boolean;
44-}
45-
46-export class PoincarePanel {
47- private P: Params;
48- private E: number;
49- private canvas: HTMLCanvasElement;
50- private dpr = window.devicePixelRatio || 1;
51- private worker: Worker | null = null;
52- private runId = 0;
53- private orbits: Orbit[] = [];
54- private finished = false;
55- private changed = true;
56- private hoverOrbit = -1;
57- private hoverPoint: [number, number] | null = null;
58- private byLambda = false;
59- private open = false;
60- private energyInput: HTMLInputElement;
61- private energyValue: HTMLElement;
62- private debounce = 0;
63- private strikeBox: HTMLInputElement;
64- private followSelect: HTMLSelectElement;
65- private noteDots: number[] = []; // th2, p2 pairs of the playing note
66- private atlas = newAtlas();
67- private atlasEntries: AtlasEntry[] = [];
68- private soundView = false; // showing the sound view (Follow "Sound" during a note)
69- private soundE = 0; // energy the sound view is fitted to
70- private atlasComputing = false;
71- private atlasButton: HTMLButtonElement;
72- private lastFollow = 0;
73- private live: number[] = []; // the pendulum's crossings: th2, p2, E triples
74- private onStrike: (y: Float64Array, E: number) => void;
75- private onUserEnergy: () => void;
76- // Axes (data ranges), set from E.
77- private thMax = 1;
78- private pMax = 1;
79-
80- // onUserEnergy: the section's energy was set by hand (the page pauses).
81- constructor(P: Params, onStrike: (y: Float64Array, E: number) => void, onUserEnergy: () => void) {
82- this.P = P;
83- this.onStrike = onStrike;
84- this.onUserEnergy = onUserEnergy;
85- this.strikeBox = document.getElementById("pc-strike") as HTMLInputElement;
86- this.followSelect = document.getElementById("pc-follow") as HTMLSelectElement;
87- this.followSelect.addEventListener("change", () => {
88- // Leaving the sound view: back to the section at the slider's energy.
89- if (this.followMode() !== "sound" && this.soundView && this.open) this.run();
90- this.showAtlasButton();
91- this.changed = true;
92- });
93- this.atlasButton = document.getElementById("pc-atlas") as HTMLButtonElement;
94- this.atlasButton.addEventListener("click", () => this.computeAtlas());
95- this.energyInput = document.getElementById("pc-energy") as HTMLInputElement;
96- this.energyValue = document.getElementById("pc-energy-value")!;
97- this.E = Number(this.energyInput.value);
98- this.canvas = document.createElement("canvas");
99- this.canvas.width = WIDTH * this.dpr;
100- this.canvas.height = HEIGHT * this.dpr;
101- this.canvas.style.cursor = "crosshair";
102- document.getElementById("pc-plot")!.appendChild(this.canvas);
103-
104- const panel = document.getElementById("poincare") as HTMLDetailsElement;
105- panel.addEventListener("toggle", () => {
106- this.open = panel.open;
107- if (this.open) this.run();
108- else this.send({ type: "stop" });
109- });
110- this.energyInput.addEventListener("input", () => {
111- this.onUserEnergy(); // pauses, so Follow does not override this energy
112- // The recent crossings (pendulum rings, note trail) belong to other energies.
113- this.live = [];
114- this.noteDots = [];
115- this.E = Number(this.energyInput.value);
116- this.showEnergy();
117- // Recompute shortly after the slider stops moving.
118- clearTimeout(this.debounce);
119- this.debounce = window.setTimeout(() => this.run(), 150);
120- });
121- const lambdaBox = document.getElementById("pc-lambda") as HTMLInputElement;
122- lambdaBox.addEventListener("change", () => {
123- this.byLambda = lambdaBox.checked;
124- this.changed = true;
125- });
126- this.canvas.addEventListener("mousemove", (e) => this.onHover(e));
127- this.canvas.addEventListener("mouseleave", () => {
128- this.hoverOrbit = -1;
129- this.hoverPoint = null;
130- this.changed = true;
131- });
132- this.canvas.addEventListener("click", (e) => this.onClick(e));
133- this.setParams(P);
134- }
135-
136- // New pendulum parameters: rescale the energy slider (keeping its fraction
137- // of the both-inverted energy) and recompute if the panel is open.
138- setParams(P: Params): void {
139- const frac = this.E / topEnergy(this.P);
140- this.P = P;
141- this.atlas.cancel();
142- this.atlasComputing = false;
143- this.setAtlas([]);
144- this.atlas.load(P).then((entries) => {
145- if (this.P === P) this.setAtlas(entries);
146- });
147- const eMax = 2 * topEnergy(P);
148- this.energyInput.max = String(eMax);
149- this.energyInput.step = String(eMax / 1200);
150- this.E = Math.min(eMax, frac * topEnergy(P));
151- this.energyInput.value = String(this.E);
152- this.showEnergy();
153- if (this.open) this.run();
154- }
155-
156- // A crossing of the animated pendulum (inner arm down, moving right) at energy E.
157- addLive(th2: number, p2: number, E: number): void {
158- this.live.push(th2, p2, E);
159- if (this.live.length > 3 * LIVE_MAX) this.live.splice(0, this.live.length - 3 * LIVE_MAX);
160- this.changed = true;
161- }
162-
163- clearLive(): void {
164- this.live = [];
165- this.changed = true;
166- }
167-
168- followMode(): string {
169- return this.followSelect.value;
170- }
171-
172- // A note started at energy E: with Follow "Sound", switch to the sound view.
173- noteStarted(E: number): void {
174- this.noteDots = [];
175- this.changed = true;
176- if (this.followMode() === "sound") this.showSound(E, true);
177- }
178-
179- // The playing note's current energy (Follow "Sound").
180- setNoteEnergy(E: number): void {
181- if (this.followMode() === "sound" && E > 0) this.showSound(E);
182- }
183-
184- private setAtlas(entries: AtlasEntry[]): void {
185- this.atlasEntries = entries;
186- this.showAtlasButton();
187- if (this.soundView) this.showSound(this.soundE, true);
188- }
189-
190- // The atlas only matters for Follow "Sound": the button is shown there
191- // until the atlas is complete.
192- private showAtlasButton(): void {
193- const n = this.atlasEntries.length;
194- this.atlasButton.hidden = this.followMode() !== "sound" || n === ATLAS_SIZE;
195- this.atlasButton.disabled = this.atlasComputing;
196- this.atlasButton.textContent = this.atlasComputing
197- ? `Computing sections ${n}/${ATLAS_SIZE}`
198- : n ? `Precompute sections (${n}/${ATLAS_SIZE})` : "Precompute sections";
199- }
200-
201- private computeAtlas(): void {
202- this.atlasComputing = true;
203- this.showAtlasButton();
204- const P = this.P;
205- this.atlas
206- .compute(P, (entries) => {
207- if (this.P === P) this.setAtlas(entries);
208- })
209- .finally(() => {
210- if (this.P !== P) return;
211- this.atlasComputing = false;
212- this.showAtlasButton();
213- });
214- }
215-
216- // Extent of the allowed region at energy E: largest |th2| and |p2|.
217- private extent(E: number): [number, number] {
218- return [thetaMax(this.P, E), p2Max(this.P, E, 0)];
219- }
220-
221- // Sound view at the note's energy E (no computation): axes fitted to E's
222- // allowed region, with the atlas section nearest E (if any) fitted to it,
223- // each point (th2, p2) of the section at E_k drawn at
224- // (th2 tmax(E)/tmax(E_k), p2 pmax(E)/pmax(E_k)). In the linear regime (low
225- // energy) sections at different energies are scaled copies, so this is
226- // exact; above it, it is an approximation over the gap between atlas
227- // energies (< ~15%). Without an atlas only the outline is drawn. Refitted
228- // after a RESCALE_STEP change.
229- private showSound(E: number, force = false): void {
230- if (!(E > 0)) return;
231- if (!force && this.soundView && Math.abs(Math.log(E / this.soundE)) < RESCALE_STEP) return;
232- if (!this.soundView) {
233- this.runId++; // ignore the interactive computation's remaining results
234- this.send({ type: "stop" });
235- this.soundView = true;
236- }
237- this.soundE = E;
238- const [tm, pm] = this.extent(E);
239- this.orbits = [];
240- if (this.atlasEntries.length) {
241- let k = 0;
242- for (let i = 1; i < this.atlasEntries.length; i++) {
243- if (Math.abs(Math.log(this.atlasEntries[i].E / E)) < Math.abs(Math.log(this.atlasEntries[k].E / E))) k = i;
244- }
245- const entry = this.atlasEntries[k];
246- const [tk, pk] = this.extent(entry.E);
247- const ft = tm / tk, fp = pm / pk;
248- this.orbits = entry.orbits.map((o) => {
249- const points = new Array<number>(o.points.length);
250- for (let i = 0; i < o.points.length; i += 2) {
251- points[i] = o.points[i] * ft;
252- points[i + 1] = o.points[i + 1] * fp;
253- }
254- return { points, lambda: o.lambda, done: true, clicked: false };
255- });
256- }
257- this.E = E;
258- this.energyInput.value = String(E);
259- this.showEnergy();
260- this.thMax = tm;
261- this.pMax = pm;
262- this.finished = true;
263- this.changed = true;
264- }
265-
266- // A crossing of the playing note, as the audio clock reaches it, with the
267- // note's energy E then (the dot is fitted to the current energy's region
268- // when drawn, like the section).
269- addNoteDot(th2: number, p2: number, E: number): void {
270- this.noteDots.push(th2, p2, E);
271- if (this.noteDots.length > 3 * NOTE_TRAIL) this.noteDots.splice(0, this.noteDots.length - 3 * NOTE_TRAIL);
272- if (this.followMode() === "sound") this.changed = true;
273- }
274-
275- private setEnergy(E: number): void {
276- if (!(E > 0) || Math.abs(E - this.E) < 1e-9 * E) return;
277- this.E = E;
278- this.energyInput.value = String(E);
279- this.showEnergy();
280- if (this.open) this.run();
281- }
282-
283- // The pendulum's current energy: with Follow "Pendulum", recompute the section at
284- // it once it has changed by FOLLOW_CHANGE (at most every FOLLOW_MIN_MS), or
285- // at once when `now` is set (a new strike).
286- follow(E: number, now = false): void {
287- if (this.followMode() !== "pendulum" || !(E > 0) || Math.abs(E - this.E) < 1e-9 * E) return;
288- const t = performance.now();
289- if (!now && (Math.abs(E - this.E) < FOLLOW_CHANGE * this.E || t - this.lastFollow < FOLLOW_MIN_MS)) return;
290- this.lastFollow = t;
291- this.setEnergy(E);
292- }
293-
294- private showEnergy(): void {
295- const E = this.E;
296- this.energyValue.textContent = `${formatEnergy(E)} J`;
297- }
298-
299- private send(msg: ToPoincare<Params>): void {
300- if (!this.worker) {
301- this.worker = new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" });
302- this.worker.onmessage = (e: MessageEvent<FromPoincare>) => this.receive(e.data);
303- }
304- this.worker.postMessage(msg);
305- }
306-
307- // Start computing the section at the current energy.
308- private run(): void {
309- this.soundView = false;
310- this.soundE = 0;
311- this.runId++;
312- this.orbits = [];
313- this.finished = false;
314- this.thMax = thetaMax(this.P, this.E);
315- this.pMax = p2Max(this.P, this.E, 0);
316- const seeds = this.E > 0 ? seedGrid(sectionOf(this.P), this.E, GRID[0], GRID[1]) : [];
317- for (let i = 0; i < seeds.length; i++) this.orbits.push({ points: [], lambda: NaN, done: false, clicked: false });
318- if (seeds.length) this.send({ type: "run", id: this.runId, P: this.P, E: this.E, seeds });
319- this.changed = true;
320- }
321-
322- private receive(msg: FromPoincare): void {
323- if (msg.id !== this.runId) return; // from an earlier energy
324- for (const o of msg.orbits) {
325- const orbit = this.orbits[o.index];
326- if (!orbit) continue;
327- for (const v of o.points) orbit.points.push(v);
328- orbit.lambda = o.lambda;
329- orbit.done = o.done;
330- }
331- this.finished = msg.finished;
332- this.changed = true;
333- }
334-
335- // Data <-> CSS pixel coordinates.
336- private px(th2: number): number {
337- return PAD + ((th2 + this.thMax * 1.04) / (2 * this.thMax * 1.04)) * (WIDTH - 2 * PAD);
338- }
339- private py(p2: number): number {
340- return TOP + PAD + ((this.pMax * 1.06 - p2) / (2 * this.pMax * 1.06)) * (HEIGHT - TOP - 2 * PAD);
341- }
342- private fromPx(x: number, y: number): [number, number] {
343- const th2 = ((x - PAD) / (WIDTH - 2 * PAD)) * 2 * this.thMax * 1.04 - this.thMax * 1.04;
344- const p2 = this.pMax * 1.06 - ((y - TOP - PAD) / (HEIGHT - TOP - 2 * PAD)) * 2 * this.pMax * 1.06;
345- return [th2, p2];
346- }
347- private mouse(e: MouseEvent): [number, number] {
348- const r = this.canvas.getBoundingClientRect();
349- return [((e.clientX - r.left) / r.width) * WIDTH, ((e.clientY - r.top) / r.height) * HEIGHT];
350- }
351-
352- private onHover(e: MouseEvent): void {
353- const [x, y] = this.mouse(e);
354- this.hoverPoint = this.fromPx(x, y);
355- let best = 8 * 8; // within 8 px
356- this.hoverOrbit = -1;
357- this.orbits.forEach((o, k) => {
358- for (let i = 0; i < o.points.length; i += 2) {
359- const dx = this.px(o.points[i]) - x, dy = this.py(o.points[i + 1]) - y;
360- if (dx * dx + dy * dy < best) {
361- best = dx * dx + dy * dy;
362- this.hoverOrbit = k;
363- }
364- }
365- });
366- this.changed = true;
367- }
368-
369- // Add an orbit starting at the clicked point, if it is reachable, and with
370- // "Click to strike" on, strike the pendulum from that state.
371- private onClick(e: MouseEvent): void {
372- const [x, y] = this.mouse(e);
373- const [th2, p2] = this.fromPx(x, y);
374- const state = sectionState(this.P, this.E, th2, p2);
375- if (!state) return;
376- if (this.strikeBox.checked) this.onStrike(state, this.E);
377- if (!this.worker || this.soundView) return; // the sound view is not computed here
378- this.orbits.push({ points: [], lambda: NaN, done: false, clicked: true });
379- this.finished = false;
380- this.send({ type: "add", id: this.runId, seed: [th2, p2] });
381- this.changed = true;
382- }
383-
384- private color(k: number, alpha: number): string {
385- const o = this.orbits[k];
386- if (this.byLambda) {
387- if (!Number.isFinite(o.lambda)) return `rgba(150, 150, 150, ${alpha})`;
388- // Blue (regular) -> amber -> red (chaotic).
389- // (A regular orbit's lambda can come out slightly negative.)
390- const u = o.lambda <= LAMBDA_LO ? 0 : Math.min(1, Math.log(o.lambda / LAMBDA_LO) / Math.log(LAMBDA_HI / LAMBDA_LO));
391- const ramp = [[47, 93, 138], [227, 162, 26], [194, 65, 45]];
392- const s = u * 2, i = Math.min(1, Math.floor(s)), w = s - i;
393- const c = ramp[i].map((a, j) => Math.round(a + (ramp[i + 1][j] - a) * w));
394- return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha})`;
395- }
396- const hue = (k * 137.508) % 360; // golden angle: neighbouring orbits differ
397- return `hsla(${hue}, 45%, 42%, ${alpha})`;
398- }
399-
400- draw(): void {
401- if (!this.open || !this.changed) return;
402- this.changed = false;
403- const ctx = this.canvas.getContext("2d")!;
404- ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
405- ctx.clearRect(0, 0, WIDTH, HEIGHT);
406-
407- // Axes through 0 and the allowed region's outline.
408- ctx.strokeStyle = "#ecece6";
409- ctx.lineWidth = 1;
410- ctx.beginPath();
411- ctx.moveTo(this.px(0), TOP);
412- ctx.lineTo(this.px(0), HEIGHT);
413- ctx.moveTo(0, this.py(0));
414- ctx.lineTo(WIDTH, this.py(0));
415- ctx.stroke();
416- ctx.strokeStyle = "#b9b8b0";
417- ctx.beginPath();
418- const tm = thetaMax(this.P, this.E); // the outline at this energy (may be inside the axes)
419- for (const sign of [1, -1]) {
420- for (let i = 0; i <= 200; i++) {
421- const th2 = -tm + (2 * tm * i) / 200;
422- const x = this.px(th2), y = this.py(sign * p2Max(this.P, this.E, th2));
423- if (i === 0) ctx.moveTo(x, y);
424- else ctx.lineTo(x, y);
425- }
426- }
427- ctx.stroke();
428-
429- // Orbits; the hovered one on top and the others faded while hovering.
430- const order = this.orbits.map((_, k) => k).filter((k) => k !== this.hoverOrbit);
431- if (this.hoverOrbit >= 0) order.push(this.hoverOrbit);
432- for (const k of order) {
433- const o = this.orbits[k];
434- const hovered = k === this.hoverOrbit;
435- ctx.fillStyle = this.color(k, this.hoverOrbit >= 0 && !hovered ? 0.25 : 0.85);
436- const size = hovered || o.clicked ? 2.5 : 1.8;
437- for (let i = 0; i < o.points.length; i += 2) {
438- ctx.fillRect(this.px(o.points[i]) - size / 2, this.py(o.points[i + 1]) - size / 2, size, size);
439- }
440- }
441-
442- if (this.followMode() === "sound") {
443- // The playing note's latest crossings: a fading trail, the newest larger.
444- const n = this.noteDots.length / 3;
445- for (let k = 0; k < n; k++) {
446- const latest = k === n - 1;
447- const [tk, pk] = this.extent(this.noteDots[3 * k + 2]);
448- ctx.globalAlpha = 0.2 + (0.8 * (k + 1)) / n;
449- ctx.beginPath();
450- ctx.arc(
451- this.px((this.noteDots[3 * k] * this.thMax) / tk),
452- this.py((this.noteDots[3 * k + 1] * this.pMax) / pk),
453- latest ? 6 : 3.5, 0, 2 * Math.PI,
454- );
455- ctx.fillStyle = "#fff";
456- ctx.fill();
457- ctx.strokeStyle = "#c2412d";
458- ctx.lineWidth = latest ? 2.5 : 1.5;
459- ctx.stroke();
460- }
461- ctx.globalAlpha = 1;
462- } else {
463- // The pendulum's own crossings at about this energy: dark rings on
464- // white, the latest larger.
465- ctx.strokeStyle = "#23262b";
466- ctx.fillStyle = "#fff";
467- for (let i = 0; i < this.live.length; i += 3) {
468- if (Math.abs(this.live[i + 2] - this.E) > LIVE_TOLERANCE * this.E) continue;
469- const latest = i === this.live.length - 3;
470- ctx.beginPath();
471- ctx.arc(this.px(this.live[i]), this.py(this.live[i + 1]), latest ? 6 : 3.5, 0, 2 * Math.PI);
472- ctx.fill();
473- ctx.lineWidth = latest ? 2.5 : 1.5;
474- ctx.stroke();
475- }
476- }
477-
478- // Legend row.
479- ctx.font = FONT;
480- ctx.textBaseline = "top";
481- ctx.fillStyle = "#2f5d8a";
482- let legend: string;
483- if (this.hoverOrbit >= 0) {
484- const o = this.orbits[this.hoverOrbit];
485- const kind = !Number.isFinite(o.lambda) ? "" : o.lambda > 0.1 ? "chaotic" : "regular";
486- legend = `orbit ${this.hoverOrbit + 1} · λ ${Number.isFinite(o.lambda) ? o.lambda.toFixed(3) : "–"} 1/s ${kind ? `(${kind})` : ""}`;
487- } else if (this.hoverPoint) {
488- const [th2, p2] = this.hoverPoint;
489- legend = `θ2 ${th2.toFixed(3)} rad · p2 ${p2.toFixed(3)} kg m²/s${sectionState(this.P, this.E, th2, p2) ? " · click to add an orbit here" : ""}`;
490- } else {
491- legend = `θ2 (rad) across · p2 (kg m²/s) up · ${this.orbits.length} orbits`;
492- // Say why the pendulum's own crossings are missing.
493- const lastE = this.live[this.live.length - 1];
494- if (this.followMode() !== "sound" && lastE > 0 && Math.abs(lastE - this.E) > LIVE_TOLERANCE * this.E) {
495- legend += ` · pendulum at ${formatEnergy(lastE)} J, not shown`;
496- }
497- }
498- ctx.fillText(legend, 4, 3);
499- ctx.fillStyle = "#999";
500- ctx.textAlign = "right";
501- ctx.fillText(
502- `${this.finished || this.soundView ? "" : "computing… · "}θ2 ±${this.thMax.toFixed(2)} · p2 ±${this.pMax.toFixed(2)}`,
503- WIDTH - 4, 3,
504- );
505- ctx.textAlign = "left";
506- }
507-}
10+export type PoincarePanel = SectionPanel<Params, Float64Array>;
50811
50912 // An energy for display: one decimal from 10 J, else two significant digits.
51013 export function formatEnergy(E: number): string {
51114 return E >= 10 ? E.toFixed(1) : E >= 0.01 ? E.toPrecision(2) : E.toExponential(1);
51215 }
16+
17+export const newPoincarePanel = (
18+ P: Params,
19+ onStrike: (y: Float64Array, E: number) => void,
20+ onUserEnergy: () => void,
21+): PoincarePanel =>
22+ new SectionPanel(
23+ {
24+ section: sectionOf,
25+ axes: () => [
26+ { label: "θ2", unit: "rad", scale: 1, digits: 3, rangeDigits: 2 },
27+ { label: "p2", unit: "kg m²/s", scale: 1, digits: 3, rangeDigits: 2 },
28+ ],
29+ // From E_top / 1000 (small oscillations: sections are scaled ellipses)
30+ // to twice the both-inverted energy (the top of the energy sliders).
31+ energyRange: (P) => [topEnergy(P) / 1000, 2 * topEnergy(P)],
32+ fmtEnergy: (E) => `${formatEnergy(E)} J`,
33+ noun: "pendulum",
34+ atlas: newAtlas(),
35+ atlasSize: ATLAS_SIZE,
36+ makeWorker: () => new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" }),
37+ },
38+ P, onStrike, onUserEnergy,
39+ );
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`, 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.
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`, the panel's configuration for `../common/section-panel.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/main.tsmodified+2−2View file
@@ -8,7 +8,7 @@ import { setupHelp } from "../common/help";
88 import { Lyapunov } from "../common/lyapunov";
99 import { shadowOf } from "./lyapunov";
1010 import { LiveModeHistory, ModeHistory } from "./modehistory";
11-import { PoincarePanel } from "./poincare";
11+import { newPoincarePanel, type PoincarePanel } from "./poincare";
1212 import { sectionDef } from "./poincare-core";
1313 import { END_RATIO, DIAG_EVERY, UNDAMPED_LENGTH, type RenderRequest, type RenderResult } from "./render-worker";
1414 import { ScatterPlot } from "../common/scatter";
@@ -545,7 +545,7 @@ function setParams(next: Params) {
545545 // clicked state at the section's energy.
546546 let poincare: PoincarePanel | null = null;
547547 setParams(P);
548-poincare = new PoincarePanel(
548+poincare = newPoincarePanel(
549549 P,
550550 (state) => strikeFrom(state, "section"),
551551 () => {
src/fput-chain/poincare.tsmodified+36−529View file
@@ -1,531 +1,38 @@
1-// Poincare section panel, for N = 2 and for N = 3's symmetric family only
2-// (see poincare-core.ts), adapted from the double pendulum app: its own
3-// energy slider (same parameters as the
4-// chain above, no damping), the allowed region's outline, orbits seeded on a
5-// grid and computed in a worker, click to add an orbit (or, with "Click to
6-// strike" on, also strike the chain above from that exact state), hover to
7-// highlight one, and coloring per orbit or by Lyapunov exponent. The animated
8-// chain's own crossings are drawn as dark rings ("you are here"), only those
9-// within LIVE_TOLERANCE of the section's energy (others would fall outside
10-// the allowed region), and for N = 3 only while the chain is in the
11-// symmetric family. Follow "real time" makes the section's energy track the
12-// chain's as it decays. Follow "sped up" shows the playing note's crossings
13-// instead, as a short fading trail, with the axes fitted to the note's
14-// current energy and, if an atlas has been computed ("Precompute sections",
15-// shown only with Follow "sped up"), the atlas section nearest that energy
16-// behind it (see showSound). Setting the section's energy by hand pauses
17-// everything (onUserEnergy), so Follow does not override it until Resume.
1+// The chain's Poincare section panel (see ../common/section-panel.ts), for
2+// N = 2 and for N = 3's symmetric family only (see poincare-core.ts); hidden
3+// for other N.
184
5+import { SectionPanel } from "../common/section-panel";
6+import { newAtlas, ATLAS_SIZE } from "./atlas";
197 import type { Params, State } from "./chain";
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";
25-
26-const WIDTH = 960, HEIGHT = 480; // CSS pixels
27-const TOP = 18; // legend row
28-const PAD = 10;
29-const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
30-const GRID = [6, 7]; // seed grid: across x, across q
31-const LIVE_MAX = 400; // chain crossings kept
32-const LIVE_TOLERANCE = 0.05; // relative energy difference for drawing a chain crossing
33-const FOLLOW_CHANGE = 0.03; // relative energy change that triggers a recompute
34-const FOLLOW_MIN_MS = 2000; // and at most this often
35-const NOTE_TRAIL = 150; // note crossings shown (each fitted from its own energy)
36-const RESCALE_STEP = 0.02; // sound view: refit after this relative energy change
37-// Lambda coloring is logarithmic between these (1/s): regular orbits measure
38-// ~0.05 or less over a run (lambda -> 0 as the run grows), chaotic ones in the
39-// larger chains ~0.5 to 1.5.
40-const LAMBDA_LO = 0.01, LAMBDA_HI = 1.5;
41-
42-interface Orbit {
43- points: number[]; // x, q pairs (the plotted mass's displacement and momentum)
44- lambda: number;
45- done: boolean;
46- clicked: boolean;
47-}
48-
49-export class PoincarePanel {
50- private P: Params;
51- private D: SectionDef | null = null;
52- private E: number;
53- private panel: HTMLDetailsElement;
54- private canvas: HTMLCanvasElement;
55- private dpr = window.devicePixelRatio || 1;
56- private worker: Worker | null = null;
57- private runId = 0;
58- private orbits: Orbit[] = [];
59- private finished = false;
60- private changed = true;
61- private hoverOrbit = -1;
62- private hoverPoint: [number, number] | null = null;
63- private byLambda = false;
64- private energyInput: HTMLInputElement;
65- private energyValue: HTMLElement;
66- private debounce = 0;
67- private strikeBox: HTMLInputElement;
68- private live: number[] = []; // the chain's crossings: x, q, E triples
69- private outside = false; // N = 3: the chain has left the symmetric family
70- private followSelect: HTMLSelectElement;
71- private lastFollow = 0;
72- private noteDots: number[] = []; // x, q, E triples of the playing note
73- private atlas = newAtlas();
74- private atlasEntries: AtlasEntry[] = [];
75- private atlasComputing = false;
76- private atlasButton: HTMLButtonElement;
77- private soundView = false; // showing the sound view (Follow "sped up" during a note)
78- private soundE = 0; // energy the sound view is fitted to
79- private onStrike: (s: State, E: number) => void;
80- private onUserEnergy: () => void;
81- private fmtEnergy: (E: number) => string;
82- // Axes (data ranges), set from E.
83- private uM = 1;
84- private pM = 1;
85-
86- // onUserEnergy: the section's energy was set by hand (the page pauses).
87- constructor(
88- P: Params,
89- onStrike: (s: State, E: number) => void,
90- onUserEnergy: () => void,
91- fmtEnergy: (E: number) => string,
92- ) {
93- this.P = P;
94- this.onStrike = onStrike;
95- this.onUserEnergy = onUserEnergy;
96- this.fmtEnergy = fmtEnergy;
97- this.followSelect = document.getElementById("pc-follow") as HTMLSelectElement;
98- this.followSelect.addEventListener("change", () => {
99- // Leaving the sound view: back to the section at the slider's energy.
100- if (this.followMode() !== "sound" && this.soundView) this.run();
101- this.showAtlasButton();
102- this.changed = true;
103- });
104- this.atlasButton = document.getElementById("pc-atlas") as HTMLButtonElement;
105- this.atlasButton.addEventListener("click", () => this.computeAtlas());
106- this.panel = document.getElementById("poincare") as HTMLDetailsElement;
107- this.strikeBox = document.getElementById("pc-strike") as HTMLInputElement;
108- this.energyInput = document.getElementById("pc-energy") as HTMLInputElement;
109- this.energyValue = document.getElementById("pc-energy-value")!;
110- this.E = 10 ** Number(this.energyInput.value);
111- this.showEnergy();
112- this.canvas = document.createElement("canvas");
113- this.canvas.width = WIDTH * this.dpr;
114- this.canvas.height = HEIGHT * this.dpr;
115- this.canvas.style.cursor = "crosshair";
116- document.getElementById("pc-plot")!.appendChild(this.canvas);
117-
118- this.panel.addEventListener("toggle", () => this.update());
119- this.energyInput.addEventListener("input", () => {
120- this.onUserEnergy(); // pauses, so Follow does not override this energy
121- // The recent crossings (chain rings, note trail) belong to other energies.
122- this.live = [];
123- this.noteDots = [];
124- this.E = 10 ** Number(this.energyInput.value);
125- this.showEnergy();
126- // Recompute shortly after the slider stops moving.
127- clearTimeout(this.debounce);
128- this.debounce = window.setTimeout(() => this.run(), 150);
129- });
130- const lambdaBox = document.getElementById("pc-lambda") as HTMLInputElement;
131- lambdaBox.addEventListener("change", () => {
132- this.byLambda = lambdaBox.checked;
133- this.changed = true;
134- });
135- this.canvas.addEventListener("mousemove", (e) => this.onHover(e));
136- this.canvas.addEventListener("mouseleave", () => {
137- this.hoverOrbit = -1;
138- this.hoverPoint = null;
139- this.changed = true;
140- });
141- this.canvas.addEventListener("click", (e) => this.onClick(e));
142- this.setParams(P);
143- }
144-
145- // Offered only where there is a section; computed only while shown and open.
146- private get active(): boolean {
147- return this.D !== null && this.panel.open;
148- }
149-
150- private update(): void {
151- this.panel.hidden = this.D === null;
152- if (this.active) this.run();
153- else this.send({ type: "stop" });
154- }
155-
156- // New chain parameters (the panel shows only for N = 2 and 3).
157- setParams(P: Params): void {
158- this.P = P;
159- this.D = sectionDef(P.N);
160- this.live = [];
161- this.noteDots = [];
162- this.outside = false;
163- this.atlas.cancel();
164- this.atlasComputing = false;
165- this.setAtlas([]);
166- if (this.D) {
167- this.atlas.load(P).then((entries) => {
168- if (this.P === P) this.setAtlas(entries);
169- });
170- }
171- this.update();
172- }
173-
174- followMode(): string {
175- return this.followSelect.value;
176- }
177-
178- // A note started at energy E: with Follow "sped up", switch to the sound view.
179- noteStarted(E: number): void {
180- this.noteDots = [];
181- this.changed = true;
182- if (this.D && this.followMode() === "sound") this.showSound(E, true);
183- }
184-
185- // The playing note's current energy (Follow "sped up").
186- setNoteEnergy(E: number): void {
187- if (this.D && this.followMode() === "sound" && E > 0) this.showSound(E);
188- }
189-
190- // A crossing of the playing note, as the audio clock reaches it, with the
191- // note's energy E then (the dot is fitted to the current energy's region
192- // when drawn, like the section).
193- addNoteDot(x: number, q: number, E: number): void {
194- this.noteDots.push(x, q, E);
195- if (this.noteDots.length > 3 * NOTE_TRAIL) this.noteDots.splice(0, this.noteDots.length - 3 * NOTE_TRAIL);
196- if (this.followMode() === "sound") this.changed = true;
197- }
198-
199- // The chain's current energy: with Follow "real time", recompute the
200- // section at it once it has changed by FOLLOW_CHANGE (at most every
201- // FOLLOW_MIN_MS), or at once when `now` is set (a new strike).
202- follow(E: number, now = false): void {
203- if (!this.D || this.followMode() !== "realtime" || !(E > 0) || Math.abs(E - this.E) < 1e-9 * E) return;
204- const t = performance.now();
205- if (!now && (Math.abs(E - this.E) < FOLLOW_CHANGE * this.E || t - this.lastFollow < FOLLOW_MIN_MS)) return;
206- this.lastFollow = t;
207- this.E = E;
208- this.energyInput.value = String(Math.log10(E));
209- this.showEnergy();
210- this.run();
211- }
212-
213- private setAtlas(entries: AtlasEntry[]): void {
214- this.atlasEntries = entries;
215- this.showAtlasButton();
216- if (this.soundView) this.showSound(this.soundE, true);
217- }
218-
219- // The atlas only matters for Follow "sped up": the button is shown there
220- // until the atlas is complete.
221- private showAtlasButton(): void {
222- const n = this.atlasEntries.length;
223- this.atlasButton.hidden = this.followMode() !== "sound" || n === ATLAS_SIZE;
224- this.atlasButton.disabled = this.atlasComputing;
225- this.atlasButton.textContent = this.atlasComputing
226- ? `Computing sections ${n}/${ATLAS_SIZE}`
227- : n ? `Precompute sections (${n}/${ATLAS_SIZE})` : "Precompute sections";
228- }
229-
230- private computeAtlas(): void {
231- this.atlasComputing = true;
232- this.showAtlasButton();
233- const P = this.P;
234- this.atlas
235- .compute(P, (entries) => {
236- if (this.P === P) this.setAtlas(entries);
237- })
238- .finally(() => {
239- if (this.P !== P) return;
240- this.atlasComputing = false;
241- this.showAtlasButton();
242- });
243- }
244-
245- // Extent of the allowed region at energy E: largest |x| and |q|.
246- private extent(E: number): [number, number] {
247- return [uMax(this.P, this.D!, E), pMax(this.P, this.D!, E, 0)];
248- }
249-
250- // Sound view at the note's energy E (no computation): axes fitted to E's
251- // allowed region, with the atlas section nearest E (if any) fitted to it,
252- // each point (x, q) of the section at E_k drawn at
253- // (x xmax(E)/xmax(E_k), q qmax(E)/qmax(E_k)). In the linear regime (low
254- // energy) sections at different energies are scaled copies, so this is
255- // exact; above it, it is an approximation over the gap between atlas
256- // energies (a factor ~1.45). Without an atlas only the outline is drawn.
257- // Refitted after a RESCALE_STEP change.
258- private showSound(E: number, force = false): void {
259- if (!(E > 0) || !this.panel.open) return;
260- if (!force && this.soundView && Math.abs(Math.log(E / this.soundE)) < RESCALE_STEP) return;
261- if (!this.soundView) {
262- this.runId++; // ignore the interactive computation's remaining results
263- this.send({ type: "stop" });
264- this.soundView = true;
265- }
266- this.soundE = E;
267- const [um, pm] = this.extent(E);
268- this.orbits = [];
269- if (this.atlasEntries.length) {
270- let k = 0;
271- for (let i = 1; i < this.atlasEntries.length; i++) {
272- if (Math.abs(Math.log(this.atlasEntries[i].E / E)) < Math.abs(Math.log(this.atlasEntries[k].E / E))) k = i;
273- }
274- const entry = this.atlasEntries[k];
275- const [uk, pk] = this.extent(entry.E);
276- const fu = um / uk, fp = pm / pk;
277- this.orbits = entry.orbits.map((o) => {
278- const points = new Array<number>(o.points.length);
279- for (let i = 0; i < o.points.length; i += 2) {
280- points[i] = o.points[i] * fu;
281- points[i + 1] = o.points[i + 1] * fp;
282- }
283- return { points, lambda: o.lambda, done: true, clicked: false };
284- });
285- }
286- this.E = E;
287- this.energyInput.value = String(Math.log10(E));
288- this.showEnergy();
289- this.uM = um;
290- this.pM = pm;
291- this.finished = true;
292- this.changed = true;
293- }
294-
295- // A crossing of the animated chain at energy E (for N = 3, inside: whether
296- // the chain is in the symmetric family; crossings outside it are not drawn).
297- addLive(x: number, q: number, E: number, inside = true): void {
298- this.outside = !inside;
299- this.changed = true;
300- if (!inside) return;
301- this.live.push(x, q, E);
302- if (this.live.length > 3 * LIVE_MAX) this.live.splice(0, this.live.length - 3 * LIVE_MAX);
303- }
304-
305- clearLive(): void {
306- this.live = [];
307- this.outside = false;
308- this.changed = true;
309- }
310-
311- private showEnergy(): void {
312- this.energyValue.textContent = this.fmtEnergy(this.E);
313- }
314-
315- private send(msg: ToPoincare<Params>): void {
316- if (!this.worker) {
317- if (msg.type === "stop") return;
318- this.worker = new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" });
319- this.worker.onmessage = (e: MessageEvent<FromPoincare>) => this.receive(e.data);
320- }
321- this.worker.postMessage(msg);
322- }
323-
324- // Start computing the section at the current energy.
325- private run(): void {
326- if (!this.active) return;
327- this.soundView = false;
328- this.soundE = 0;
329- this.runId++;
330- this.orbits = [];
331- this.finished = false;
332- const D = this.D!;
333- this.uM = uMax(this.P, D, this.E);
334- this.pM = pMax(this.P, D, this.E, 0);
335- const seeds = seedGrid(sectionOf(this.P)!, this.E, GRID[0], GRID[1]);
336- for (let i = 0; i < seeds.length; i++) this.orbits.push({ points: [], lambda: NaN, done: false, clicked: false });
337- this.send({ type: "run", id: this.runId, P: this.P, E: this.E, seeds });
338- this.changed = true;
339- }
340-
341- private receive(msg: FromPoincare): void {
342- if (msg.id !== this.runId) return; // from an earlier energy
343- for (const o of msg.orbits) {
344- const orbit = this.orbits[o.index];
345- if (!orbit) continue;
346- for (const v of o.points) orbit.points.push(v);
347- orbit.lambda = o.lambda;
348- orbit.done = o.done;
349- }
350- this.finished = msg.finished;
351- this.changed = true;
352- }
353-
354- // Data <-> CSS pixel coordinates.
355- private px(u: number): number {
356- return PAD + ((u + this.uM * 1.04) / (2 * this.uM * 1.04)) * (WIDTH - 2 * PAD);
357- }
358- private py(p: number): number {
359- return TOP + PAD + ((this.pM * 1.06 - p) / (2 * this.pM * 1.06)) * (HEIGHT - TOP - 2 * PAD);
360- }
361- private fromPx(x: number, y: number): [number, number] {
362- const u = ((x - PAD) / (WIDTH - 2 * PAD)) * 2 * this.uM * 1.04 - this.uM * 1.04;
363- const p = this.pM * 1.06 - ((y - TOP - PAD) / (HEIGHT - TOP - 2 * PAD)) * 2 * this.pM * 1.06;
364- return [u, p];
365- }
366- private mouse(e: MouseEvent): [number, number] {
367- const r = this.canvas.getBoundingClientRect();
368- return [((e.clientX - r.left) / r.width) * WIDTH, ((e.clientY - r.top) / r.height) * HEIGHT];
369- }
370-
371- private onHover(e: MouseEvent): void {
372- const [x, y] = this.mouse(e);
373- this.hoverPoint = this.fromPx(x, y);
374- let best = 8 * 8; // within 8 px
375- this.hoverOrbit = -1;
376- this.orbits.forEach((o, k) => {
377- for (let i = 0; i < o.points.length; i += 2) {
378- const dx = this.px(o.points[i]) - x, dy = this.py(o.points[i + 1]) - y;
379- if (dx * dx + dy * dy < best) {
380- best = dx * dx + dy * dy;
381- this.hoverOrbit = k;
382- }
383- }
384- });
385- this.changed = true;
386- }
387-
388- // Add an orbit starting at the clicked point, if it is reachable, and with
389- // "Click to strike" on, strike the chain from that state.
390- private onClick(e: MouseEvent): void {
391- const [x, y] = this.mouse(e);
392- const [u, p] = this.fromPx(x, y);
393- const state = sectionState(this.P, this.D!, this.E, u, p);
394- if (!state) return;
395- if (this.strikeBox.checked) this.onStrike(state, this.E);
396- if (this.soundView) return; // the sound view is not computed here
397- this.orbits.push({ points: [], lambda: NaN, done: false, clicked: true });
398- this.finished = false;
399- this.send({ type: "add", id: this.runId, seed: [u, p] });
400- this.changed = true;
401- }
402-
403- private color(k: number, alpha: number): string {
404- const o = this.orbits[k];
405- if (this.byLambda) {
406- if (!Number.isFinite(o.lambda)) return `rgba(150, 150, 150, ${alpha})`;
407- // Blue (regular) -> amber -> red (chaotic).
408- // (A regular orbit's lambda can come out slightly negative.)
409- const u = o.lambda <= LAMBDA_LO ? 0 : Math.min(1, Math.log(o.lambda / LAMBDA_LO) / Math.log(LAMBDA_HI / LAMBDA_LO));
410- const ramp = [[47, 93, 138], [227, 162, 26], [194, 65, 45]];
411- const s = u * 2, i = Math.min(1, Math.floor(s)), w = s - i;
412- const c = ramp[i].map((a, j) => Math.round(a + (ramp[i + 1][j] - a) * w));
413- return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha})`;
414- }
415- const hue = (k * 137.508) % 360; // golden angle: neighbouring orbits differ
416- return `hsla(${hue}, 45%, 42%, ${alpha})`;
417- }
418-
419- draw(): void {
420- if (!this.active || !this.changed) return;
421- this.changed = false;
422- const D = this.D!;
423- const ctx = this.canvas.getContext("2d")!;
424- ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
425- ctx.clearRect(0, 0, WIDTH, HEIGHT);
426-
427- // Axes through 0 and the allowed region's outline.
428- ctx.strokeStyle = "#ecece6";
429- ctx.lineWidth = 1;
430- ctx.beginPath();
431- ctx.moveTo(this.px(0), TOP);
432- ctx.lineTo(this.px(0), HEIGHT);
433- ctx.moveTo(0, this.py(0));
434- ctx.lineTo(WIDTH, this.py(0));
435- ctx.stroke();
436- ctx.strokeStyle = "#b9b8b0";
437- ctx.beginPath();
438- for (const sign of [1, -1]) {
439- for (let i = 0; i <= 200; i++) {
440- const u = -this.uM + (2 * this.uM * i) / 200;
441- const x = this.px(u), y = this.py(sign * pMax(this.P, D, this.E, u));
442- if (i === 0) ctx.moveTo(x, y);
443- else ctx.lineTo(x, y);
444- }
445- }
446- ctx.stroke();
447-
448- // Orbits; the hovered one on top and the others faded while hovering.
449- const order = this.orbits.map((_, k) => k).filter((k) => k !== this.hoverOrbit);
450- if (this.hoverOrbit >= 0) order.push(this.hoverOrbit);
451- for (const k of order) {
452- const o = this.orbits[k];
453- const hovered = k === this.hoverOrbit;
454- ctx.fillStyle = this.color(k, this.hoverOrbit >= 0 && !hovered ? 0.25 : 0.85);
455- const size = hovered || o.clicked ? 2.5 : 1.8;
456- for (let i = 0; i < o.points.length; i += 2) {
457- ctx.fillRect(this.px(o.points[i]) - size / 2, this.py(o.points[i + 1]) - size / 2, size, size);
458- }
459- }
460-
461- if (this.followMode() === "sound") {
462- // The playing note's latest crossings: a fading trail, the newest
463- // larger, each fitted from its own energy to the current one.
464- const n = this.noteDots.length / 3;
465- for (let k = 0; k < n; k++) {
466- const latest = k === n - 1;
467- const [uk, pk] = this.extent(this.noteDots[3 * k + 2]);
468- ctx.globalAlpha = 0.2 + (0.8 * (k + 1)) / n;
469- ctx.beginPath();
470- ctx.arc(
471- this.px((this.noteDots[3 * k] * this.uM) / uk),
472- this.py((this.noteDots[3 * k + 1] * this.pM) / pk),
473- latest ? 6 : 3.5, 0, 2 * Math.PI,
474- );
475- ctx.fillStyle = "#fff";
476- ctx.fill();
477- ctx.strokeStyle = "#c2412d";
478- ctx.lineWidth = latest ? 2.5 : 1.5;
479- ctx.stroke();
480- }
481- ctx.globalAlpha = 1;
482- } else {
483- // The chain's own crossings at about this energy: dark rings on white,
484- // the latest larger.
485- ctx.strokeStyle = "#23262b";
486- ctx.fillStyle = "#fff";
487- for (let i = 0; i < this.live.length; i += 3) {
488- if (Math.abs(this.live[i + 2] - this.E) > LIVE_TOLERANCE * this.E) continue;
489- const latest = i === this.live.length - 3;
490- ctx.beginPath();
491- ctx.arc(this.px(this.live[i]), this.py(this.live[i + 1]), latest ? 6 : 3.5, 0, 2 * Math.PI);
492- ctx.fill();
493- ctx.lineWidth = latest ? 2.5 : 1.5;
494- ctx.stroke();
495- }
496- }
497-
498- // Legend row.
499- ctx.font = FONT;
500- ctx.textBaseline = "top";
501- ctx.fillStyle = "#2f5d8a";
502- let legend: string;
503- if (this.hoverOrbit >= 0) {
504- const o = this.orbits[this.hoverOrbit];
505- const kind = !Number.isFinite(o.lambda) ? "" : o.lambda > 0.1 ? "chaotic" : "regular";
506- legend = `orbit ${this.hoverOrbit + 1} · λ ${Number.isFinite(o.lambda) ? o.lambda.toFixed(3) : "–"} 1/s ${kind ? `(${kind})` : ""}`;
507- } else if (this.hoverPoint) {
508- const [u, p] = this.hoverPoint;
509- legend = `${D.uLabel} ${(100 * u).toFixed(2)} cm · ${D.pLabel} ${p.toFixed(3)} kg m/s${sectionState(this.P, D, this.E, u, p) ? " · click to add an orbit here" : ""}`;
510- } else {
511- legend = `${D.uLabel} (cm) across · ${D.pLabel} (kg m/s) up · ${this.orbits.length} orbits`;
512- if (this.P.N === 3) legend += " · symmetric motions only";
513- // Say why the chain's own crossings are missing.
514- const lastE = this.live[this.live.length - 1];
515- if (this.followMode() === "sound") {
516- if (this.soundView && !this.atlasEntries.length) legend += " · no precomputed sections";
517- } else if (this.outside) legend += " · chain not symmetric, not shown";
518- else if (lastE > 0 && Math.abs(lastE - this.E) > LIVE_TOLERANCE * this.E) {
519- legend += ` · chain at ${this.fmtEnergy(lastE)}, not shown`;
520- }
521- }
522- ctx.fillText(legend, 4, 3);
523- ctx.fillStyle = "#999";
524- ctx.textAlign = "right";
525- ctx.fillText(
526- `${this.finished || this.soundView ? "" : "computing… · "}±${(100 * this.uM).toFixed(2)} cm · ±${this.pM.toFixed(3)} kg m/s`,
527- WIDTH - 4, 3,
528- );
529- ctx.textAlign = "left";
530- }
531-}
8+import { sectionDef, sectionOf } from "./poincare-core";
9+
10+export type PoincarePanel = SectionPanel<Params, State>;
11+
12+export const newPoincarePanel = (
13+ P: Params,
14+ onStrike: (s: State, E: number) => void,
15+ onUserEnergy: () => void,
16+ fmtEnergy: (E: number) => string,
17+): PoincarePanel =>
18+ new SectionPanel(
19+ {
20+ section: sectionOf,
21+ axes: (P) => {
22+ const D = sectionDef(P.N)!;
23+ return [
24+ { label: D.uLabel, unit: "cm", scale: 100, digits: 2, rangeDigits: 2 },
25+ { label: D.pLabel, unit: "kg m/s", scale: 1, digits: 3, rangeDigits: 3 },
26+ ];
27+ },
28+ energyRange: () => [1e-5, 100], // 10 µJ to the top of the energy sliders
29+ fmtEnergy,
30+ noun: "chain",
31+ family: (P) => (P.N === 3 ? "symmetric motions only" : null),
32+ outside: "chain not symmetric",
33+ atlas: newAtlas(),
34+ atlasSize: ATLAS_SIZE,
35+ makeWorker: () => new Worker(new URL("./poincare-worker.ts", import.meta.url), { type: "module" }),
36+ },
37+ P, onStrike, onUserEnergy,
38+ );
↑↓ move↵openescclose