/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
FPUT chain chaos: struck FPUT-beta chain with damping, real-time plots, ghost, Lyapunov, and a sped-up listen panel
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 52a89c0ec2af Browse files
19 changed files+3565−0
src/fput-chain/.github/workflows/pages.ymladded+41−0View file
@@ -0,0 +1,41 @@
1+name: Deploy site
2+
3+on:
4+ push:
5+ branches: [main]
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: read
10+ pages: write
11+ id-token: write
12+
13+concurrency:
14+ group: pages
15+ cancel-in-progress: true
16+
17+jobs:
18+ build:
19+ runs-on: ubuntu-latest
20+ steps:
21+ - uses: actions/checkout@v4
22+ - uses: actions/setup-node@v4
23+ with:
24+ node-version: 22
25+ cache: npm
26+ - run: npm ci
27+ - run: npm run build
28+ - uses: actions/configure-pages@v5
29+ - uses: actions/upload-pages-artifact@v3
30+ with:
31+ path: dist
32+
33+ deploy:
34+ needs: build
35+ runs-on: ubuntu-latest
36+ environment:
37+ name: github-pages
38+ url: ${{ steps.deployment.outputs.page_url }}
39+ steps:
40+ - id: deployment
41+ uses: actions/deploy-pages@v4
src/fput-chain/.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules
2+dist
src/fput-chain/README.mdadded+23−0View file
@@ -0,0 +1,23 @@
1+# fput-chain-chaos
2+
3+Struck FPUT-beta chain, step 2 of "Listening for chaos" (after double-pendulum-chaos). Work in progress.
4+
5+Current state: N masses (2 to 64, default 4) between two fixed walls, joined by springs with tension k d + k3 d^3. The chain sits at rest until Strike, which gives it energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, either on one mass j alone (default mass 1, a hammer blow at one end) or in the shape of one normal mode n. The left card animates the chain (top: to scale, rest spacing a; below: the same displacements drawn sideways as a profile, scaled to fit, with the scale given) with the time and the relative energy error. Speed: 0.1x to 5x real time (default 1x). The time and current energy show at the top left. The Ghost chip (as in the pendulum app) adds a second chain started from the same state with every mass moved by the same amount, 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10; moving only the struck mass would put the offset along the motion itself, which does not grow), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost" (from the chain's current state), when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started, and a "Separation" chart (log, last 60 s; offered only with the ghost on) plots it. A "Lyapunov" chart (last 60 s; the first 5 s after a strike left out of its y range) shows the largest Lyapunov exponent over the last 10 s and since the strike (`src/lyapunov.ts`, two-trajectory Benettin method as in the pendulum app: a hidden shadow chain 1e-8 away, displacements in units of d0 and momenta in units of sqrt(m k) d0, renormalized every step). Checked (N = 4, mass-1 strike, 300 s): without damping 0.1 J ~0.05 to 0.1 /s (weak chaos; N = 4 onset is near 3 k d0^2 = 75 mJ), 0.5 J ~0.5 /s, 2.51 J ~0.9 /s, 25 J ~1.7 /s; with tau 540 s, 0.1 J falls to ~0.01 /s and 2.51 J falls from ~1.0 to ~0.3 /s by 300 s. The slider is labelled "Strike energy". Parameters (m, k, k3, a, tau) fold out underneath with derived values (mode frequencies, d0, nonlinear energy scale, integrator step); Apply or changing N returns the chain to rest. The "Real time" card on the right has toggle chips for its charts. "Mode bars" shows the current harmonic energy of each normal mode relative to E on a log scale (12 decades), with the number of modes sharing the energy (inverse participation, 1 / sum (E_n / sum E)^2). The other chips are charts over time (from `src/traces.ts` and `src/cursor.ts` of double-pendulum-chaos, with per-group colors and a legend capped at 8 series): displacements, velocities, spring stretches, mode energies (log, last 60 s; values below 1e-12 of E left out), energies (E, T, V, W dissipated) and the energy balance error ((E + W - E0) / E0); others last 10 s; sampled every 10 ms; hover for values, click to pin. Mode bars, displacements and mode energies are on by default. Masses, springs and modes share one color per index (four fixed colors for up to 4, a blue-to-red sweep beyond).
6+
7+"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 (`src/render-worker.ts`) sped up so the lowest linear mode sounds at the Pitch (30 to 1000 Hz, default 100; 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 (`src/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 (`src/sound.ts`). Chips (as in the pendulum app): Spectrogram (on; pendulum's `NoteSpectrogram`: 2048-sample Hann, zero-pad 4096, hop 512; 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), 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 (`src/scatter.ts` from the pendulum app: 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). 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.
8+
9+```bash
10+npm install
11+npm run dev
12+```
13+
14+## Model
15+
16+- SI units. Defaults m = 1 kg, k = 10 N/m, k3 = 4000 N/m^3, a = 0.5 m. The cubic tension equals the linear one at d0 = sqrt(k / k3) = 5 cm; how nonlinear the motion is depends only on E / (k d0^2) (25 mJ by default). The rest spacing only enters the drawing.
17+- Fixed ends. Normal modes of the linear chain: shapes sqrt(2 / (N + 1)) sin(pi n j / (N + 1)), angular frequencies omega_n = 2 sqrt(k / m) sin(pi n / (2 (N + 1))); 0.311, 0.592, 0.814, 0.957 Hz for the defaults. Mode energy P_n^2 / 2m + m omega_n^2 Q_n^2 / 2.
18+- Damping: linear drag on each mass proportional to its mass, force -gamma m v = -gamma p, so every linear mode's amplitude decays as exp(-t / tau), tau = 2 / gamma (default 540 s: ~10% amplitude per minute in the animation; 0 = off). Applied exactly in half steps around each Verlet step (p scaled by exp(-gamma h / 2)), with the kinetic energy removed added to W. Checked: modes 1 and 4 at 1e-8 J decay to 0.13539 and 0.13536 of their energy after tau (e^-2 = 0.13534); energy balance error 3e-5 over the default note (6000 s); -60 dB reached at 3635 s vs 3730 s predicted.
19+- Integrator: velocity Verlet (order 2, symplectic; H is separable, so an explicit method suffices). Step from the energy (`stepFor`): min(0.005 sqrt(m / k), 0.02 sqrt(m / k_eff)), k_eff = k + 3 k3 d^2 with d = (4 E / k3)^(1/4) the largest stretch a spring holding all of E could reach. Set at each strike in the animation (1.58 ms when weak, 0.81 ms at 2.5 J, 0.32 ms at 100 J); re-chosen every 256 samples in the sound, so steps lengthen as a note decays. Relative energy error 1e-5 to 5e-5 from 1 mJ to 100 J (N = 4 and 32, 300 s). Render time in the pane: 0.2 to 0.3 s (N = 4), 0.6 to 0.8 s (N = 32), 2.5 to 100 J. At 100 J stretches reach ~0.55 m, more than the drawing's rest spacing, so masses pass each other in the to-scale drawing (the model has no contact).
20+- Chaos for N = 4 (two-trajectory Lyapunov, 5000 / sqrt(k/m) s): none below E ~ 1 k d0^2; onset near 3 k d0^2; at the default 10 k d0^2, lambda ~0.1 sqrt(k/m) = 0.32 /s, energy shared by ~2.5 modes.
21+- Symmetry: the quartic force couples odd modes only to odd modes, so a strike in an odd mode leaves the even modes at exactly zero (they stay off the bottom of the chart). A strike on one mass, other than the middle one, excites all modes.
22+
23+Code: `src/chain.ts` (physics, modes, strikes), `src/main.ts` (page), `src/traces.ts` and `src/cursor.ts` (charts), `src/signals.ts`, `src/render-worker.ts`, `src/sound.ts`, `src/spectrogram.ts`, `src/scatter.ts` (sound), `src/lyapunov.ts` (Lyapunov exponent, animation and sound).
src/fput-chain/chain.tsadded+190−0View file
@@ -0,0 +1,190 @@
1+// FPUT-beta chain: N equal masses on a line between two fixed walls, joined
2+// by N + 1 equal springs with tension f(d) = k d + k3 d^3 for a stretch d.
3+// SI units. The cubic term matches the linear one at the stretch
4+// d0 = sqrt(k / k3); how nonlinear the motion is depends only on the energy
5+// relative to k d0^2.
6+// Optional damping: linear drag on each mass proportional to its mass,
7+// force -gamma m v = -gamma p, so every linear mode's amplitude decays as
8+// exp(-t / tau) with tau = 2 / gamma.
9+// State: displacements u[0..N-1] (m) from rest, momenta p[0..N-1] (kg m/s),
10+// and W, the energy (J) dissipated so far (so E + W is conserved); the walls
11+// are u = 0 at both ends.
12+
13+export interface Params {
14+ N: number; // number of moving masses
15+ m: number; // kg, each mass
16+ k: number; // N/m, linear spring constant
17+ k3: number; // N/m^3, cubic spring coefficient
18+ a: number; // m, rest spacing (for drawing only; it does not enter the equations)
19+ tau: number; // s, amplitude decay time from drag (all modes); 0 = no damping
20+}
21+
22+// Drag rate gamma (1/s).
23+export const dragRate = (P: Params) => (P.tau > 0 ? 2 / P.tau : 0);
24+
25+export const d0 = (P: Params) => Math.sqrt(P.k / P.k3);
26+
27+// Integrator step (s) for motion with energy E. Linear part: 1/200 of
28+// sqrt(m / k), ~600 steps per period of the fastest linear mode. Strong
29+// motion stiffens the springs: a spring holding all of E stretches by at
30+// most d = (4 E / k3)^(1/4), where its stiffness is k + 3 k3 d^2; the step
31+// is kept at 1/50 of sqrt(m) over the root of that. Relative energy error
32+// stays at a few 1e-5 from 1 mJ to 100 J.
33+export function stepFor(P: Params, E: number): number {
34+ const d = Math.sqrt(Math.sqrt((4 * Math.max(E, 0)) / P.k3));
35+ const stiff = P.k + 3 * P.k3 * d * d;
36+ return Math.min(0.005 * Math.sqrt(P.m / P.k), 0.02 * Math.sqrt(P.m / stiff));
37+}
38+
39+export interface State {
40+ u: Float64Array;
41+ p: Float64Array;
42+ W: number;
43+}
44+
45+export function newState(N: number): State {
46+ return { u: new Float64Array(N), p: new Float64Array(N), W: 0 };
47+}
48+
49+export const copyState = (s: State): State => ({ u: Float64Array.from(s.u), p: Float64Array.from(s.p), W: s.W });
50+
51+// Stretch of spring j (0..N): u[j] - u[j - 1], with the walls at 0.
52+const stretch = (u: Float64Array, j: number) =>
53+ (j < u.length ? u[j] : 0) - (j > 0 ? u[j - 1] : 0);
54+
55+// Force on each mass, written into F.
56+export function forces(P: Params, u: Float64Array, F: Float64Array): void {
57+ const { N, k, k3 } = P;
58+ // Mass j sits between springs j (left) and j + 1 (right); a stretched
59+ // spring pulls both its ends toward each other.
60+ let left = 0; // tension of the spring on the current mass's left
61+ for (let j = 0; j <= N; j++) {
62+ const d = stretch(u, j);
63+ const f = (k + k3 * d * d) * d; // tension of spring j
64+ if (j > 0) F[j - 1] = f - left;
65+ left = f;
66+ }
67+}
68+
69+export function kinetic(P: Params, s: State): number {
70+ let T = 0;
71+ for (const p of s.p) T += p * p;
72+ return T / (2 * P.m);
73+}
74+
75+export function potential(P: Params, s: State): number {
76+ let V = 0;
77+ for (let j = 0; j <= P.N; j++) {
78+ const d2 = stretch(s.u, j) ** 2;
79+ V += (P.k * d2) / 2 + (P.k3 * d2 * d2) / 4;
80+ }
81+ return V;
82+}
83+
84+export function energy(P: Params, s: State): number {
85+ return kinetic(P, s) + potential(P, s);
86+}
87+
88+// Velocity Verlet (leapfrog), order 2, symplectic. H = T(p) + V(u) is
89+// separable, so this explicit method is enough (the double pendulum needed
90+// an implicit one). F holds the force at the current u between steps.
91+// Drag is applied exactly in two half steps around the Verlet step (Strang
92+// splitting), p -> p exp(-gamma h / 2), with the kinetic energy removed
93+// added to W.
94+export class Verlet {
95+ private P: Params;
96+ private F: Float64Array;
97+ constructor(P: Params, s: State) {
98+ this.P = P;
99+ this.F = new Float64Array(P.N);
100+ forces(P, s.u, this.F);
101+ }
102+ // Force on each mass at the current displacements.
103+ get force(): Float64Array {
104+ return this.F;
105+ }
106+ // Call after changing u from outside.
107+ reset(s: State): void {
108+ forces(this.P, s.u, this.F);
109+ }
110+ step(s: State, h: number): void {
111+ const { u, p } = s;
112+ const F = this.F;
113+ const { N, m } = this.P;
114+ const gamma = dragRate(this.P);
115+ if (gamma > 0) this.drag(s, Math.exp(-0.5 * gamma * h));
116+ for (let j = 0; j < N; j++) p[j] += 0.5 * h * F[j];
117+ for (let j = 0; j < N; j++) u[j] += (h * p[j]) / m;
118+ forces(this.P, u, F);
119+ for (let j = 0; j < N; j++) p[j] += 0.5 * h * F[j];
120+ if (gamma > 0) this.drag(s, Math.exp(-0.5 * gamma * h));
121+ }
122+ private drag(s: State, f: number): void {
123+ let p2 = 0;
124+ for (let j = 0; j < s.p.length; j++) {
125+ p2 += s.p[j] * s.p[j];
126+ s.p[j] *= f;
127+ }
128+ s.W += ((1 - f * f) * p2) / (2 * this.P.m);
129+ }
130+}
131+
132+// Normal modes of the linear chain (k3 = 0), mode n = 1..N stored at index
133+// n - 1: shape S[n][j] = sqrt(2 / (N + 1)) sin(pi n (j + 1) / (N + 1)),
134+// angular frequency omega_n = 2 sqrt(k / m) sin(pi n / (2 (N + 1))) (rad/s).
135+// S is symmetric and its own inverse, so mode amplitudes are Q = S u, P = S p.
136+export class Modes {
137+ readonly N: number;
138+ readonly m: number;
139+ readonly omega: Float64Array;
140+ readonly S: Float64Array[]; // S[n - 1][j]
141+ constructor(P: Params) {
142+ const { N, m } = P;
143+ this.N = N;
144+ this.m = m;
145+ const w0 = Math.sqrt(P.k / m);
146+ this.omega = new Float64Array(N);
147+ this.S = [];
148+ const c = Math.sqrt(2 / (N + 1));
149+ for (let n = 1; n <= N; n++) {
150+ this.omega[n - 1] = 2 * w0 * Math.sin((Math.PI * n) / (2 * (N + 1)));
151+ const row = new Float64Array(N);
152+ for (let j = 0; j < N; j++) row[j] = c * Math.sin((Math.PI * n * (j + 1)) / (N + 1));
153+ this.S.push(row);
154+ }
155+ }
156+ // Harmonic energy of each mode (J), P_n^2 / 2m + m omega_n^2 Q_n^2 / 2, into
157+ // out. Their sum is the energy without the quartic part of the springs.
158+ energies(s: State, out: Float64Array): void {
159+ const m = this.m;
160+ for (let n = 0; n < this.N; n++) {
161+ const row = this.S[n];
162+ let Q = 0, Pn = 0;
163+ for (let j = 0; j < this.N; j++) {
164+ Q += row[j] * s.u[j];
165+ Pn += row[j] * s.p[j];
166+ }
167+ const w = this.omega[n];
168+ out[n] = (Pn * Pn) / (2 * m) + (m * w * w * Q * Q) / 2;
169+ }
170+ }
171+}
172+
173+// Strikes from rest. All the energy starts as kinetic energy, so the total is
174+// exactly E whatever k3 is.
175+
176+// Velocity in the shape of mode n (1..N).
177+export function strikeMode(P: Params, modes: Modes, n: number, E: number): State {
178+ const s = newState(P.N);
179+ const pn = Math.sqrt(2 * P.m * E); // mode momentum; the shape has unit norm
180+ const row = modes.S[n - 1];
181+ for (let j = 0; j < P.N; j++) s.p[j] = pn * row[j];
182+ return s;
183+}
184+
185+// Velocity on mass j (1..N) alone: a hammer blow to one mass.
186+export function strikeMass(P: Params, j: number, E: number): State {
187+ const s = newState(P.N);
188+ s.p[j - 1] = Math.sqrt(2 * P.m * E);
189+ return s;
190+}
src/fput-chain/cursor.tsadded+30−0View file
@@ -0,0 +1,30 @@
1+// A time cursor shared by the charts of one card: hovering sets the time,
2+// a click pins it (another click releases it), leaving clears it unless
3+// pinned. Charts read it when they draw.
4+
5+export interface Cursor {
6+ t: number | null; // time under the cursor (chart time units), or none
7+ pinned: boolean;
8+}
9+
10+export const newCursor = (): Cursor => ({ t: null, pinned: false });
11+
12+// Wire mouse events on a canvas to a cursor; toTime maps an x position (0..1
13+// of the canvas width) to a time.
14+export function attachCursor(canvas: HTMLCanvasElement, cursor: Cursor, toTime: (frac: number) => number): void {
15+ const at = (e: MouseEvent) => {
16+ const r = canvas.getBoundingClientRect();
17+ return toTime((e.clientX - r.left) / r.width);
18+ };
19+ canvas.style.cursor = "crosshair";
20+ canvas.addEventListener("mousemove", (e) => {
21+ if (!cursor.pinned) cursor.t = at(e);
22+ });
23+ canvas.addEventListener("mouseleave", () => {
24+ if (!cursor.pinned) cursor.t = null;
25+ });
26+ canvas.addEventListener("click", (e) => {
27+ cursor.pinned = !cursor.pinned;
28+ cursor.t = at(e);
29+ });
30+}
src/fput-chain/index.htmladded+208−0View file
@@ -0,0 +1,208 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>FPUT chain chaos</title>
7+ <style>
8+ :root {
9+ --bg: #f6f6f3;
10+ --card: #ffffff;
11+ --line: #e3e2dc;
12+ --text: #23262b;
13+ --muted: #7a7d82;
14+ --accent: #2f5d8a;
15+ --accent-soft: #e6eef6;
16+ --serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
17+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
18+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
19+ }
20+ * { box-sizing: border-box; }
21+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 var(--sans); }
22+ main { max-width: 1040px; margin: 0 auto; padding: 28px 16px 48px; }
23+ header { margin-bottom: 16px; }
24+ h1 { font: 400 28px/1.2 var(--serif); margin: 0 0 4px; letter-spacing: 0.01em; }
25+ .grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
26+ @media (max-width: 900px) { .grid { grid-template-columns: minmax(0, 1fr); } }
27+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
28+ .card-head { font-size: 12px; margin-bottom: 12px; }
29+ .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
30+ .row + .row { margin-top: 10px; }
31+ .num { font: 13px var(--mono); font-variant-numeric: tabular-nums; }
32+ .muted { color: var(--muted); font-size: 12px; }
33+ canvas { display: block; width: 100%; }
34+ [hidden] { display: none !important; }
35+ #chain { aspect-ratio: 1.4; }
36+ #modes { aspect-ratio: 2.2; }
37+ figure { margin: 14px 0 0; }
38+ #toggles { display: contents; }
39+ #controls { margin-top: 14px; }
40+ input[type="range"] { accent-color: var(--accent); flex: 1; min-width: 120px; margin: 0; }
41+ select, input[type="number"] {
42+ font: inherit; font-size: 13px; color: var(--text); background: #fff;
43+ border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px;
44+ }
45+ input[type="number"] { width: 4.5em; font-family: var(--mono); }
46+ button {
47+ font: inherit; font-size: 13px; color: var(--text); background: #fff; cursor: pointer;
48+ border: 1px solid var(--line); border-radius: 6px; padding: 5px 12px;
49+ }
50+ button:hover { border-color: #c9c8c0; }
51+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; min-width: 76px; }
52+ button.primary:hover { filter: brightness(1.08); }
53+ button:disabled { color: #b5b5b0; cursor: default; }
54+ #energy-value { min-width: 4.5em; text-align: right; }
55+ #ghost-row { margin-top: 10px; min-height: 28px; }
56+ #ghost-controls { flex: 1; }
57+ #ghost-controls .row, #ghost-controls { margin-top: 0; }
58+ #ghost-offset { max-width: 140px; min-width: 80px; }
59+ #ghost-sep { width: 100%; }
60+ #mode-stats { margin-top: 6px; }
61+ .label { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
62+ details.panel { margin-top: 16px; border: 1px solid var(--line); padding: 16px; }
63+ details.panel summary { display: flex; align-items: center; gap: 10px; }
64+ details.panel summary .label { font-size: 12px; color: var(--text); }
65+ details.panel[open] summary { margin-bottom: 14px; }
66+ #sound .row + .row { margin-top: 12px; }
67+ #sound label { display: flex; align-items: center; gap: 8px; }
68+ #sound .grow { flex: 1; min-width: 220px; }
69+ #volume { max-width: 110px; }
70+ #sound-mass { width: 3.5em; }
71+ #sound-chips { margin-left: auto; }
72+ #sound-hint { margin: 14px 0 0; }
73+ #spectrogram canvas { width: 100%; height: auto; border-radius: 4px; }
74+ #sound-traces canvas { height: auto; margin-top: 12px; }
75+ #scatter canvas { width: 100%; height: auto; }
76+ .scatter-actions { justify-content: flex-end; margin-top: 6px; }
77+ button.mute { min-width: 36px; height: 30px; padding: 0 9px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
78+ button.mute svg { width: 18px; height: 18px; flex: none; }
79+ button.mute.is-muted { background: #fbece4; border-color: #e8c2ad; color: #b0482a; font-size: 12px; }
80+ button.mute.is-muted:hover { border-color: #d9a68c; }
81+ button.mute.audible { background: var(--accent-soft); border-color: #b9cde0; color: var(--accent); }
82+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
83+ .chip { position: relative; cursor: pointer; user-select: none; }
84+ .chip input { position: absolute; opacity: 0; pointer-events: none; }
85+ .chip span {
86+ display: inline-block; font-size: 12px; padding: 3px 11px; border-radius: 999px;
87+ border: 1px solid var(--line); color: var(--muted); background: #fff;
88+ }
89+ .chip:hover span { border-color: #c9c8c0; }
90+ .chip input:checked + span { background: var(--accent-soft); border-color: #b9cde0; color: var(--accent); }
91+ .chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }
92+ #traces canvas { height: auto; margin-top: 12px; }
93+ details { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
94+ summary { cursor: pointer; color: var(--muted); font-size: 13px; list-style: none; }
95+ summary::-webkit-details-marker { display: none; }
96+ summary::before { content: "▸ "; }
97+ details[open] summary::before { content: "▾ "; }
98+ #param-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px 16px; margin-top: 10px; }
99+ #param-form label { display: grid; grid-template-columns: 1.6em 5.5em auto; align-items: center; gap: 6px; font-size: 13px; }
100+ #param-form .unit { color: var(--muted); font-size: 12px; }
101+ #param-actions { margin-top: 10px; }
102+ #param-status { color: #a33; font-size: 12px; }
103+ #params { margin-top: 10px; font: 12px/1.6 var(--mono); color: var(--muted); white-space: pre; overflow-x: auto; }
104+ </style>
105+ </head>
106+ <body>
107+ <main>
108+ <header><h1>FPUT chain chaos</h1></header>
109+ <div class="grid">
110+ <section class="card">
111+ <canvas id="chain"></canvas>
112+ <div id="controls">
113+ <div class="row">
114+ <button id="strike" class="primary">Strike</button>
115+ <select id="strike-kind">
116+ <option value="mode">mode</option>
117+ <option value="mass" selected>mass</option>
118+ </select>
119+ <input id="strike-index" type="number" min="1" value="1" step="1" />
120+ <button id="pause" disabled>Pause</button>
121+ </div>
122+ <div class="row">
123+ <span class="muted">Strike energy</span>
124+ <input id="energy" type="range" min="-5" max="2" step="0.01" value="0.4" />
125+ <span id="energy-value" class="num"></span>
126+ </div>
127+ <div class="row">
128+ <span class="muted">Masses</span>
129+ <input id="n" type="number" min="2" max="64" step="1" value="4" />
130+ <span class="muted" style="margin-left: 12px">Speed</span>
131+ <select id="speed">
132+ <option value="0.1">0.1×</option>
133+ <option value="0.25">0.25×</option>
134+ <option value="0.5">0.5×</option>
135+ <option value="1" selected>1× (real time)</option>
136+ <option value="2">2×</option>
137+ <option value="5">5×</option>
138+ </select>
139+ </div>
140+ </div>
141+ <div id="ghost-row" class="row">
142+ <label class="chip" title="A second chain started with every mass moved by a tiny offset"><input id="ghost-toggle" type="checkbox" /><span>Ghost</span></label>
143+ <span id="ghost-controls" class="row" hidden>
144+ <span class="muted">Offset</span>
145+ <input id="ghost-offset" type="range" min="-12" max="-1" step="1" value="-10" aria-label="Ghost offset (power of ten, m)" />
146+ <span id="ghost-offset-value" class="num"></span>
147+ <button id="ghost-reset" type="button" title="Restart the ghost from the chain's current state">Restart ghost</button>
148+ </span>
149+ <span id="ghost-sep" class="num muted"></span>
150+ </div>
151+ <details>
152+ <summary>Parameters</summary>
153+ <div id="param-form">
154+ <label>m <input id="p-m" type="number" step="any" /> <span class="unit">kg</span></label>
155+ <label>k <input id="p-k" type="number" step="any" /> <span class="unit">N/m</span></label>
156+ <label>k3 <input id="p-k3" type="number" step="any" /> <span class="unit">N/m³</span></label>
157+ <label>a <input id="p-a" type="number" step="any" /> <span class="unit">m (spacing)</span></label>
158+ <label>τ <input id="p-tau" type="number" step="any" /> <span class="unit">s (decay, 0 = off)</span></label>
159+ </div>
160+ <div id="param-actions" class="row">
161+ <button id="param-apply">Apply</button>
162+ <span id="param-status"></span>
163+ </div>
164+ <div id="params"></div>
165+ </details>
166+ </section>
167+ <section class="card">
168+ <div class="card-head">Real time</div>
169+ <div class="chips">
170+ <label class="chip"><input id="bars-toggle" type="checkbox" checked /><span>Mode bars</span></label>
171+ <span id="toggles"></span>
172+ </div>
173+ <figure id="bars">
174+ <canvas id="modes"></canvas>
175+ <div id="mode-stats" class="num muted"></div>
176+ </figure>
177+ <div id="traces"></div>
178+ </section>
179+ </div>
180+ <details class="card panel" id="sound" open>
181+ <summary><span class="label">Sped up · listen</span> <span id="sound-clock" class="muted"></span></summary>
182+ <div class="row">
183+ <button id="mute" type="button" class="mute" title="Unmute" aria-label="Unmute"></button>
184+ <label><span class="label">Signal</span> <select id="signal"></select></label>
185+ <input id="sound-mass" type="number" min="1" step="1" value="1" aria-label="Mass" />
186+ <label><span class="label">Volume</span> <input id="volume" type="range" min="0" max="1" step="0.01" value="0.5" /></label>
187+ <button id="replay" type="button" disabled title="Play the last strike again with the current settings">Replay</button>
188+ <span id="sound-chips" class="chips">
189+ <label class="chip"><input id="spec-toggle" type="checkbox" checked /><span>Spectrogram</span></label>
190+ </span>
191+ </div>
192+ <div class="row">
193+ <label class="grow"><span class="label">Pitch</span> <input id="pitch" type="range" min="1.477" max="3" step="0.001" value="2" aria-label="Pitch of the lowest mode" /></label>
194+ <span id="pitch-value" class="num"></span>
195+ </div>
196+ <div class="row"><span id="sound-status" class="muted"></span></div>
197+ <p id="sound-hint" class="muted">Strike to hear the whole decay.</p>
198+ <figure id="spec-fig"><div id="spectrogram"></div></figure>
199+ <div id="sound-traces"></div>
200+ <figure id="scatter-fig" hidden>
201+ <div id="scatter"></div>
202+ <div class="row scatter-actions"><button id="scatter-clear" type="button">Clear</button></div>
203+ </figure>
204+ </details>
205+ </main>
206+ <script type="module" src="/src/main.ts"></script>
207+ </body>
208+</html>
src/fput-chain/lyapunov.tsadded+102−0View file
@@ -0,0 +1,102 @@
1+// Largest Lyapunov exponent by the two-trajectory (Benettin) method, as in
2+// the double pendulum app. A shadow copy of the chain is kept at distance D0
3+// from it: after every step the separation is measured, its logarithmic
4+// growth recorded, and the shadow moved back to distance D0 along the
5+// current separation direction. The exponent is the average growth rate:
6+// over a sliding window (finite-time exponent, which follows a damped
7+// ring-down) and since the start.
8+//
9+// Distance: displacements in units of d0 = sqrt(k / k3) and momenta in units
10+// of sqrt(m k) d0 (the momentum of a mass swinging at sqrt(k / m) with
11+// amplitude d0), so both count comparably. The long-time exponent does not
12+// depend on this choice; finite-time values depend on it a little.
13+
14+import { Verlet, copyState, d0, type Params, type State } from "./chain";
15+
16+const D0 = 1e-8; // shadow distance
17+const CAP = 1 << 18; // per-step growth values kept (> window / step)
18+
19+export class Lyapunov {
20+ private window: number; // s
21+ private shadow: { s: State; verlet: Verlet } | null = null;
22+ private uRef = 1;
23+ private pRef = 1;
24+ // Ring of (time, log growth) per step, for the sliding window.
25+ private ts = new Float64Array(CAP);
26+ private gs = new Float64Array(CAP);
27+ private head = 0;
28+ private tail = 0;
29+ private windowSum = 0;
30+ private totalSum = 0;
31+ private t0 = 0; // start time
32+ private t = 0; // time of the last step
33+
34+ constructor(window: number) {
35+ this.window = window;
36+ }
37+
38+ // Start (or restart) measuring from the chain's current state at time t.
39+ start(P: Params, s: State, t: number): void {
40+ this.uRef = d0(P);
41+ this.pRef = Math.sqrt(P.m * P.k) * this.uRef;
42+ const sh = copyState(s);
43+ // Initial separation: equal parts in all 2N coordinates.
44+ const e = D0 / Math.sqrt(2 * P.N);
45+ for (let j = 0; j < P.N; j++) {
46+ sh.u[j] += e * this.uRef;
47+ sh.p[j] += e * this.pRef;
48+ }
49+ this.shadow = { s: sh, verlet: new Verlet(P, sh) };
50+ this.head = this.tail = 0;
51+ this.windowSum = this.totalSum = 0;
52+ this.t0 = this.t = t;
53+ }
54+
55+ stop(): void {
56+ this.shadow = null;
57+ }
58+
59+ // Advance the shadow by h (call right after the chain's step) and measure;
60+ // t is the chain's time after the step.
61+ step(s: State, h: number, t: number): void {
62+ const sh = this.shadow;
63+ if (!sh) return;
64+ sh.verlet.step(sh.s, h);
65+ const a = s, b = sh.s, ur = this.uRef, pr = this.pRef;
66+ let d2 = 0;
67+ for (let j = 0; j < a.u.length; j++) {
68+ d2 += ((b.u[j] - a.u[j]) / ur) ** 2 + ((b.p[j] - a.p[j]) / pr) ** 2;
69+ }
70+ const d = Math.sqrt(d2);
71+ const r = D0 / d;
72+ for (let j = 0; j < a.u.length; j++) {
73+ b.u[j] = a.u[j] + (b.u[j] - a.u[j]) * r;
74+ b.p[j] = a.p[j] + (b.p[j] - a.p[j]) * r;
75+ }
76+ sh.verlet.reset(b); // forces at the moved displacements
77+
78+ const g = Math.log(d / D0);
79+ this.t = t;
80+ this.totalSum += g;
81+ this.windowSum += g;
82+ this.ts[this.head] = t;
83+ this.gs[this.head] = g;
84+ this.head = (this.head + 1) % CAP;
85+ while (this.tail !== this.head && this.ts[this.tail] <= t - this.window) {
86+ this.windowSum -= this.gs[this.tail];
87+ this.tail = (this.tail + 1) % CAP;
88+ }
89+ }
90+
91+ // Exponent over the last `window` seconds (or since the start, if shorter), 1/s.
92+ windowed(): number {
93+ const span = Math.min(this.window, this.t - this.t0);
94+ return this.shadow && span > 0 ? this.windowSum / span : NaN;
95+ }
96+
97+ // Exponent since the start, 1/s.
98+ mean(): number {
99+ const span = this.t - this.t0;
100+ return this.shadow && span > 0 ? this.totalSum / span : NaN;
101+ }
102+}
src/fput-chain/main.tsadded+682−0View file
@@ -0,0 +1,682 @@
1+import {
2+ Modes, Verlet, copyState, d0, energy, kinetic, newState, stepFor, potential, strikeMass, strikeMode, type Params, type State,
3+} from "./chain";
4+import { newCursor } from "./cursor";
5+import { Lyapunov } from "./lyapunov";
6+import { END_RATIO, DIAG_EVERY, UNDAMPED_LENGTH, type RenderRequest, type RenderResult } from "./render-worker";
7+import { ScatterPlot } from "./scatter";
8+import { SIGNALS, signalByKey } from "./signals";
9+import { Player } from "./sound";
10+import { NoteSpectrogram } from "./spectrogram";
11+import { TracePanel, type Group } from "./traces";
12+
13+const chainCanvas = document.getElementById("chain") as HTMLCanvasElement;
14+const modesCanvas = document.getElementById("modes") as HTMLCanvasElement;
15+const energyInput = document.getElementById("energy") as HTMLInputElement;
16+const energyValue = document.getElementById("energy-value")!;
17+const nInput = document.getElementById("n") as HTMLInputElement;
18+const kindSelect = document.getElementById("strike-kind") as HTMLSelectElement;
19+const indexInput = document.getElementById("strike-index") as HTMLInputElement;
20+const speedSelect = document.getElementById("speed") as HTMLSelectElement;
21+const pauseButton = document.getElementById("pause") as HTMLButtonElement;
22+const modeStats = document.getElementById("mode-stats")!;
23+
24+// Parameters. The cubic coefficient puts d0 = sqrt(k / k3) at 5 cm, so the
25+// motion turns nonlinear at energies around k d0^2 = 25 mJ.
26+// tau: amplitude decay time from drag; 540 s barely shows in the animation
27+// (~10% amplitude per minute) but makes a struck note ring down in about
28+// 12 s of sound. 0 = no damping.
29+let P: Params = { N: 4, m: 1, k: 10, k3: 4000, a: 0.5, tau: 540 };
30+const SAMPLE_DT = 0.01; // s between plotted samples
31+
32+// The chain. It sits at rest until the first Strike; changing parameters
33+// puts it back at rest.
34+let modes: Modes;
35+let s: State;
36+let verlet: Verlet;
37+let H: number; // integrator step (s), set at each strike from its energy (see stepFor)
38+let E0 = 0; // energy of the last strike
39+let t = 0;
40+let running = false;
41+let Ek = new Float64Array(0); // mode energies at the last sample or draw
42+let ampl = 0; // recent largest |u|, for scaling the profile
43+let lastStrike: State | null = null; // state right after the last strike (for the sound)
44+
45+// Colors by index k of n (masses, springs, modes): the trace palette for a
46+// few, a blue-to-red sweep for more.
47+const PALETTE = ["#2f5d8a", "#c8742c", "#4a8c5c", "#a8476e"];
48+const indexColor = (k: number, n: number) =>
49+ n <= PALETTE.length ? PALETTE[k] : `hsl(${215 - (215 * k) / (n - 1)}, 55%, 45%)`;
50+
51+// Lengths and energies in readable units.
52+function fmtLength(x: number): string {
53+ const a = Math.abs(x);
54+ return a >= 1 ? `${x.toPrecision(3)} m` : a >= 0.01 ? `${(100 * x).toPrecision(3)} cm` : `${(1000 * x).toPrecision(3)} mm`;
55+}
56+function fmtEnergy(E: number): string {
57+ return E >= 1 ? `${E.toPrecision(3)} J` : E >= 1e-3 ? `${(1e3 * E).toPrecision(3)} mJ` : `${(1e6 * E).toPrecision(3)} µJ`;
58+}
59+
60+const strikeEnergy = () => 10 ** Number(energyInput.value);
61+const showEnergy = () => (energyValue.textContent = fmtEnergy(strikeEnergy()));
62+showEnergy();
63+energyInput.addEventListener("input", showEnergy);
64+
65+// Pause stops everything: the animation and the sound.
66+function setRunning(r: boolean) {
67+ running = r;
68+ pauseButton.textContent = r ? "Pause" : "Resume";
69+ player.setPaused(!r);
70+}
71+pauseButton.addEventListener("click", () => setRunning(!running));
72+document.addEventListener("keydown", (e) => {
73+ const el = e.target as HTMLElement;
74+ if (e.key !== " " || pauseButton.disabled || el.closest("input, select, button, summary")) return;
75+ e.preventDefault();
76+ setRunning(!running);
77+});
78+
79+const strikeIndex = () => Math.min(P.N, Math.max(1, Math.round(Number(indexInput.value) || 1)));
80+
81+document.getElementById("strike")!.addEventListener("click", () => {
82+ const E = strikeEnergy();
83+ indexInput.value = String(strikeIndex());
84+ s = kindSelect.value === "mode" ? strikeMode(P, modes, strikeIndex(), E) : strikeMass(P, strikeIndex(), E);
85+ lastStrike = copyState(s);
86+ verlet = new Verlet(P, s);
87+ E0 = energy(P, s);
88+ H = stepFor(P, E0);
89+ showDerived();
90+ t = 0;
91+ ampl = 0;
92+ resetGhost();
93+ lyap.start(P, s, t);
94+ replayButton.disabled = false;
95+ renderNote();
96+ traces.clear();
97+ modes.energies(s, Ek);
98+ traces.sample(t);
99+ pauseButton.disabled = false;
100+ setRunning(true);
101+});
102+
103+// The ghost: a second chain started from the same state with every mass
104+// moved by the same tiny amount (total offset delta, so each by
105+// delta / sqrt(N)), run in lockstep (same steps, no renormalization). Moving
106+// only the struck mass would not do: right after a strike on one mass the
107+// chain moves only along that mass's displacement, so the ghost would just
108+// run slightly ahead along the same motion, and nothing would grow until
109+// round-off pushed it aside (~12 s at the defaults). Its
110+// separation from the chain grows exponentially while the motion is chaotic
111+// and stays small (growing at most linearly) while it is regular. Restarted
112+// on Strike, on "Restart ghost" (from the chain's current state), and when
113+// switched on; none while the chain is at rest.
114+let ghost: { s: State; verlet: Verlet } | null = null;
115+let ghostT0 = 0; // time of the last ghost start
116+// Distance between the chain's and the ghost's displacements (m).
117+function ghostSeparation(): number {
118+ if (!ghost) return NaN;
119+ let d2 = 0;
120+ for (let j = 0; j < P.N; j++) d2 += (s.u[j] - ghost.s.u[j]) ** 2;
121+ return Math.sqrt(d2);
122+}
123+
124+// Largest Lyapunov exponent of the animated chain, measured from each strike:
125+// over the last 10 s and since the strike.
126+const lyap = new Lyapunov(10);
127+// Time after a strike left out of the Lyapunov chart's y range: the
128+// early values swing widely before they settle.
129+const LYAP_SKIP = 5;
130+
131+// Plots of the chain over time, one chart per group. The groups depend on N,
132+// so the panel is rebuilt with the parameters (keeping which charts are on).
133+// Energy balance: energy plus what drag has dissipated, against the strike's.
134+const relErr = () => (E0 > 0 ? (energy(P, s) + s.W - E0) / E0 : 0);
135+function groups(shown: string[]): Group[] {
136+ const { N } = P;
137+ const idx = Array.from({ length: N }, (_, j) => j);
138+ const g: Group[] = [
139+ {
140+ label: "Displacements", unit: "m", color: indexColor,
141+ series: idx.map((j) => ({ label: `u${j + 1}`, value: () => s.u[j] })),
142+ },
143+ {
144+ label: "Velocities", unit: "m/s", color: indexColor,
145+ series: idx.map((j) => ({ label: `v${j + 1}`, value: () => s.p[j] / P.m })),
146+ },
147+ {
148+ label: "Stretches", unit: "m", color: indexColor,
149+ series: Array.from({ length: N + 1 }, (_, j) => ({
150+ label: `d${j + 1}`,
151+ value: () => (j < N ? s.u[j] : 0) - (j > 0 ? s.u[j - 1] : 0),
152+ })),
153+ },
154+ {
155+ // Energies far below the strike's (e.g. modes that symmetry keeps at
156+ // zero, which sit at round-off level) are left out.
157+ label: "Mode energies", unit: "J", log: true, window: 60, color: indexColor,
158+ series: idx.map((n) => ({ label: `E${n + 1}`, value: () => (Ek[n] > 1e-12 * E0 ? Ek[n] : NaN) })),
159+ },
160+ {
161+ label: "Energies", unit: "J", zero: true,
162+ series: [
163+ { label: "E", value: () => energy(P, s) },
164+ { label: "T", value: () => kinetic(P, s) },
165+ { label: "V", value: () => potential(P, s) },
166+ { label: "W dissipated", value: () => s.W },
167+ ],
168+ },
169+ { label: "Energy error", series: [{ label: "energy balance error", value: relErr }] },
170+ {
171+ label: "Separation", unit: "m", log: true, window: 60,
172+ series: [{ label: "ghost", value: ghostSeparation }],
173+ },
174+ {
175+ label: "Lyapunov", unit: "1/s", window: 60, skip: () => LYAP_SKIP,
176+ series: [
177+ { label: "λ 10 s", value: () => lyap.windowed() },
178+ { label: "λ since strike", value: () => lyap.mean() },
179+ ],
180+ },
181+ ];
182+ for (const x of g) x.shown = shown.includes(x.label);
183+ return g;
184+}
185+const togglesEl = document.getElementById("toggles")!;
186+const chartsEl = document.getElementById("traces")!;
187+let traces: TracePanel;
188+function buildTraces(shown: string[]) {
189+ togglesEl.replaceChildren();
190+ chartsEl.replaceChildren();
191+ traces = new TracePanel(togglesEl, chartsEl, groups(shown), 10);
192+ traces.setAvailable("Separation", ghostToggle.checked); // offered only with the ghost on
193+}
194+
195+const ghostToggle = document.getElementById("ghost-toggle") as HTMLInputElement;
196+const ghostControls = document.getElementById("ghost-controls")!;
197+const ghostOffset = document.getElementById("ghost-offset") as HTMLInputElement; // log10 of the offset (m)
198+const ghostOffsetValue = document.getElementById("ghost-offset-value")!;
199+const ghostSep = document.getElementById("ghost-sep")!;
200+const SUP: Record<string, string> = { "-": "⁻", "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹" };
201+const showGhostOffset = () =>
202+ (ghostOffsetValue.textContent = `10${[...ghostOffset.value].map((c) => SUP[c]).join("")} m`);
203+showGhostOffset();
204+function resetGhost() {
205+ ghost = null;
206+ if (!ghostToggle.checked || energy(P, s) === 0) return;
207+ const g = copyState(s);
208+ const each = 10 ** Number(ghostOffset.value) / Math.sqrt(P.N);
209+ for (let j = 0; j < P.N; j++) g.u[j] += each;
210+ ghost = { s: g, verlet: new Verlet(P, g) };
211+ ghostT0 = t;
212+}
213+ghostToggle.addEventListener("change", () => {
214+ ghostControls.hidden = !ghostToggle.checked;
215+ traces.setAvailable("Separation", ghostToggle.checked);
216+ resetGhost();
217+});
218+ghostOffset.addEventListener("input", () => {
219+ showGhostOffset();
220+ resetGhost();
221+});
222+document.getElementById("ghost-reset")!.addEventListener("click", resetGhost);
223+let shownSep = "";
224+function showSeparation() {
225+ const sep = ghost ? `separation ${ghostSeparation().toExponential(1)} m · ${(t - ghostT0).toFixed(1)} s` : "";
226+ if (sep !== shownSep) ghostSep.textContent = shownSep = sep;
227+}
228+
229+// Mode bars: the current energy of each mode (chip ahead of the trace chips).
230+const barsToggle = document.getElementById("bars-toggle") as HTMLInputElement;
231+const barsFig = document.getElementById("bars")!;
232+barsToggle.addEventListener("change", () => (barsFig.hidden = !barsToggle.checked));
233+
234+// Parameters form.
235+const paramInputs = {
236+ m: document.getElementById("p-m") as HTMLInputElement,
237+ k: document.getElementById("p-k") as HTMLInputElement,
238+ k3: document.getElementById("p-k3") as HTMLInputElement,
239+ a: document.getElementById("p-a") as HTMLInputElement,
240+ tau: document.getElementById("p-tau") as HTMLInputElement,
241+};
242+const paramStatus = document.getElementById("param-status")!;
243+function showParams() {
244+ for (const [key, el] of Object.entries(paramInputs)) el.value = String(P[key as keyof typeof paramInputs]);
245+ showDerived();
246+}
247+// Values that follow from the parameters (and, for the step, the last strike).
248+function showDerived() {
249+ const hz = Array.from(modes.omega, (w) => w / (2 * Math.PI));
250+ const list = P.N <= 8 ? hz.map((f) => f.toFixed(3)).join(", ") : `${hz[0].toFixed(3)} … ${hz[P.N - 1].toFixed(3)}`;
251+ document.getElementById("params")!.textContent =
252+ `normal modes ${list} Hz\n` +
253+ `cubic = linear at stretch d0 = sqrt(k/k3) = ${fmtLength(d0(P))}\n` +
254+ `nonlinear scale k d0² = ${fmtEnergy(P.k * d0(P) ** 2)}\n` +
255+ `decay time ${damped() ? `${P.tau} s (amplitude, all modes)` : "none (no damping)"}\n` +
256+ `integrator step ${(1000 * H).toPrecision(3)} ms (from the strike energy; ` +
257+ `${(1000 * stepFor(P, 0)).toPrecision(3)} ms when weak, ${(1000 * stepFor(P, 10 ** Number(energyInput.max))).toPrecision(3)} ms at the top of the slider)`;
258+}
259+
260+// Sound: the last strike again, sped up so the lowest linear mode sounds at
261+// the chosen pitch, rendered in a worker. While the panel is open (the
262+// default) each Strike plays a note (a hint says so until the first one);
263+// the note is computed and shown even when muted, which is how the page
264+// starts. A damped note is the whole decay, to -60 dB. The displays (from
265+// strike at the left to the end of the note at the right) fill in as the
266+// audio clock reaches each sample. Replay plays the last strike again with
267+// the current settings, to compare signals.
268+const soundPanel = document.getElementById("sound") as HTMLDetailsElement;
269+const muteButton = document.getElementById("mute") as HTMLButtonElement;
270+const replayButton = document.getElementById("replay") as HTMLButtonElement;
271+const signalSelect = document.getElementById("signal") as HTMLSelectElement;
272+const soundMass = document.getElementById("sound-mass") as HTMLInputElement;
273+const pitchInput = document.getElementById("pitch") as HTMLInputElement;
274+const soundStatus = document.getElementById("sound-status")!;
275+const soundHint = document.getElementById("sound-hint")!;
276+const soundClock = document.getElementById("sound-clock")!;
277+const specFig = document.getElementById("spec-fig")!;
278+for (const sig of SIGNALS) signalSelect.add(new Option(sig.label, sig.key));
279+signalSelect.value = "v";
280+const showSoundMass = () => (soundMass.hidden = !signalByKey(signalSelect.value).perMass);
281+signalSelect.addEventListener("change", showSoundMass);
282+showSoundMass();
283+
284+const player = new Player();
285+const damped = () => P.tau > 0;
286+const pitch = () => 10 ** Number(pitchInput.value); // Hz of the lowest linear mode
287+const speedup = () => (pitch() * 2 * Math.PI) / modes.omega[0];
288+const modePitches = () => Array.from(modes.omega, (w) => (w / (2 * Math.PI)) * speedup());
289+// Energy decays at about 2/tau, so a drop by END_RATIO takes about
290+// (tau/2) ln(1/END_RATIO) of physical time (s of sound here).
291+const predictedNoteLength = () =>
292+ damped() ? ((P.tau / 2) * Math.log(1 / END_RATIO)) / speedup() : UNDAMPED_LENGTH;
293+const fmtDuration = (x: number) => (x >= 120 ? `${(x / 60).toFixed(0)} min` : `${x.toFixed(x >= 10 ? 0 : 1)} s`);
294+function showPitch() {
295+ const f = modePitches();
296+ document.getElementById("pitch-value")!.textContent =
297+ `lowest mode ${f[0].toFixed(0)} Hz · highest ${f[f.length - 1].toFixed(0)} Hz`;
298+ // The sound runs on its own clock: the same strike, sped up.
299+ const S = speedup(), note = predictedNoteLength();
300+ soundClock.textContent = damped()
301+ ? `the whole decay, ${S.toFixed(0)}× faster: ${fmtDuration(note * S)} of motion in ${fmtDuration(note)}`
302+ : `${S.toFixed(0)}× faster than the chain above`;
303+}
304+pitchInput.addEventListener("input", showPitch);
305+document.getElementById("volume")!.addEventListener("input", (e) =>
306+ player.setVolume(Number((e.target as HTMLInputElement).value)),
307+);
308+
309+// Speaker icon: with sound waves when audible; crossed out, tinted, and
310+// labelled "Muted" when muted, so the silence is not mistaken for a fault.
311+const SPEAKER = '<path d="M4 9h3l5-4v14l-5-4H4z" fill="currentColor" stroke="none"/>';
312+const ICON_MUTED = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">${SPEAKER}<path d="M16 9l5 6M21 9l-5 6"/></svg>`;
313+const ICON_AUDIBLE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">${SPEAKER}<path d="M16 9.5a3.5 3.5 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10"/></svg>`;
314+function showMute() {
315+ const label = player.muted ? "Unmute" : "Mute";
316+ muteButton.innerHTML = player.muted ? `${ICON_MUTED}<span>Muted</span>` : ICON_AUDIBLE;
317+ muteButton.title = label;
318+ muteButton.setAttribute("aria-label", label);
319+ muteButton.classList.toggle("audible", !player.muted);
320+ muteButton.classList.toggle("is-muted", player.muted);
321+}
322+showMute();
323+muteButton.addEventListener("click", () => {
324+ player.setMuted(!player.muted);
325+ showMute();
326+});
327+
328+// Closing the panel stops the note. The audio context is created at the
329+// first note, from the Strike click (browsers require a user gesture).
330+soundPanel.addEventListener("toggle", () => {
331+ if (!soundPanel.open) player.stop();
332+});
333+
334+// Displays of the note: spectrogram, Lyapunov exponent and energy, sharing
335+// one time cursor (the playhead while playing; hover / click to pin), and
336+// lambda against energy accumulated over notes.
337+const soundCursor = newCursor();
338+let spec: NoteSpectrogram | null = null;
339+const specToggle = document.getElementById("spec-toggle") as HTMLInputElement;
340+specToggle.addEventListener("change", () => (specFig.hidden = !specToggle.checked));
341+let noteEnergy = NaN, noteLambda = NaN;
342+const soundTraces = new TracePanel(
343+ document.getElementById("sound-chips")!,
344+ document.getElementById("sound-traces")!,
345+ [
346+ {
347+ label: "Lyapunov", unit: "1/s",
348+ skip: () => LYAP_SKIP / speedup(), // the same physical time as in the animation, in s of sound
349+ series: [{ label: "λ 10 s", value: () => noteLambda }],
350+ },
351+ // Log scale: the decay is exponential, a straight line.
352+ { label: "Energy", unit: "J", log: true, series: [{ label: "E", value: () => noteEnergy }] },
353+ ],
354+ 10, 960, soundCursor,
355+);
356+// Lambda against energy, accumulated over notes (chip after the trace chips).
357+const scatter = new ScatterPlot(960);
358+document.getElementById("scatter")!.appendChild(scatter.canvas);
359+const scatterFig = document.getElementById("scatter-fig")!;
360+{
361+ const box = document.createElement("input");
362+ box.type = "checkbox";
363+ box.addEventListener("change", () => (scatterFig.hidden = !box.checked));
364+ const text = document.createElement("span");
365+ text.textContent = "λ vs E";
366+ const chip = document.createElement("label");
367+ chip.className = "chip";
368+ chip.append(box, text);
369+ document.getElementById("sound-chips")!.appendChild(chip);
370+}
371+document.getElementById("scatter-clear")!.addEventListener("click", () => scatter.clear());
372+// The rendered note and how much of it the displays have been fed.
373+let note: { x: Float32Array; energy: Float32Array; lambda: Float32Array; rate: number } | null = null;
374+let fed = 0;
375+function feedDisplays(upTo: number) {
376+ if (!note || !spec) return;
377+ const end = Math.min(note.x.length, upTo);
378+ for (; fed < end; fed++) {
379+ spec.push(note.x[fed]);
380+ if (fed % DIAG_EVERY === 0) {
381+ const t = fed / note.rate;
382+ noteEnergy = note.energy[fed / DIAG_EVERY];
383+ noteLambda = note.lambda[fed / DIAG_EVERY];
384+ soundTraces.sample(t);
385+ scatter.add(noteEnergy, noteLambda, t);
386+ }
387+ }
388+}
389+
390+const worker = new Worker(new URL("./render-worker.ts", import.meta.url), { type: "module" });
391+let requestId = 0;
392+let noteInfo = "";
393+function renderNote() {
394+ if (!lastStrike || !soundPanel.open) return;
395+ const ctx = player.ensure();
396+ player.setPaused(!running); // resumes the audio clock within the click
397+ const sig = signalByKey(signalSelect.value);
398+ const j = Math.min(P.N, Math.max(1, Math.round(Number(soundMass.value) || 1)));
399+ soundMass.value = String(j);
400+ const req: RenderRequest = {
401+ id: ++requestId, P, state: lastStrike, speedup: speedup(), sampleRate: ctx.sampleRate,
402+ maxLength: 2 * predictedNoteLength(), signal: sig.key, j: j - 1,
403+ };
404+ noteInfo = `${sig.label}${sig.perMass ? ` ${j}` : ""}`;
405+ soundStatus.textContent = "computing…";
406+ soundHint.hidden = true;
407+ player.stop();
408+ worker.postMessage(req);
409+}
410+worker.onmessage = (e: MessageEvent<RenderResult>) => {
411+ if (e.data.id !== requestId) return; // superseded
412+ const rate = player.sampleRate;
413+ const x = e.data.samples;
414+ Player.normalize(x, rate);
415+ note = { x, energy: e.data.energy, lambda: e.data.lambda, rate };
416+ fed = 0;
417+ const span = x.length / rate;
418+ spec = new NoteSpectrogram(
419+ { dt: 1 / rate, n: 2048, nfft: 4096, hop: 512, refFreqs: modePitches(), width: 960 },
420+ soundCursor,
421+ );
422+ spec.start(span);
423+ document.getElementById("spectrogram")!.replaceChildren(spec.canvas);
424+ soundTraces.clear();
425+ soundTraces.setFixed({ t0: 0, span });
426+ scatter.newNote(span);
427+ soundStatus.textContent = `${noteInfo} · ${fmtDuration(span)}`;
428+ player.play(x);
429+ player.setPaused(!running);
430+};
431+replayButton.addEventListener("click", renderNote);
432+
433+// Set new parameters: everything back to rest.
434+function setParams(next: Params) {
435+ const shown = traces ? traces.shownLabels() : ["Displacements", "Mode energies"];
436+ P = next;
437+ H = stepFor(P, 0);
438+ modes = new Modes(P);
439+ s = newState(P.N);
440+ verlet = new Verlet(P, s);
441+ Ek = new Float64Array(P.N);
442+ E0 = 0;
443+ t = 0;
444+ ampl = 0;
445+ ghost = null;
446+ lyap.stop();
447+ nInput.value = String(P.N);
448+ indexInput.max = String(P.N);
449+ indexInput.value = String(strikeIndex());
450+ pauseButton.disabled = true;
451+ setRunning(false);
452+ pauseButton.textContent = "Pause";
453+ lastStrike = null;
454+ replayButton.disabled = true;
455+ player.stop();
456+ soundMass.max = String(P.N);
457+ soundMass.value = String(Math.min(P.N, Number(soundMass.value) || 1));
458+ showPitch();
459+ buildTraces(shown);
460+ showParams();
461+}
462+setParams(P);
463+
464+nInput.addEventListener("change", () => {
465+ const N = Math.min(64, Math.max(2, Math.round(Number(nInput.value) || P.N)));
466+ setParams({ ...P, N });
467+});
468+document.getElementById("param-apply")!.addEventListener("click", () => {
469+ const v = Object.fromEntries(Object.entries(paramInputs).map(([key, el]) => [key, Number(el.value)]));
470+ if (!Object.entries(v).every(([key, x]) => Number.isFinite(x) && (x > 0 || (key === "tau" && x === 0)))) {
471+ paramStatus.textContent = "parameters must be positive (tau may be 0)";
472+ return;
473+ }
474+ paramStatus.textContent = "";
475+ setParams({ ...P, ...v });
476+});
477+
478+// Size a canvas's backing store to its displayed size at device resolution.
479+function fit(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
480+ const dpr = window.devicePixelRatio || 1;
481+ const w = Math.round(canvas.clientWidth * dpr), h = Math.round(canvas.clientHeight * dpr);
482+ if (canvas.width !== w || canvas.height !== h) {
483+ canvas.width = w;
484+ canvas.height = h;
485+ }
486+ const ctx = canvas.getContext("2d")!;
487+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
488+ return ctx;
489+}
490+
491+const ACCENT = "#2f5d8a", TEXT = "#23262b", MUTED = "#7a7d82", LINE = "#e3e2dc";
492+const MONO = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
493+
494+// Spring as a zigzag from x0 to x1 at height y.
495+function spring(ctx: CanvasRenderingContext2D, x0: number, x1: number, y: number, amp: number) {
496+ const teeth = Math.max(2, Math.min(6, Math.round(Math.abs(x1 - x0) / 8))), lead = 0.15;
497+ const a = x0 + lead * (x1 - x0), b = x1 - lead * (x1 - x0);
498+ ctx.moveTo(x0, y);
499+ ctx.lineTo(a, y);
500+ for (let i = 0; i < 2 * teeth; i++) ctx.lineTo(a + ((i + 0.5) / (2 * teeth)) * (b - a), y + (i % 2 ? amp : -amp));
501+ ctx.lineTo(b, y);
502+ ctx.lineTo(x1, y);
503+}
504+
505+// Top: the chain as it moves, to scale (rest spacing a). Below: the same
506+// displacements drawn sideways, as a profile along the chain, scaled to fit.
507+// The ghost, if on, is drawn the same way in translucent orange, underneath.
508+const GHOST = "rgba(214, 120, 40, 0.45)";
509+function drawChain() {
510+ const ctx = fit(chainCanvas);
511+ const W = chainCanvas.clientWidth, Ht = chainCanvas.clientHeight;
512+ ctx.clearRect(0, 0, W, Ht);
513+ const { N } = P;
514+ const m = 14, dx = (W - 2 * m) / (N + 1);
515+ const x = (j: number) => m + (j + 1) * dx; // rest position of mass j (0..N-1); walls at j = -1, N
516+ const r = Math.max(2.5, Math.min(6, dx / 5));
517+ const yc = 64; // the chain's line
518+ const top = 116, bottom = Ht - 12, y0 = (top + bottom) / 2, half = 0.45 * (bottom - top); // profile
519+ const scale = ampl > 0 ? half / ampl : 0; // profile px per m
520+
521+ // Walls and the profile's zero line.
522+ ctx.strokeStyle = MUTED;
523+ ctx.lineWidth = 2;
524+ ctx.beginPath();
525+ ctx.moveTo(m, yc - 16);
526+ ctx.lineTo(m, yc + 16);
527+ ctx.moveTo(W - m, yc - 16);
528+ ctx.lineTo(W - m, yc + 16);
529+ ctx.stroke();
530+ ctx.strokeStyle = LINE;
531+ ctx.lineWidth = 1;
532+ ctx.beginPath();
533+ ctx.moveTo(m, y0);
534+ ctx.lineTo(W - m, y0);
535+ ctx.stroke();
536+
537+ const drawState = (st: State, ghostly: boolean) => {
538+ const color = (j: number) => (ghostly ? GHOST : indexColor(j, N));
539+ // Chain.
540+ const pos = (j: number) => (j < 0 ? m : j >= N ? W - m : x(j) + (dx / P.a) * st.u[j]);
541+ ctx.strokeStyle = ghostly ? GHOST : MUTED;
542+ ctx.lineWidth = 1;
543+ ctx.beginPath();
544+ for (let j = -1; j < N; j++) spring(ctx, pos(j), pos(j + 1), yc, Math.min(5, dx / 6));
545+ ctx.stroke();
546+ for (let j = 0; j < N; j++) {
547+ ctx.fillStyle = color(j);
548+ ctx.beginPath();
549+ ctx.arc(pos(j), yc, r, 0, 2 * Math.PI);
550+ ctx.fill();
551+ }
552+ // Profile.
553+ ctx.lineWidth = 1.5;
554+ ctx.beginPath();
555+ ctx.moveTo(m, y0);
556+ for (let j = 0; j < N; j++) ctx.lineTo(x(j), y0 - scale * st.u[j]);
557+ ctx.lineTo(W - m, y0);
558+ ctx.stroke();
559+ for (let j = 0; j < N; j++) {
560+ ctx.fillStyle = color(j);
561+ ctx.beginPath();
562+ ctx.arc(x(j), y0 - scale * st.u[j], Math.min(r, 4), 0, 2 * Math.PI);
563+ ctx.fill();
564+ }
565+ };
566+ if (ghost) drawState(ghost.s, true);
567+ drawState(s, false);
568+
569+ ctx.fillStyle = MUTED;
570+ ctx.font = "11px system-ui, sans-serif";
571+ const span = ampl > 0 ? `, full height ±${fmtLength(ampl / 0.9)}` : "";
572+ ctx.fillText(`displacement, drawn sideways${span}`, m, top - 4);
573+
574+ // Readouts.
575+ ctx.fillStyle = TEXT;
576+ ctx.font = MONO;
577+ ctx.fillText(`t ${t.toFixed(2)} s · E ${fmtEnergy(energy(P, s))}`, m, 16);
578+ ctx.fillStyle = MUTED;
579+ ctx.textAlign = "right";
580+ ctx.fillText(`energy error ${Math.abs(relErr()).toExponential(1)}`, W - m, 16);
581+ ctx.textAlign = "left";
582+}
583+
584+// Harmonic energy of each mode relative to the strike energy, log scale.
585+const DECADES = 12;
586+function drawModes() {
587+ const ctx = fit(modesCanvas);
588+ const W = modesCanvas.clientWidth, Ht = modesCanvas.clientHeight;
589+ ctx.clearRect(0, 0, W, Ht);
590+ const { N } = P;
591+ const left = 40, right = W - 6, top = 8, bottom = Ht - 22;
592+ const yOf = (lg: number) => top + (-lg / DECADES) * (bottom - top); // lg = log10(E_n / E), 0 at top
593+
594+ ctx.font = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
595+ ctx.lineWidth = 1;
596+ ctx.textAlign = "right";
597+ ctx.textBaseline = "middle";
598+ for (let d = 0; d <= DECADES; d += 3) {
599+ const y = yOf(-d);
600+ ctx.strokeStyle = LINE;
601+ ctx.beginPath();
602+ ctx.moveTo(left, y);
603+ ctx.lineTo(right, y);
604+ ctx.stroke();
605+ ctx.fillStyle = MUTED;
606+ ctx.fillText(d === 0 ? "1" : `1e-${d}`, left - 6, y);
607+ }
608+
609+ const bw = (right - left) / N;
610+ ctx.textAlign = "center";
611+ ctx.textBaseline = "top";
612+ const every = N <= 16 ? 1 : N <= 32 ? 4 : 8;
613+ for (let n = 1; n <= N; n++) {
614+ if (n === 1 || n % every === 0) ctx.fillText(String(n), left + (n - 0.5) * bw, bottom + 6);
615+ }
616+ ctx.textBaseline = "alphabetic";
617+
618+ let pr = 0;
619+ if (E0 > 0) {
620+ modes.energies(s, Ek);
621+ let sum = 0;
622+ for (let n = 0; n < N; n++) sum += Ek[n];
623+ for (let n = 0; n < N; n++) {
624+ const lg = Math.log10(Ek[n] / E0);
625+ if (!(lg > -DECADES)) continue;
626+ const y = yOf(Math.min(0, lg));
627+ ctx.fillStyle = indexColor(n, N);
628+ ctx.fillRect(left + n * bw + 0.15 * bw, y, 0.7 * bw, bottom - y);
629+ pr += (Ek[n] / sum) ** 2;
630+ }
631+ }
632+ const w = modes.omega;
633+ const hz = (x: number) => (x / (2 * Math.PI)).toFixed(3);
634+ modeStats.textContent =
635+ (pr > 0 ? `modes sharing the energy ${(1 / pr).toFixed(1)} · ` : "") + `modes ${hz(w[0])} … ${hz(w[N - 1])} Hz`;
636+}
637+
638+// Animation: the simulation advances `speed` seconds per second of real time.
639+let last = performance.now();
640+let carry = 0; // simulated time owed but not yet stepped
641+let sinceSample = 0;
642+let playing = false; // a note was playing at the last frame
643+function frame(now: number) {
644+ const dt = Math.min(0.1, (now - last) / 1000);
645+ last = now;
646+ if (running) {
647+ carry += Number(speedSelect.value) * dt;
648+ while (carry >= H) {
649+ verlet.step(s, H);
650+ ghost?.verlet.step(ghost.s, H);
651+ lyap.step(s, H, t + H);
652+ carry -= H;
653+ t += H;
654+ sinceSample += H;
655+ if (sinceSample >= SAMPLE_DT) {
656+ sinceSample -= SAMPLE_DT;
657+ modes.energies(s, Ek);
658+ traces.sample(t);
659+ }
660+ }
661+ }
662+ let umax = 0;
663+ for (const u of s.u) umax = Math.max(umax, Math.abs(u));
664+ ampl = Math.max(umax, 0.998 * ampl);
665+ drawChain();
666+ showSeparation();
667+ if (!barsFig.hidden) drawModes();
668+ traces.draw();
669+ if (note && soundPanel.open) {
670+ // Feed the displays up to the audio clock; playhead unless pinned.
671+ const at = player.position();
672+ if (at !== null) feedDisplays(Math.round(at * note.rate));
673+ else if (playing) feedDisplays(note.x.length); // ended
674+ if (!soundCursor.pinned && (at !== null || playing)) soundCursor.t = at;
675+ playing = at !== null;
676+ if (!specFig.hidden) spec?.draw();
677+ soundTraces.draw();
678+ if (!scatterFig.hidden) scatter.draw();
679+ }
680+ requestAnimationFrame(frame);
681+}
682+requestAnimationFrame(frame);
src/fput-chain/package-lock.jsonadded+1202−0View file
@@ -0,0 +1,1202 @@
1+{
2+ "name": "fput-chain-chaos",
3+ "lockfileVersion": 3,
4+ "requires": true,
5+ "packages": {
6+ "": {
7+ "name": "fput-chain-chaos",
8+ "devDependencies": {
9+ "typescript": "^7.0.2",
10+ "vite": "^8.3.4"
11+ }
12+ },
13+ "node_modules/@oxc-project/types": {
14+ "version": "0.153.0",
15+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
16+ "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
17+ "dev": true,
18+ "license": "MIT",
19+ "funding": {
20+ "url": "https://github.com/sponsors/oxc-project"
21+ }
22+ },
23+ "node_modules/@rolldown/binding-android-arm-eabi": {
24+ "version": "1.2.13",
25+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
26+ "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
27+ "cpu": [
28+ "arm"
29+ ],
30+ "dev": true,
31+ "license": "MIT",
32+ "optional": true,
33+ "os": [
34+ "android"
35+ ],
36+ "engines": {
37+ "node": "^20.19.0 || >=22.12.0"
38+ }
39+ },
40+ "node_modules/@rolldown/binding-android-arm64": {
41+ "version": "1.2.13",
42+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
43+ "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
44+ "cpu": [
45+ "arm64"
46+ ],
47+ "dev": true,
48+ "license": "MIT",
49+ "optional": true,
50+ "os": [
51+ "android"
52+ ],
53+ "engines": {
54+ "node": "^20.19.0 || >=22.12.0"
55+ }
56+ },
57+ "node_modules/@rolldown/binding-darwin-arm64": {
58+ "version": "1.2.13",
59+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
60+ "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
61+ "cpu": [
62+ "arm64"
63+ ],
64+ "dev": true,
65+ "license": "MIT",
66+ "optional": true,
67+ "os": [
68+ "darwin"
69+ ],
70+ "engines": {
71+ "node": "^20.19.0 || >=22.12.0"
72+ }
73+ },
74+ "node_modules/@rolldown/binding-darwin-x64": {
75+ "version": "1.2.13",
76+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
77+ "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
78+ "cpu": [
79+ "x64"
80+ ],
81+ "dev": true,
82+ "license": "MIT",
83+ "optional": true,
84+ "os": [
85+ "darwin"
86+ ],
87+ "engines": {
88+ "node": "^20.19.0 || >=22.12.0"
89+ }
90+ },
91+ "node_modules/@rolldown/binding-freebsd-x64": {
92+ "version": "1.2.13",
93+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
94+ "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
95+ "cpu": [
96+ "x64"
97+ ],
98+ "dev": true,
99+ "license": "MIT",
100+ "optional": true,
101+ "os": [
102+ "freebsd"
103+ ],
104+ "engines": {
105+ "node": "^20.19.0 || >=22.12.0"
106+ }
107+ },
108+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
109+ "version": "1.2.13",
110+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
111+ "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
112+ "cpu": [
113+ "arm"
114+ ],
115+ "dev": true,
116+ "license": "MIT",
117+ "optional": true,
118+ "os": [
119+ "linux"
120+ ],
121+ "engines": {
122+ "node": "^20.19.0 || >=22.12.0"
123+ }
124+ },
125+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
126+ "version": "1.2.13",
127+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
128+ "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
129+ "cpu": [
130+ "arm64"
131+ ],
132+ "dev": true,
133+ "libc": [
134+ "glibc"
135+ ],
136+ "license": "MIT",
137+ "optional": true,
138+ "os": [
139+ "linux"
140+ ],
141+ "engines": {
142+ "node": "^20.19.0 || >=22.12.0"
143+ }
144+ },
145+ "node_modules/@rolldown/binding-linux-arm64-musl": {
146+ "version": "1.2.13",
147+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
148+ "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
149+ "cpu": [
150+ "arm64"
151+ ],
152+ "dev": true,
153+ "libc": [
154+ "musl"
155+ ],
156+ "license": "MIT",
157+ "optional": true,
158+ "os": [
159+ "linux"
160+ ],
161+ "engines": {
162+ "node": "^20.19.0 || >=22.12.0"
163+ }
164+ },
165+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
166+ "version": "1.2.13",
167+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
168+ "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
169+ "cpu": [
170+ "ppc64"
171+ ],
172+ "dev": true,
173+ "libc": [
174+ "glibc"
175+ ],
176+ "license": "MIT",
177+ "optional": true,
178+ "os": [
179+ "linux"
180+ ],
181+ "engines": {
182+ "node": "^20.19.0 || >=22.12.0"
183+ }
184+ },
185+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
186+ "version": "1.2.13",
187+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
188+ "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
189+ "cpu": [
190+ "s390x"
191+ ],
192+ "dev": true,
193+ "libc": [
194+ "glibc"
195+ ],
196+ "license": "MIT",
197+ "optional": true,
198+ "os": [
199+ "linux"
200+ ],
201+ "engines": {
202+ "node": "^20.19.0 || >=22.12.0"
203+ }
204+ },
205+ "node_modules/@rolldown/binding-linux-x64-gnu": {
206+ "version": "1.2.13",
207+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
208+ "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
209+ "cpu": [
210+ "x64"
211+ ],
212+ "dev": true,
213+ "libc": [
214+ "glibc"
215+ ],
216+ "license": "MIT",
217+ "optional": true,
218+ "os": [
219+ "linux"
220+ ],
221+ "engines": {
222+ "node": "^20.19.0 || >=22.12.0"
223+ }
224+ },
225+ "node_modules/@rolldown/binding-linux-x64-musl": {
226+ "version": "1.2.13",
227+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
228+ "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
229+ "cpu": [
230+ "x64"
231+ ],
232+ "dev": true,
233+ "libc": [
234+ "musl"
235+ ],
236+ "license": "MIT",
237+ "optional": true,
238+ "os": [
239+ "linux"
240+ ],
241+ "engines": {
242+ "node": "^20.19.0 || >=22.12.0"
243+ }
244+ },
245+ "node_modules/@rolldown/binding-openharmony-arm64": {
246+ "version": "1.2.13",
247+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
248+ "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
249+ "cpu": [
250+ "arm64"
251+ ],
252+ "dev": true,
253+ "license": "MIT",
254+ "optional": true,
255+ "os": [
256+ "openharmony"
257+ ],
258+ "engines": {
259+ "node": "^20.19.0 || >=22.12.0"
260+ }
261+ },
262+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
263+ "version": "1.2.13",
264+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
265+ "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
266+ "cpu": [
267+ "arm64"
268+ ],
269+ "dev": true,
270+ "license": "MIT",
271+ "optional": true,
272+ "os": [
273+ "win32"
274+ ],
275+ "engines": {
276+ "node": "^20.19.0 || >=22.12.0"
277+ }
278+ },
279+ "node_modules/@rolldown/binding-win32-x64-msvc": {
280+ "version": "1.2.13",
281+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
282+ "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
283+ "cpu": [
284+ "x64"
285+ ],
286+ "dev": true,
287+ "license": "MIT",
288+ "optional": true,
289+ "os": [
290+ "win32"
291+ ],
292+ "engines": {
293+ "node": "^20.19.0 || >=22.12.0"
294+ }
295+ },
296+ "node_modules/@rolldown/pluginutils": {
297+ "version": "1.0.1",
298+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
299+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
300+ "dev": true,
301+ "license": "MIT"
302+ },
303+ "node_modules/@typescript/typescript-aix-ppc64": {
304+ "version": "7.0.2",
305+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
306+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
307+ "cpu": [
308+ "ppc64"
309+ ],
310+ "dev": true,
311+ "license": "Apache-2.0",
312+ "optional": true,
313+ "os": [
314+ "aix"
315+ ],
316+ "engines": {
317+ "node": ">=16.20.0"
318+ }
319+ },
320+ "node_modules/@typescript/typescript-darwin-arm64": {
321+ "version": "7.0.2",
322+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
323+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
324+ "cpu": [
325+ "arm64"
326+ ],
327+ "dev": true,
328+ "license": "Apache-2.0",
329+ "optional": true,
330+ "os": [
331+ "darwin"
332+ ],
333+ "engines": {
334+ "node": ">=16.20.0"
335+ }
336+ },
337+ "node_modules/@typescript/typescript-darwin-x64": {
338+ "version": "7.0.2",
339+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
340+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
341+ "cpu": [
342+ "x64"
343+ ],
344+ "dev": true,
345+ "license": "Apache-2.0",
346+ "optional": true,
347+ "os": [
348+ "darwin"
349+ ],
350+ "engines": {
351+ "node": ">=16.20.0"
352+ }
353+ },
354+ "node_modules/@typescript/typescript-freebsd-arm64": {
355+ "version": "7.0.2",
356+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
357+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
358+ "cpu": [
359+ "arm64"
360+ ],
361+ "dev": true,
362+ "license": "Apache-2.0",
363+ "optional": true,
364+ "os": [
365+ "freebsd"
366+ ],
367+ "engines": {
368+ "node": ">=16.20.0"
369+ }
370+ },
371+ "node_modules/@typescript/typescript-freebsd-x64": {
372+ "version": "7.0.2",
373+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
374+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
375+ "cpu": [
376+ "x64"
377+ ],
378+ "dev": true,
379+ "license": "Apache-2.0",
380+ "optional": true,
381+ "os": [
382+ "freebsd"
383+ ],
384+ "engines": {
385+ "node": ">=16.20.0"
386+ }
387+ },
388+ "node_modules/@typescript/typescript-linux-arm": {
389+ "version": "7.0.2",
390+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
391+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
392+ "cpu": [
393+ "arm"
394+ ],
395+ "dev": true,
396+ "license": "Apache-2.0",
397+ "optional": true,
398+ "os": [
399+ "linux"
400+ ],
401+ "engines": {
402+ "node": ">=16.20.0"
403+ }
404+ },
405+ "node_modules/@typescript/typescript-linux-arm64": {
406+ "version": "7.0.2",
407+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
408+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
409+ "cpu": [
410+ "arm64"
411+ ],
412+ "dev": true,
413+ "license": "Apache-2.0",
414+ "optional": true,
415+ "os": [
416+ "linux"
417+ ],
418+ "engines": {
419+ "node": ">=16.20.0"
420+ }
421+ },
422+ "node_modules/@typescript/typescript-linux-loong64": {
423+ "version": "7.0.2",
424+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
425+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
426+ "cpu": [
427+ "loong64"
428+ ],
429+ "dev": true,
430+ "license": "Apache-2.0",
431+ "optional": true,
432+ "os": [
433+ "linux"
434+ ],
435+ "engines": {
436+ "node": ">=16.20.0"
437+ }
438+ },
439+ "node_modules/@typescript/typescript-linux-mips64el": {
440+ "version": "7.0.2",
441+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
442+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
443+ "cpu": [
444+ "mips64el"
445+ ],
446+ "dev": true,
447+ "license": "Apache-2.0",
448+ "optional": true,
449+ "os": [
450+ "linux"
451+ ],
452+ "engines": {
453+ "node": ">=16.20.0"
454+ }
455+ },
456+ "node_modules/@typescript/typescript-linux-ppc64": {
457+ "version": "7.0.2",
458+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
459+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
460+ "cpu": [
461+ "ppc64"
462+ ],
463+ "dev": true,
464+ "license": "Apache-2.0",
465+ "optional": true,
466+ "os": [
467+ "linux"
468+ ],
469+ "engines": {
470+ "node": ">=16.20.0"
471+ }
472+ },
473+ "node_modules/@typescript/typescript-linux-riscv64": {
474+ "version": "7.0.2",
475+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
476+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
477+ "cpu": [
478+ "riscv64"
479+ ],
480+ "dev": true,
481+ "license": "Apache-2.0",
482+ "optional": true,
483+ "os": [
484+ "linux"
485+ ],
486+ "engines": {
487+ "node": ">=16.20.0"
488+ }
489+ },
490+ "node_modules/@typescript/typescript-linux-s390x": {
491+ "version": "7.0.2",
492+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
493+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
494+ "cpu": [
495+ "s390x"
496+ ],
497+ "dev": true,
498+ "license": "Apache-2.0",
499+ "optional": true,
500+ "os": [
501+ "linux"
502+ ],
503+ "engines": {
504+ "node": ">=16.20.0"
505+ }
506+ },
507+ "node_modules/@typescript/typescript-linux-x64": {
508+ "version": "7.0.2",
509+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
510+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
511+ "cpu": [
512+ "x64"
513+ ],
514+ "dev": true,
515+ "license": "Apache-2.0",
516+ "optional": true,
517+ "os": [
518+ "linux"
519+ ],
520+ "engines": {
521+ "node": ">=16.20.0"
522+ }
523+ },
524+ "node_modules/@typescript/typescript-netbsd-arm64": {
525+ "version": "7.0.2",
526+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
527+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
528+ "cpu": [
529+ "arm64"
530+ ],
531+ "dev": true,
532+ "license": "Apache-2.0",
533+ "optional": true,
534+ "os": [
535+ "netbsd"
536+ ],
537+ "engines": {
538+ "node": ">=16.20.0"
539+ }
540+ },
541+ "node_modules/@typescript/typescript-netbsd-x64": {
542+ "version": "7.0.2",
543+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
544+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
545+ "cpu": [
546+ "x64"
547+ ],
548+ "dev": true,
549+ "license": "Apache-2.0",
550+ "optional": true,
551+ "os": [
552+ "netbsd"
553+ ],
554+ "engines": {
555+ "node": ">=16.20.0"
556+ }
557+ },
558+ "node_modules/@typescript/typescript-openbsd-arm64": {
559+ "version": "7.0.2",
560+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
561+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
562+ "cpu": [
563+ "arm64"
564+ ],
565+ "dev": true,
566+ "license": "Apache-2.0",
567+ "optional": true,
568+ "os": [
569+ "openbsd"
570+ ],
571+ "engines": {
572+ "node": ">=16.20.0"
573+ }
574+ },
575+ "node_modules/@typescript/typescript-openbsd-x64": {
576+ "version": "7.0.2",
577+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
578+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
579+ "cpu": [
580+ "x64"
581+ ],
582+ "dev": true,
583+ "license": "Apache-2.0",
584+ "optional": true,
585+ "os": [
586+ "openbsd"
587+ ],
588+ "engines": {
589+ "node": ">=16.20.0"
590+ }
591+ },
592+ "node_modules/@typescript/typescript-sunos-x64": {
593+ "version": "7.0.2",
594+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
595+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
596+ "cpu": [
597+ "x64"
598+ ],
599+ "dev": true,
600+ "license": "Apache-2.0",
601+ "optional": true,
602+ "os": [
603+ "sunos"
604+ ],
605+ "engines": {
606+ "node": ">=16.20.0"
607+ }
608+ },
609+ "node_modules/@typescript/typescript-win32-arm64": {
610+ "version": "7.0.2",
611+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
612+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
613+ "cpu": [
614+ "arm64"
615+ ],
616+ "dev": true,
617+ "license": "Apache-2.0",
618+ "optional": true,
619+ "os": [
620+ "win32"
621+ ],
622+ "engines": {
623+ "node": ">=16.20.0"
624+ }
625+ },
626+ "node_modules/@typescript/typescript-win32-x64": {
627+ "version": "7.0.2",
628+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
629+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
630+ "cpu": [
631+ "x64"
632+ ],
633+ "dev": true,
634+ "license": "Apache-2.0",
635+ "optional": true,
636+ "os": [
637+ "win32"
638+ ],
639+ "engines": {
640+ "node": ">=16.20.0"
641+ }
642+ },
643+ "node_modules/detect-libc": {
644+ "version": "2.1.2",
645+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
646+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
647+ "dev": true,
648+ "license": "Apache-2.0",
649+ "engines": {
650+ "node": ">=8"
651+ }
652+ },
653+ "node_modules/fdir": {
654+ "version": "6.5.0",
655+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
656+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
657+ "dev": true,
658+ "license": "MIT",
659+ "engines": {
660+ "node": ">=12.0.0"
661+ },
662+ "peerDependencies": {
663+ "picomatch": "^3 || ^4"
664+ },
665+ "peerDependenciesMeta": {
666+ "picomatch": {
667+ "optional": true
668+ }
669+ }
670+ },
671+ "node_modules/fsevents": {
672+ "version": "2.3.3",
673+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
674+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
675+ "dev": true,
676+ "hasInstallScript": true,
677+ "license": "MIT",
678+ "optional": true,
679+ "os": [
680+ "darwin"
681+ ],
682+ "engines": {
683+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
684+ }
685+ },
686+ "node_modules/lightningcss": {
687+ "version": "1.33.0",
688+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
689+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
690+ "dev": true,
691+ "license": "MPL-2.0",
692+ "dependencies": {
693+ "detect-libc": "^2.0.3"
694+ },
695+ "engines": {
696+ "node": ">= 12.0.0"
697+ },
698+ "funding": {
699+ "type": "opencollective",
700+ "url": "https://opencollective.com/parcel"
701+ },
702+ "optionalDependencies": {
703+ "lightningcss-android-arm64": "1.33.0",
704+ "lightningcss-darwin-arm64": "1.33.0",
705+ "lightningcss-darwin-x64": "1.33.0",
706+ "lightningcss-freebsd-x64": "1.33.0",
707+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
708+ "lightningcss-linux-arm64-gnu": "1.33.0",
709+ "lightningcss-linux-arm64-musl": "1.33.0",
710+ "lightningcss-linux-x64-gnu": "1.33.0",
711+ "lightningcss-linux-x64-musl": "1.33.0",
712+ "lightningcss-win32-arm64-msvc": "1.33.0",
713+ "lightningcss-win32-x64-msvc": "1.33.0"
714+ }
715+ },
716+ "node_modules/lightningcss-android-arm64": {
717+ "version": "1.33.0",
718+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
719+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
720+ "cpu": [
721+ "arm64"
722+ ],
723+ "dev": true,
724+ "license": "MPL-2.0",
725+ "optional": true,
726+ "os": [
727+ "android"
728+ ],
729+ "engines": {
730+ "node": ">= 12.0.0"
731+ },
732+ "funding": {
733+ "type": "opencollective",
734+ "url": "https://opencollective.com/parcel"
735+ }
736+ },
737+ "node_modules/lightningcss-darwin-arm64": {
738+ "version": "1.33.0",
739+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
740+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
741+ "cpu": [
742+ "arm64"
743+ ],
744+ "dev": true,
745+ "license": "MPL-2.0",
746+ "optional": true,
747+ "os": [
748+ "darwin"
749+ ],
750+ "engines": {
751+ "node": ">= 12.0.0"
752+ },
753+ "funding": {
754+ "type": "opencollective",
755+ "url": "https://opencollective.com/parcel"
756+ }
757+ },
758+ "node_modules/lightningcss-darwin-x64": {
759+ "version": "1.33.0",
760+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
761+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
762+ "cpu": [
763+ "x64"
764+ ],
765+ "dev": true,
766+ "license": "MPL-2.0",
767+ "optional": true,
768+ "os": [
769+ "darwin"
770+ ],
771+ "engines": {
772+ "node": ">= 12.0.0"
773+ },
774+ "funding": {
775+ "type": "opencollective",
776+ "url": "https://opencollective.com/parcel"
777+ }
778+ },
779+ "node_modules/lightningcss-freebsd-x64": {
780+ "version": "1.33.0",
781+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
782+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
783+ "cpu": [
784+ "x64"
785+ ],
786+ "dev": true,
787+ "license": "MPL-2.0",
788+ "optional": true,
789+ "os": [
790+ "freebsd"
791+ ],
792+ "engines": {
793+ "node": ">= 12.0.0"
794+ },
795+ "funding": {
796+ "type": "opencollective",
797+ "url": "https://opencollective.com/parcel"
798+ }
799+ },
800+ "node_modules/lightningcss-linux-arm-gnueabihf": {
801+ "version": "1.33.0",
802+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
803+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
804+ "cpu": [
805+ "arm"
806+ ],
807+ "dev": true,
808+ "license": "MPL-2.0",
809+ "optional": true,
810+ "os": [
811+ "linux"
812+ ],
813+ "engines": {
814+ "node": ">= 12.0.0"
815+ },
816+ "funding": {
817+ "type": "opencollective",
818+ "url": "https://opencollective.com/parcel"
819+ }
820+ },
821+ "node_modules/lightningcss-linux-arm64-gnu": {
822+ "version": "1.33.0",
823+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
824+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
825+ "cpu": [
826+ "arm64"
827+ ],
828+ "dev": true,
829+ "libc": [
830+ "glibc"
831+ ],
832+ "license": "MPL-2.0",
833+ "optional": true,
834+ "os": [
835+ "linux"
836+ ],
837+ "engines": {
838+ "node": ">= 12.0.0"
839+ },
840+ "funding": {
841+ "type": "opencollective",
842+ "url": "https://opencollective.com/parcel"
843+ }
844+ },
845+ "node_modules/lightningcss-linux-arm64-musl": {
846+ "version": "1.33.0",
847+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
848+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
849+ "cpu": [
850+ "arm64"
851+ ],
852+ "dev": true,
853+ "libc": [
854+ "musl"
855+ ],
856+ "license": "MPL-2.0",
857+ "optional": true,
858+ "os": [
859+ "linux"
860+ ],
861+ "engines": {
862+ "node": ">= 12.0.0"
863+ },
864+ "funding": {
865+ "type": "opencollective",
866+ "url": "https://opencollective.com/parcel"
867+ }
868+ },
869+ "node_modules/lightningcss-linux-x64-gnu": {
870+ "version": "1.33.0",
871+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
872+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
873+ "cpu": [
874+ "x64"
875+ ],
876+ "dev": true,
877+ "libc": [
878+ "glibc"
879+ ],
880+ "license": "MPL-2.0",
881+ "optional": true,
882+ "os": [
883+ "linux"
884+ ],
885+ "engines": {
886+ "node": ">= 12.0.0"
887+ },
888+ "funding": {
889+ "type": "opencollective",
890+ "url": "https://opencollective.com/parcel"
891+ }
892+ },
893+ "node_modules/lightningcss-linux-x64-musl": {
894+ "version": "1.33.0",
895+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
896+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
897+ "cpu": [
898+ "x64"
899+ ],
900+ "dev": true,
901+ "libc": [
902+ "musl"
903+ ],
904+ "license": "MPL-2.0",
905+ "optional": true,
906+ "os": [
907+ "linux"
908+ ],
909+ "engines": {
910+ "node": ">= 12.0.0"
911+ },
912+ "funding": {
913+ "type": "opencollective",
914+ "url": "https://opencollective.com/parcel"
915+ }
916+ },
917+ "node_modules/lightningcss-win32-arm64-msvc": {
918+ "version": "1.33.0",
919+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
920+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
921+ "cpu": [
922+ "arm64"
923+ ],
924+ "dev": true,
925+ "license": "MPL-2.0",
926+ "optional": true,
927+ "os": [
928+ "win32"
929+ ],
930+ "engines": {
931+ "node": ">= 12.0.0"
932+ },
933+ "funding": {
934+ "type": "opencollective",
935+ "url": "https://opencollective.com/parcel"
936+ }
937+ },
938+ "node_modules/lightningcss-win32-x64-msvc": {
939+ "version": "1.33.0",
940+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
941+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
942+ "cpu": [
943+ "x64"
944+ ],
945+ "dev": true,
946+ "license": "MPL-2.0",
947+ "optional": true,
948+ "os": [
949+ "win32"
950+ ],
951+ "engines": {
952+ "node": ">= 12.0.0"
953+ },
954+ "funding": {
955+ "type": "opencollective",
956+ "url": "https://opencollective.com/parcel"
957+ }
958+ },
959+ "node_modules/nanoid": {
960+ "version": "3.3.20",
961+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
962+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
963+ "dev": true,
964+ "funding": [
965+ {
966+ "type": "github",
967+ "url": "https://github.com/sponsors/ai"
968+ }
969+ ],
970+ "license": "MIT",
971+ "bin": {
972+ "nanoid": "bin/nanoid.cjs"
973+ },
974+ "engines": {
975+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
976+ }
977+ },
978+ "node_modules/picocolors": {
979+ "version": "1.1.1",
980+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
981+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
982+ "dev": true,
983+ "license": "ISC"
984+ },
985+ "node_modules/picomatch": {
986+ "version": "4.0.7",
987+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
988+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
989+ "dev": true,
990+ "license": "MIT",
991+ "engines": {
992+ "node": ">=12"
993+ },
994+ "funding": {
995+ "url": "https://github.com/sponsors/jonschlinkert"
996+ }
997+ },
998+ "node_modules/postcss": {
999+ "version": "8.5.29",
1000+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
1001+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
1002+ "dev": true,
1003+ "funding": [
1004+ {
1005+ "type": "opencollective",
1006+ "url": "https://opencollective.com/postcss/"
1007+ },
1008+ {
1009+ "type": "tidelift",
1010+ "url": "https://tidelift.com/funding/github/npm/postcss"
1011+ },
1012+ {
1013+ "type": "github",
1014+ "url": "https://github.com/sponsors/ai"
1015+ }
1016+ ],
1017+ "license": "MIT",
1018+ "dependencies": {
1019+ "nanoid": "^3.3.19",
1020+ "picocolors": "^1.1.1",
1021+ "source-map-js": "^1.2.2"
1022+ },
1023+ "engines": {
1024+ "node": "^10 || ^12 || >=14"
1025+ }
1026+ },
1027+ "node_modules/rolldown": {
1028+ "version": "1.2.13",
1029+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
1030+ "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
1031+ "dev": true,
1032+ "license": "MIT",
1033+ "dependencies": {
1034+ "@oxc-project/types": "=0.153.0",
1035+ "@rolldown/pluginutils": "^1.0.0"
1036+ },
1037+ "bin": {
1038+ "rolldown": "bin/cli.mjs"
1039+ },
1040+ "engines": {
1041+ "node": "^20.19.0 || >=22.12.0"
1042+ },
1043+ "optionalDependencies": {
1044+ "@rolldown/binding-android-arm-eabi": "1.2.13",
1045+ "@rolldown/binding-android-arm64": "1.2.13",
1046+ "@rolldown/binding-darwin-arm64": "1.2.13",
1047+ "@rolldown/binding-darwin-x64": "1.2.13",
1048+ "@rolldown/binding-freebsd-x64": "1.2.13",
1049+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
1050+ "@rolldown/binding-linux-arm64-gnu": "1.2.13",
1051+ "@rolldown/binding-linux-arm64-musl": "1.2.13",
1052+ "@rolldown/binding-linux-ppc64-gnu": "1.2.13",
1053+ "@rolldown/binding-linux-s390x-gnu": "1.2.13",
1054+ "@rolldown/binding-linux-x64-gnu": "1.2.13",
1055+ "@rolldown/binding-linux-x64-musl": "1.2.13",
1056+ "@rolldown/binding-openharmony-arm64": "1.2.13",
1057+ "@rolldown/binding-win32-arm64-msvc": "1.2.13",
1058+ "@rolldown/binding-win32-x64-msvc": "1.2.13"
1059+ }
1060+ },
1061+ "node_modules/source-map-js": {
1062+ "version": "1.2.2",
1063+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
1064+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
1065+ "dev": true,
1066+ "license": "BSD-3-Clause",
1067+ "engines": {
1068+ "node": ">=0.10.0"
1069+ }
1070+ },
1071+ "node_modules/tinyglobby": {
1072+ "version": "0.2.17",
1073+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1074+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1075+ "dev": true,
1076+ "license": "MIT",
1077+ "dependencies": {
1078+ "fdir": "^6.5.0",
1079+ "picomatch": "^4.0.4"
1080+ },
1081+ "engines": {
1082+ "node": ">=12.0.0"
1083+ },
1084+ "funding": {
1085+ "url": "https://github.com/sponsors/SuperchupuDev"
1086+ }
1087+ },
1088+ "node_modules/typescript": {
1089+ "version": "7.0.2",
1090+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
1091+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
1092+ "dev": true,
1093+ "license": "Apache-2.0",
1094+ "bin": {
1095+ "tsc": "bin/tsc"
1096+ },
1097+ "engines": {
1098+ "node": ">=16.20.0"
1099+ },
1100+ "optionalDependencies": {
1101+ "@typescript/typescript-aix-ppc64": "7.0.2",
1102+ "@typescript/typescript-darwin-arm64": "7.0.2",
1103+ "@typescript/typescript-darwin-x64": "7.0.2",
1104+ "@typescript/typescript-freebsd-arm64": "7.0.2",
1105+ "@typescript/typescript-freebsd-x64": "7.0.2",
1106+ "@typescript/typescript-linux-arm": "7.0.2",
1107+ "@typescript/typescript-linux-arm64": "7.0.2",
1108+ "@typescript/typescript-linux-loong64": "7.0.2",
1109+ "@typescript/typescript-linux-mips64el": "7.0.2",
1110+ "@typescript/typescript-linux-ppc64": "7.0.2",
1111+ "@typescript/typescript-linux-riscv64": "7.0.2",
1112+ "@typescript/typescript-linux-s390x": "7.0.2",
1113+ "@typescript/typescript-linux-x64": "7.0.2",
1114+ "@typescript/typescript-netbsd-arm64": "7.0.2",
1115+ "@typescript/typescript-netbsd-x64": "7.0.2",
1116+ "@typescript/typescript-openbsd-arm64": "7.0.2",
1117+ "@typescript/typescript-openbsd-x64": "7.0.2",
1118+ "@typescript/typescript-sunos-x64": "7.0.2",
1119+ "@typescript/typescript-win32-arm64": "7.0.2",
1120+ "@typescript/typescript-win32-x64": "7.0.2"
1121+ }
1122+ },
1123+ "node_modules/vite": {
1124+ "version": "8.3.4",
1125+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
1126+ "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
1127+ "dev": true,
1128+ "license": "MIT",
1129+ "dependencies": {
1130+ "lightningcss": "^1.33.0",
1131+ "picomatch": "^4.0.7",
1132+ "postcss": "^8.5.29",
1133+ "rolldown": "~1.2.12",
1134+ "tinyglobby": "^0.2.17"
1135+ },
1136+ "bin": {
1137+ "vite": "bin/vite.js"
1138+ },
1139+ "engines": {
1140+ "node": "^20.19.0 || >=22.12.0"
1141+ },
1142+ "funding": {
1143+ "url": "https://github.com/vitejs/vite?sponsor=1"
1144+ },
1145+ "optionalDependencies": {
1146+ "fsevents": "~2.3.3"
1147+ },
1148+ "peerDependencies": {
1149+ "@types/node": "^20.19.0 || >=22.12.0",
1150+ "@vitejs/devtools": "^0.7.1",
1151+ "esbuild": "^0.27.0 || ^0.28.0",
1152+ "jiti": ">=1.21.0",
1153+ "less": "^4.0.0",
1154+ "sass": "^1.70.0",
1155+ "sass-embedded": "^1.70.0",
1156+ "stylus": ">=0.54.8",
1157+ "sugarss": "^5.0.0",
1158+ "terser": "^5.16.0",
1159+ "tsx": "^4.8.1",
1160+ "yaml": "^2.4.2"
1161+ },
1162+ "peerDependenciesMeta": {
1163+ "@types/node": {
1164+ "optional": true
1165+ },
1166+ "@vitejs/devtools": {
1167+ "optional": true
1168+ },
1169+ "esbuild": {
1170+ "optional": true
1171+ },
1172+ "jiti": {
1173+ "optional": true
1174+ },
1175+ "less": {
1176+ "optional": true
1177+ },
1178+ "sass": {
1179+ "optional": true
1180+ },
1181+ "sass-embedded": {
1182+ "optional": true
1183+ },
1184+ "stylus": {
1185+ "optional": true
1186+ },
1187+ "sugarss": {
1188+ "optional": true
1189+ },
1190+ "terser": {
1191+ "optional": true
1192+ },
1193+ "tsx": {
1194+ "optional": true
1195+ },
1196+ "yaml": {
1197+ "optional": true
1198+ }
1199+ }
1200+ }
1201+ }
1202+}
src/fput-chain/package.jsonadded+14−0View file
@@ -0,0 +1,14 @@
1+{
2+ "name": "fput-chain-chaos",
3+ "private": true,
4+ "type": "module",
5+ "scripts": {
6+ "dev": "vite",
7+ "build": "tsc && vite build",
8+ "preview": "vite preview"
9+ },
10+ "devDependencies": {
11+ "typescript": "^7.0.2",
12+ "vite": "^8.3.4"
13+ }
14+}
src/fput-chain/render-worker.tsadded+74−0View file
@@ -0,0 +1,74 @@
1+// Renders a note: the chain from a given state, sped up, sampled as audio.
2+// Each audio sample advances the physics by speedup / sampleRate seconds, in
3+// equal substeps no longer than stepFor the current energy (re-chosen every
4+// DIAG_EVERY samples, so the steps lengthen as a damped note decays). A
5+// Lyapunov shadow runs along (as in the animation: 10 s window of physical
6+// time); the energy and the windowed exponent are sent with the samples, one
7+// value each per DIAG_EVERY samples. A damped note ends
8+// once its energy has fallen to END_RATIO of the start (-60 dB); an undamped
9+// one after UNDAMPED_LENGTH seconds.
10+
11+import { Verlet, copyState, energy, stepFor, type Params, type State } from "./chain";
12+import { Lyapunov } from "./lyapunov";
13+import { signalByKey } from "./signals";
14+
15+export const END_RATIO = 1e-6;
16+export const UNDAMPED_LENGTH = 10; // s of sound
17+export const DIAG_EVERY = 256; // samples per diagnostic value (energy, exponent)
18+export const LYAP_WINDOW = 10; // s of physical time
19+
20+export interface RenderRequest {
21+ id: number;
22+ P: Params;
23+ state: State;
24+ speedup: number;
25+ sampleRate: number;
26+ maxLength: number; // s of sound, at most
27+ signal: string; // signal key
28+ j: number; // mass index (0-based) for per-mass signals
29+}
30+
31+export interface RenderResult {
32+ id: number;
33+ samples: Float32Array; // raw signal, in the signal's units
34+ energy: Float32Array; // chain energy (J) at samples 0, DIAG_EVERY, 2 DIAG_EVERY, ...
35+ lambda: Float32Array; // largest Lyapunov exponent over the last LYAP_WINDOW s (1/s), same samples
36+}
37+
38+self.onmessage = (e: MessageEvent<RenderRequest>) => {
39+ const r = e.data;
40+ const s = copyState(r.state);
41+ const verlet = new Verlet(r.P, s);
42+ const signal = signalByKey(r.signal);
43+ const dtSample = r.speedup / r.sampleRate;
44+ let sub = 1, h = dtSample;
45+ const E0 = energy(r.P, s);
46+ const nMax = Math.round((r.P.tau > 0 ? r.maxLength : UNDAMPED_LENGTH) * r.sampleRate);
47+ let out = new Float32Array(nMax);
48+ const en = new Float32Array(Math.ceil(nMax / DIAG_EVERY));
49+ const lam = new Float32Array(en.length);
50+ const lyap = new Lyapunov(LYAP_WINDOW);
51+ let t = 0; // physical time (s)
52+ lyap.start(r.P, s, t);
53+ let n = 0;
54+ for (; n < nMax; n++) {
55+ if (n % DIAG_EVERY === 0) {
56+ const E = energy(r.P, s);
57+ en[n / DIAG_EVERY] = E;
58+ lam[n / DIAG_EVERY] = lyap.windowed();
59+ if (r.P.tau > 0 && E <= END_RATIO * E0) break;
60+ sub = Math.ceil(dtSample / stepFor(r.P, E));
61+ h = dtSample / sub;
62+ }
63+ out[n] = signal.value(r.P, s, verlet.force, r.j);
64+ for (let k = 0; k < sub; k++) {
65+ verlet.step(s, h);
66+ t += h;
67+ lyap.step(s, h, t);
68+ }
69+ }
70+ out = out.slice(0, n);
71+ const nDiag = Math.ceil(n / DIAG_EVERY);
72+ const result: RenderResult = { id: r.id, samples: out, energy: en.slice(0, nDiag), lambda: lam.slice(0, nDiag) };
73+ (self as unknown as Worker).postMessage(result, [result.samples.buffer, result.energy.buffer, result.lambda.buffer]);
74+};
src/fput-chain/scatter.tsadded+206−0View file
@@ -0,0 +1,206 @@
1+// Scatter plot of the sound's Lyapunov exponent against its energy, one point
2+// per diagnostic sample, accumulated over notes. Points are colored by time
3+// into their note on a log scale (red just after the strike, then amber, blue
4+// late): the energy decays exponentially, so on a linear time scale nearly all
5+// of the high-energy part would share one color. For the same reason the
6+// energy axis is logarithmic. Earlier notes are faded. Hovering shows the
7+// nearest point's values in the legend row.
8+// Earlier notes are drawn once into a cached image (rebuilt when the axes
9+// change); each frame adds only the current note.
10+
11+const HEIGHT = 220; // CSS pixels
12+const TOP = 18; // legend row
13+const PAD = 6;
14+const MAX_POINTS = 200000;
15+const RAMP = [[194, 65, 45], [227, 162, 26], [47, 93, 138]]; // red -> amber -> blue
16+const LOG_T1 = 0.01; // log time scale: u = ln(1 + t/(LOG_T1 L)) / ln(1 + 1/LOG_T1), L = note length
17+const DOT = 3; // CSS pixels
18+const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
19+const BINS = 16; // color steps along the note
20+
21+export class ScatterPlot {
22+ canvas: HTMLCanvasElement;
23+ private width: number;
24+ private dpr = window.devicePixelRatio || 1;
25+ private E = new Float32Array(MAX_POINTS);
26+ private L = new Float32Array(MAX_POINTS);
27+ private T = new Float32Array(MAX_POINTS); // s into the note
28+ private note = new Int32Array(MAX_POINTS);
29+ private n = 0;
30+ private noteIndex = 0;
31+ private noteStart = 0; // index of the current note's first point
32+ private cache: HTMLCanvasElement;
33+ private cacheKey = "";
34+ private noteLength: number[] = []; // per note, for coloring (s)
35+ private changed = true;
36+ private hover: { x: number; y: number } | null = null;
37+ // Current axes (data -> device pixels), set at draw.
38+ private lgMin = -1; // log10 of the energy axis range
39+ private lgMax = 0;
40+ private lMin = -0.5;
41+ private lMax = 2;
42+
43+ constructor(width = 960) {
44+ this.width = width;
45+ this.canvas = document.createElement("canvas");
46+ this.canvas.width = width * this.dpr;
47+ this.canvas.height = HEIGHT * this.dpr;
48+ this.canvas.style.cursor = "crosshair";
49+ this.cache = document.createElement("canvas");
50+ this.cache.width = this.canvas.width;
51+ this.cache.height = this.canvas.height;
52+ this.canvas.addEventListener("mousemove", (e) => {
53+ const r = this.canvas.getBoundingClientRect();
54+ this.hover = { x: ((e.clientX - r.left) / r.width) * this.width, y: ((e.clientY - r.top) / r.height) * HEIGHT };
55+ this.changed = true;
56+ });
57+ this.canvas.addEventListener("mouseleave", () => {
58+ this.hover = null;
59+ this.changed = true;
60+ });
61+ }
62+
63+ clear(): void {
64+ this.n = 0;
65+ this.noteStart = 0;
66+ this.noteLength = [];
67+ this.noteIndex = 0;
68+ this.changed = true;
69+ }
70+
71+ // Start a new note expected to last about `length` seconds (for coloring).
72+ newNote(length: number): void {
73+ this.noteIndex = this.noteLength.length;
74+ this.noteLength.push(length);
75+ this.noteStart = this.n;
76+ this.changed = true;
77+ }
78+
79+ // Final length of the current note.
80+ setNoteLength(length: number): void {
81+ if (this.noteLength.length) this.noteLength[this.noteIndex] = length;
82+ this.changed = true;
83+ }
84+
85+ add(E: number, lambda: number, t: number): void {
86+ if (!(E > 0) || !Number.isFinite(lambda) || this.n >= MAX_POINTS || !this.noteLength.length) return;
87+ this.E[this.n] = E;
88+ this.L[this.n] = lambda;
89+ this.T[this.n] = t;
90+ this.note[this.n] = this.noteIndex;
91+ this.n++;
92+ this.changed = true;
93+ }
94+
95+ private px(E: number): number {
96+ return PAD + ((Math.log10(E) - this.lgMin) / (this.lgMax - this.lgMin)) * (this.width - 2 * PAD);
97+ }
98+
99+ private py(l: number): number {
100+ return HEIGHT - PAD - ((l - this.lMin) / (this.lMax - this.lMin)) * (HEIGHT - TOP - 2 * PAD);
101+ }
102+
103+ // Points from..to, colored by time into their note, binned into BINS colors.
104+ private drawPoints(ctx: CanvasRenderingContext2D, from: number, to: number, alpha: number): void {
105+ const { E, L, T, note } = this;
106+ const norm = Math.log(1 + 1 / LOG_T1);
107+ const bin = (i: number) => {
108+ const f = Math.min(1, T[i] / (this.noteLength[note[i]] || 1));
109+ return Math.min(BINS - 1, Math.floor((Math.log(1 + f / LOG_T1) / norm) * BINS));
110+ };
111+ for (let b = 0; b < BINS; b++) {
112+ const c = rampColor((b + 0.5) / BINS);
113+ ctx.fillStyle = `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha})`;
114+ for (let i = from; i < to; i++) {
115+ if (bin(i) === b) ctx.fillRect(this.px(E[i]) - DOT / 2, this.py(L[i]) - DOT / 2, DOT, DOT);
116+ }
117+ }
118+ }
119+
120+ draw(): void {
121+ if (!this.changed) return;
122+ this.changed = false;
123+ const { n, E, L, T, dpr } = this;
124+ // Axes: energy (log) over the points, in whole decades; lambda including
125+ // 0, padded.
126+ let eMin = Infinity, eMax = 0, lMin = 0, lMax = 0;
127+ for (let i = 0; i < n; i++) {
128+ if (E[i] < eMin) eMin = E[i];
129+ if (E[i] > eMax) eMax = E[i];
130+ if (L[i] < lMin) lMin = L[i];
131+ if (L[i] > lMax) lMax = L[i];
132+ }
133+ this.lgMin = n ? Math.floor(Math.log10(eMin)) : -1;
134+ this.lgMax = n ? Math.ceil(Math.log10(eMax)) : 0;
135+ if (this.lgMax <= this.lgMin) this.lgMax = this.lgMin + 1;
136+ const span = lMax - lMin || 1;
137+ this.lMin = lMin - 0.05 * span;
138+ this.lMax = lMax + 0.05 * span;
139+
140+ const ctx = this.canvas.getContext("2d")!;
141+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
142+ ctx.clearRect(0, 0, this.width, HEIGHT);
143+ // lambda = 0 line.
144+ ctx.strokeStyle = "#ddd";
145+ ctx.lineWidth = 1;
146+ ctx.beginPath();
147+ ctx.moveTo(0, this.py(0));
148+ ctx.lineTo(this.width, this.py(0));
149+ ctx.stroke();
150+
151+ // Earlier notes (faded) from the cache, then the current note.
152+ const key = `${this.lgMin}|${this.lgMax}|${this.lMin}|${this.lMax}|${this.noteStart}|${this.noteLength.length}`;
153+ if (key !== this.cacheKey) {
154+ this.cacheKey = key;
155+ const cc = this.cache.getContext("2d")!;
156+ cc.setTransform(dpr, 0, 0, dpr, 0, 0);
157+ cc.clearRect(0, 0, this.width, HEIGHT);
158+ this.drawPoints(cc, 0, this.noteStart, 0.35);
159+ }
160+ ctx.setTransform(1, 0, 0, 1, 0, 0);
161+ ctx.drawImage(this.cache, 0, 0);
162+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
163+ this.drawPoints(ctx, this.noteStart, n, 0.85);
164+
165+ // Nearest point to the mouse.
166+ let near = -1;
167+ if (this.hover) {
168+ let best = 15 * 15; // within 15 px
169+ for (let i = 0; i < n; i++) {
170+ const dx = this.px(E[i]) - this.hover.x, dy = this.py(L[i]) - this.hover.y;
171+ if (dx * dx + dy * dy < best) {
172+ best = dx * dx + dy * dy;
173+ near = i;
174+ }
175+ }
176+ }
177+ if (near >= 0) {
178+ ctx.strokeStyle = "#23262b";
179+ ctx.lineWidth = 1.25;
180+ ctx.beginPath();
181+ ctx.arc(this.px(E[near]), this.py(L[near]), 4, 0, 2 * Math.PI);
182+ ctx.stroke();
183+ }
184+
185+ ctx.font = FONT;
186+ ctx.textBaseline = "top";
187+ ctx.fillStyle = "#2f5d8a";
188+ const legend =
189+ near >= 0
190+ ? `E ${E[near].toPrecision(3)} J λ ${L[near].toFixed(3)} 1/s ${T[near].toFixed(2)} s into note`
191+ : "λ (1/s) vs E (J)";
192+ ctx.fillText(legend, 4, 3);
193+ ctx.fillStyle = "#999";
194+ ctx.textAlign = "right";
195+ ctx.fillText(`E 1e${this.lgMin} … 1e${this.lgMax} (log) · λ ${this.lMin.toFixed(2)} … ${this.lMax.toFixed(2)}`, this.width - 4, 3);
196+ ctx.textAlign = "left";
197+ ctx.fillText("λ = 0", 4, this.py(0) + 2);
198+ }
199+}
200+
201+// Color at u in [0, 1] along RAMP.
202+function rampColor(u: number): number[] {
203+ const s = u * (RAMP.length - 1);
204+ const i = Math.min(RAMP.length - 2, Math.floor(s)), w = s - i;
205+ return RAMP[i].map((a, k) => Math.round(a + (RAMP[i + 1][k] - a) * w));
206+}
src/fput-chain/signals.tsadded+38−0View file
@@ -0,0 +1,38 @@
1+// Quantities that can be heard. In a single mass's motion, mode n appears
2+// with weight sqrt(E_n) / omega_n (displacement), sqrt(E_n) (velocity) or
3+// sqrt(E_n) omega_n (acceleration), times the mode shape at that mass. The
4+// end masses are never at a node, so they carry every mode.
5+
6+import type { Params, State } from "./chain";
7+
8+export interface Signal {
9+ key: string;
10+ label: string;
11+ perMass: boolean; // takes a mass index j
12+ // Value now; F is the force on each mass at the current displacements.
13+ value: (P: Params, s: State, F: Float64Array, j: number) => number;
14+}
15+
16+export const SIGNALS: Signal[] = [
17+ { key: "v", label: "velocity of mass", perMass: true, value: (P, s, _F, j) => s.p[j] / P.m },
18+ { key: "u", label: "displacement of mass", perMass: true, value: (_P, s, _F, j) => s.u[j] },
19+ { key: "a", label: "acceleration of mass", perMass: true, value: (P, _s, F, j) => F[j] / P.m },
20+ {
21+ // Tension of the first spring: what the chain pulls on the left wall
22+ // with, like a string on its bridge.
23+ key: "wall", label: "force on left wall", perMass: false,
24+ value: (P, s) => (P.k + P.k3 * s.u[0] * s.u[0]) * s.u[0],
25+ },
26+ {
27+ // Equals the net force of the walls on the chain. Antisymmetric (even)
28+ // modes cancel in the sum, so it does not carry them at all.
29+ key: "suma", label: "sum of accelerations", perMass: false,
30+ value: (P, _s, F) => {
31+ let x = 0;
32+ for (const f of F) x += f;
33+ return x / P.m;
34+ },
35+ },
36+];
37+
38+export const signalByKey = (key: string) => SIGNALS.find((s) => s.key === key) ?? SIGNALS[0];
src/fput-chain/sound.tsadded+100−0View file
@@ -0,0 +1,100 @@
1+// Plays rendered notes. Each note is made zero-mean, scaled so its peak is
2+// at full scale (so every signal and energy plays equally loud; the volume
3+// slider sets the level) and faded out over its last 20 ms.
4+
5+const FADE = 0.02; // s
6+
7+export class Player {
8+ private ctx: AudioContext | null = null;
9+ private gain: GainNode | null = null;
10+ private source: AudioBufferSourceNode | null = null;
11+ private startTime = 0;
12+ private duration = 0;
13+ private volume = 0.5;
14+ muted = true;
15+
16+ // Create the audio context; call from a user gesture.
17+ ensure(): AudioContext {
18+ if (!this.ctx) {
19+ this.ctx = new AudioContext();
20+ this.gain = this.ctx.createGain();
21+ this.gain.gain.value = this.level();
22+ this.gain.connect(this.ctx.destination);
23+ }
24+ return this.ctx;
25+ }
26+
27+ get sampleRate(): number {
28+ return this.ctx?.sampleRate ?? 48000;
29+ }
30+
31+ private level = () => (this.muted ? 0 : this.volume);
32+
33+ setVolume(v: number): void {
34+ this.volume = v;
35+ if (this.gain) this.gain.gain.value = this.level();
36+ }
37+
38+ // Muted notes still play (silently), so the displays keep in step.
39+ setMuted(m: boolean): void {
40+ this.muted = m;
41+ if (this.gain) this.gain.gain.value = this.level();
42+ }
43+
44+ // Pausing suspends the audio clock: the note, and the playhead, stop.
45+ setPaused(p: boolean): void {
46+ if (!this.ctx) return;
47+ if (p) void this.ctx.suspend();
48+ else void this.ctx.resume();
49+ }
50+
51+ // Normalize in place (see above).
52+ static normalize(x: Float32Array, sampleRate: number): void {
53+ let mean = 0;
54+ for (const v of x) mean += v;
55+ mean /= x.length || 1;
56+ let peak = 0;
57+ for (let i = 0; i < x.length; i++) {
58+ x[i] -= mean;
59+ peak = Math.max(peak, Math.abs(x[i]));
60+ }
61+ const g = peak > 0 ? 0.9 / peak : 0;
62+ const nFade = Math.min(x.length, Math.round(FADE * sampleRate));
63+ for (let i = 0; i < x.length; i++) {
64+ const fromEnd = x.length - 1 - i;
65+ x[i] *= fromEnd < nFade ? (g * fromEnd) / nFade : g;
66+ }
67+ }
68+
69+ play(x: Float32Array): void {
70+ const ctx = this.ensure();
71+ this.stop();
72+ const buf = ctx.createBuffer(1, x.length, ctx.sampleRate);
73+ buf.copyToChannel(x as Float32Array<ArrayBuffer>, 0);
74+ const src = ctx.createBufferSource();
75+ src.buffer = buf;
76+ src.connect(this.gain!);
77+ src.start();
78+ this.source = src;
79+ this.startTime = ctx.currentTime;
80+ this.duration = buf.duration;
81+ src.onended = () => {
82+ if (this.source === src) this.source = null;
83+ };
84+ }
85+
86+ stop(): void {
87+ if (this.source) {
88+ this.source.onended = null;
89+ this.source.stop();
90+ this.source = null;
91+ }
92+ }
93+
94+ // Seconds into the playing note, or null when nothing plays.
95+ position(): number | null {
96+ if (!this.source || !this.ctx) return null;
97+ const t = this.ctx.currentTime - this.startTime;
98+ return t <= this.duration ? t : null;
99+ }
100+}
src/fput-chain/spectrogram.tsadded+307−0View file
@@ -0,0 +1,307 @@
1+// Spectrogram of one whole note. Samples arrive at a fixed spacing dt (s);
2+// every `hop` samples a Hann-windowed FFT of the last `n` samples (zero
3+// padded to `nfft`) becomes one column. The frequency axis tops out just
4+// above the highest frequency that comes within CONTENT_DB of the peak at any
5+// time (a time average would hide the broadband start of a chaotic note under
6+// its long, quiet, tonal tail).
7+
8+import { attachCursor, type Cursor } from "./cursor";
9+
10+const HEIGHT = 200;
11+const RANGE_DB = 60; // color range below the peak
12+const CONTENT_DB = 40; // "significant" power for the frequency auto scale
13+const MIN_REF_GAP = 6; // px between reference lines, or none are drawn
14+
15+export interface SpectrogramOptions {
16+ dt: number; // sample spacing (s)
17+ n: number; // samples per FFT window
18+ nfft: number; // zero-padded FFT length (power of two)
19+ hop: number; // samples between columns
20+ refFreqs: number[]; // frequencies to mark with dashed lines (Hz)
21+ width?: number; // columns kept and drawn (canvas width), default 480
22+}
23+
24+// White text on a dark box, readable over any part of the image.
25+function label(ctx: CanvasRenderingContext2D, text: string, x: number, y: number): void {
26+ ctx.fillStyle = "rgba(0,0,0,0.6)";
27+ ctx.fillRect(x, y, ctx.measureText(text).width + 6, 16);
28+ ctx.fillStyle = "#fff";
29+ ctx.textBaseline = "top";
30+ ctx.fillText(text, x + 3, y + 2);
31+}
32+
33+// Smallest 1, 2 or 5 times a power of ten that is >= x.
34+function niceCeil(x: number): number {
35+ const p = Math.pow(10, Math.floor(Math.log10(x)));
36+ for (const m of [1, 2, 5, 10]) if (m * p >= x) return +(m * p).toPrecision(1);
37+ return 10 * p;
38+}
39+
40+// Inferno-like colormap, u in [0, 1] (clamped).
41+const STOPS = [
42+ [0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99],
43+ [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164],
44+];
45+function colormap(u: number, out: Uint8ClampedArray, p: number): void {
46+ const s = Math.max(0, Math.min(1, u)) * (STOPS.length - 1);
47+ const i = Math.min(STOPS.length - 2, Math.floor(s)), w = s - i;
48+ for (let ch = 0; ch < 3; ch++) out[p + ch] = STOPS[i][ch] * (1 - w) + STOPS[i + 1][ch] * w;
49+}
50+
51+// In-place radix-2 complex FFT of a fixed power-of-two length, with
52+// precomputed twiddle factors.
53+class FFT {
54+ private n: number;
55+ private cos: Float64Array;
56+ private sin: Float64Array;
57+
58+ constructor(n: number) {
59+ this.n = n;
60+ this.cos = new Float64Array(n / 2);
61+ this.sin = new Float64Array(n / 2);
62+ for (let k = 0; k < n / 2; k++) {
63+ this.cos[k] = Math.cos((-2 * Math.PI * k) / n);
64+ this.sin[k] = Math.sin((-2 * Math.PI * k) / n);
65+ }
66+ }
67+
68+ transform(re: Float64Array, im: Float64Array): void {
69+ const n = this.n;
70+ for (let i = 1, j = 0; i < n; i++) {
71+ let bit = n >> 1;
72+ for (; j & bit; bit >>= 1) j ^= bit;
73+ j ^= bit;
74+ if (i < j) {
75+ let tmp = re[i]; re[i] = re[j]; re[j] = tmp;
76+ tmp = im[i]; im[i] = im[j]; im[j] = tmp;
77+ }
78+ }
79+ for (let len = 2; len <= n; len <<= 1) {
80+ const half = len >> 1, step = n / len;
81+ for (let i = 0; i < n; i += len) {
82+ for (let k = 0; k < half; k++) {
83+ const wr = this.cos[k * step], wi = this.sin[k * step];
84+ const a = i + k, b = a + half;
85+ const xr = re[b] * wr - im[b] * wi, xi = re[b] * wi + im[b] * wr;
86+ re[b] = re[a] - xr;
87+ im[b] = im[a] - xi;
88+ re[a] += xr;
89+ im[a] += xi;
90+ }
91+ }
92+ }
93+ }
94+}
95+
96+// Spectrogram of one whole note, from its start (left edge) to its end (right
97+// edge), without scrolling. The time axis spans `span` seconds, set at the
98+// start (predicted length) and changed with setSpan (extended if the note
99+// runs longer, fitted when it ends). All columns are kept; past MAX_COLS,
100+// adjacent columns are averaged in pairs (halving the time resolution) so
101+// memory stays bounded. Colors are relative to the loudest column so far, so
102+// the note's decay shows. Only new pixel columns are drawn, except when the
103+// axis, the frequency range, or the color scale changes. An optional shared
104+// time cursor (see cursor.ts) is drawn as a vertical line.
105+const MAX_COLS = 4096;
106+
107+export class NoteSpectrogram {
108+ canvas: HTMLCanvasElement;
109+ private cursor: Cursor | null;
110+ private lastCursor = "";
111+ private width: number;
112+ private dt: number;
113+ private n: number;
114+ private hop: number;
115+ private bins: number;
116+ private refFreqs: number[];
117+ private buf: Float64Array;
118+ private nSamples = 0;
119+ private hann: Float64Array;
120+ private fft: FFT;
121+ private re: Float64Array;
122+ private im: Float64Array;
123+ private cols: Float32Array[] = []; // stored columns (power)
124+ private merge = 1; // raw columns per stored column
125+ private pending: Float32Array; // raw columns being averaged into the next stored one
126+ private pendingCount = 0;
127+ private hold: Float64Array; // largest power in each bin over all raw columns
128+ private peak = 0;
129+ private fmax = 1;
130+ private span = 10; // s shown across the width
131+ private drawnX = 0; // pixel columns already drawn
132+ private dirty = true; // full redraw needed
133+ private image: ImageData;
134+
135+ constructor(opts: SpectrogramOptions, cursor: Cursor | null = null) {
136+ this.cursor = cursor;
137+ this.width = opts.width ?? 960;
138+ this.dt = opts.dt;
139+ this.n = opts.n;
140+ this.hop = opts.hop;
141+ this.bins = opts.nfft / 2 + 1;
142+ this.refFreqs = opts.refFreqs;
143+ this.buf = new Float64Array(this.n);
144+ this.hann = new Float64Array(this.n);
145+ for (let i = 0; i < this.n; i++) this.hann[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (this.n - 1));
146+ this.fft = new FFT(opts.nfft);
147+ this.re = new Float64Array(opts.nfft);
148+ this.im = new Float64Array(opts.nfft);
149+ this.pending = new Float32Array(this.bins);
150+ this.hold = new Float64Array(this.bins);
151+ this.canvas = document.createElement("canvas");
152+ this.canvas.width = this.width;
153+ this.canvas.height = HEIGHT;
154+ this.image = this.canvas.getContext("2d")!.createImageData(this.width, HEIGHT);
155+ if (cursor) attachCursor(this.canvas, cursor, (f) => f * this.span);
156+ }
157+
158+ // Start a new note shown over `span` seconds.
159+ start(span: number): void {
160+ this.nSamples = 0;
161+ this.cols = [];
162+ this.merge = 1;
163+ this.pending.fill(0);
164+ this.pendingCount = 0;
165+ this.hold.fill(0);
166+ this.peak = 0;
167+ this.span = span;
168+ this.dirty = true;
169+ }
170+
171+ setSpan(span: number): void {
172+ this.span = span;
173+ this.dirty = true;
174+ }
175+
176+ setRefFreqs(f: number[]): void {
177+ this.refFreqs = f;
178+ this.dirty = true;
179+ }
180+
181+ push(x: number): void {
182+ this.buf[this.nSamples % this.n] = x;
183+ this.nSamples++;
184+ if (this.nSamples >= this.n && this.nSamples % this.hop === 0) this.addColumn();
185+ }
186+
187+ private addColumn(): void {
188+ const { buf, re, im, hann, n, bins } = this;
189+ let mean = 0;
190+ for (let i = 0; i < n; i++) mean += buf[i];
191+ mean /= n;
192+ const start = this.nSamples % n;
193+ re.fill(0);
194+ im.fill(0);
195+ for (let i = 0; i < n; i++) re[i] = (buf[(start + i) % n] - mean) * hann[i];
196+ this.fft.transform(re, im);
197+ for (let k = 0; k < bins; k++) {
198+ const p = re[k] * re[k] + im[k] * im[k];
199+ this.pending[k] += p / this.merge;
200+ if (p > this.hold[k]) this.hold[k] = p;
201+ }
202+ if (++this.pendingCount < this.merge) return;
203+ const col = this.pending;
204+ this.pending = new Float32Array(bins);
205+ this.pendingCount = 0;
206+ this.cols.push(col);
207+ let colPeak = 0;
208+ for (let k = 1; k < bins; k++) if (col[k] > colPeak) colPeak = col[k];
209+ if (colPeak > 2 * this.peak) this.dirty = true; // 3 dB louder: rescale colors
210+ this.peak = Math.max(this.peak, colPeak);
211+ if (this.cols.length === MAX_COLS) {
212+ const merged: Float32Array[] = [];
213+ for (let c = 0; c < MAX_COLS; c += 2) {
214+ const a = this.cols[c], b = this.cols[c + 1], m = new Float32Array(bins);
215+ for (let k = 0; k < bins; k++) m[k] = 0.5 * (a[k] + b[k]);
216+ merged.push(m);
217+ }
218+ this.cols = merged;
219+ this.merge *= 2;
220+ this.dirty = true;
221+ }
222+ }
223+
224+ draw(): void {
225+ const { bins, cols } = this;
226+ const df = 1 / (2 * (bins - 1) * this.dt);
227+ let holdPeak = 0;
228+ for (let k = 1; k < bins; k++) if (this.hold[k] > holdPeak) holdPeak = this.hold[k];
229+ let top = bins - 1;
230+ while (top > 1 && this.hold[top] < holdPeak * 10 ** (-CONTENT_DB / 10)) top--;
231+ if (cols.length > 0) {
232+ const fmax = Math.min(niceCeil(1.2 * top * df), (bins - 1) * df);
233+ if (fmax !== this.fmax) this.dirty = true;
234+ this.fmax = fmax;
235+ }
236+ // Pixel columns whose nearest stored column exists (column c is centered
237+ // at t0 + c * colDt; the first one also covers the note's first t0).
238+ const colDt = this.merge * this.hop * this.dt; // s per stored column
239+ const t0 = this.n * this.dt; // the first column ends here
240+ const tReady = cols.length > 0 ? t0 + (cols.length - 0.5) * colDt : 0;
241+ const ready = Math.min(this.width, Math.max(0, Math.floor((tReady / this.span) * this.width)));
242+ if (this.dirty) this.drawnX = 0;
243+ const cursorKey = this.cursor ? `${this.cursor.t}|${this.cursor.pinned}` : "";
244+ if (!this.dirty && ready <= this.drawnX && cursorKey === this.lastCursor) return;
245+ this.lastCursor = cursorKey;
246+
247+ const data = this.image.data;
248+ const peakDb = 10 * Math.log10(this.peak + 1e-30);
249+ const from = this.dirty ? 0 : this.drawnX;
250+ const to = this.dirty ? this.width : ready;
251+ for (let x = from; x < to; x++) {
252+ const t = ((x + 0.5) / this.width) * this.span;
253+ const c = Math.min(cols.length - 1, Math.max(0, Math.round((t - t0) / colDt)));
254+ const col = x < ready ? cols[c] : null;
255+ for (let y = 0; y < HEIGHT; y++) {
256+ const p = 4 * (y * this.width + x);
257+ if (!col) {
258+ data[p] = data[p + 1] = data[p + 2] = 246; // no data (yet)
259+ } else {
260+ const f = (this.fmax * (HEIGHT - 1 - y)) / (HEIGHT - 1);
261+ const k = Math.min(bins - 1, Math.round(f / df));
262+ const db = 10 * Math.log10(col[k] + 1e-30);
263+ colormap((db - (peakDb - RANGE_DB)) / RANGE_DB, data, p);
264+ }
265+ data[p + 3] = 255;
266+ }
267+ }
268+ this.drawnX = ready;
269+ this.dirty = false;
270+
271+ const ctx = this.canvas.getContext("2d")!;
272+ ctx.putImageData(this.image, 0, 0);
273+ const py = (f: number) => (HEIGHT - 1) * (1 - f / this.fmax);
274+ ctx.strokeStyle = "rgba(255,255,255,0.7)";
275+ ctx.setLineDash([4, 4]);
276+ // Reference lines only while they are far enough apart to read as
277+ // separate lines (crowded together, their dashes merge into a solid
278+ // on-off bar that looks like signal).
279+ const shown = this.refFreqs.filter((f) => f < this.fmax).sort((a, b) => a - b);
280+ let minGap = Infinity;
281+ for (let i = 1; i < shown.length; i++) minGap = Math.min(minGap, py(shown[i - 1]) - py(shown[i]));
282+ for (const f of minGap >= MIN_REF_GAP ? shown : []) {
283+ ctx.beginPath();
284+ ctx.moveTo(0, py(f));
285+ ctx.lineTo(this.width, py(f));
286+ ctx.stroke();
287+ }
288+ ctx.setLineDash([]);
289+ ctx.font = "12px ui-monospace, monospace";
290+ if (cols.length > 0) label(ctx, `${this.fmax} Hz`, 2, 2);
291+ label(ctx, "0", 2, HEIGHT - 18);
292+ const end = `${this.span.toFixed(1)} s`;
293+ label(ctx, end, this.width - ctx.measureText(end).width - 8, HEIGHT - 18);
294+ const ct = this.cursor?.t ?? null;
295+ if (ct !== null && ct >= 0 && ct <= this.span) {
296+ const cx = (ct / this.span) * this.width;
297+ ctx.strokeStyle = this.cursor!.pinned ? "#fff" : "rgba(255,255,255,0.6)";
298+ ctx.lineWidth = 1;
299+ ctx.beginPath();
300+ ctx.moveTo(cx, 0);
301+ ctx.lineTo(cx, HEIGHT);
302+ ctx.stroke();
303+ const at = `at ${ct.toFixed(2)} s`;
304+ label(ctx, at, this.width - ctx.measureText(at).width - 8, 2);
305+ }
306+ }
307+}
src/fput-chain/traces.tsadded+325−0View file
@@ -0,0 +1,325 @@
1+// Live scrolling time traces. Quantities are arranged in groups; each group is
2+// one chart (shared y axis, one line per series) shown or hidden by a
3+// toggle chip. The y range fits the samples in the visible window (linear or
4+// log scale), optionally leaving out an initial transient (`skip`). The time
5+// span is shown at the bottom right. Each pixel column draws only the min and
6+// max of its samples, so cost does not grow with the sample rate and short
7+// spikes are kept.
8+// Non-finite values (NaN) leave a gap. Hovering shows a time cursor (shared by
9+// the panel's charts, and optionally with other charts) and the values there;
10+// a click pins it.
11+
12+import { attachCursor, newCursor, type Cursor } from "./cursor";
13+
14+export interface Series {
15+ label: string;
16+ value: () => number;
17+ period?: number; // for wrapped quantities: break the line at wraps
18+}
19+
20+export interface Group {
21+ label: string; // toggle text
22+ unit?: string; // shown after the legend
23+ series: Series[];
24+ shown?: boolean; // initially shown
25+ window?: number; // visible time span for this chart (default: the panel's)
26+ log?: boolean; // log10 y axis (non-positive values leave a gap); range in whole decades
27+ zero?: boolean; // non-negative quantity: the y range starts at 0
28+ skip?: () => number; // time after the first sample (since clear) left out of the y range
29+ color?: (k: number, n: number) => string; // color of series k of n (default: COLORS in turn)
30+}
31+
32+const CAPACITY = 16384; // samples kept per series (ring buffer)
33+const HEIGHT = 92; // CSS pixels; drawn at devicePixelRatio
34+const TOP = 18; // legend row height; the plot is below it
35+const COLORS = ["#2f5d8a", "#c8742c", "#4a8c5c", "#a8476e"];
36+const LEGEND_MAX = 8; // series named in the legend; the rest are summarized
37+const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
38+const MUTED = "#999";
39+
40+export class TracePanel {
41+ private groups: Group[];
42+ private window: number; // visible time span
43+ private t = new Float64Array(CAPACITY);
44+ private v: Float64Array[][]; // [group][series]
45+ private count = 0;
46+ private head = 0; // next write index
47+ private tFirst = 0; // time of the first sample since clear
48+ private canvases: HTMLCanvasElement[];
49+ private chips: HTMLElement[] = [];
50+ private boxes: HTMLInputElement[] = [];
51+ private dpr = window.devicePixelRatio || 1;
52+ private width: number; // chart width, CSS pixels
53+ private fixed: { t0: number; span: number } | null = null; // fixed x range instead of scrolling
54+ readonly cursor: Cursor;
55+ private ranges: { t0: number; win: number }[]; // time range of each chart at its last draw
56+ // Per draw: buffer index and pixel column of each visible sample, oldest first.
57+ private idx = new Int32Array(CAPACITY);
58+ private col = new Int32Array(CAPACITY);
59+
60+ // Toggle chips go into `toggles`, charts into `charts`.
61+ constructor(
62+ toggles: HTMLElement,
63+ charts: HTMLElement,
64+ groups: Group[],
65+ window: number,
66+ width = 480,
67+ cursor: Cursor = newCursor(),
68+ ) {
69+ this.width = width;
70+ this.cursor = cursor;
71+ this.ranges = groups.map(() => ({ t0: 0, win: window }));
72+ this.groups = groups;
73+ this.window = window;
74+ this.v = groups.map((g) => g.series.map(() => new Float64Array(CAPACITY)));
75+ this.canvases = groups.map((g, j) => {
76+ const canvas = document.createElement("canvas");
77+ canvas.width = this.width * this.dpr;
78+ canvas.height = HEIGHT * this.dpr;
79+ canvas.hidden = !g.shown;
80+ const box = document.createElement("input");
81+ box.type = "checkbox";
82+ box.checked = !!g.shown;
83+ box.addEventListener("change", () => (canvas.hidden = !box.checked));
84+ this.boxes.push(box);
85+ const text = document.createElement("span");
86+ text.textContent = g.label;
87+ const chip = document.createElement("label");
88+ chip.className = "chip";
89+ chip.append(box, text);
90+ toggles.appendChild(chip);
91+ this.chips.push(chip);
92+ attachCursor(canvas, cursor, (f) => this.ranges[j].t0 + f * this.ranges[j].win);
93+ return canvas;
94+ });
95+ for (const c of this.canvases) charts.appendChild(c);
96+ }
97+
98+ // Labels of the charts currently switched on.
99+ shownLabels(): string[] {
100+ return this.groups.filter((_, j) => this.boxes[j].checked).map((g) => g.label);
101+ }
102+
103+ anyShown(): boolean {
104+ return this.canvases.some((c) => !c.hidden);
105+ }
106+
107+ // Offer a chart or not: when unavailable its chip and chart are hidden.
108+ setAvailable(label: string, available: boolean): void {
109+ const j = this.groups.findIndex((g) => g.label === label);
110+ this.chips[j].hidden = !available;
111+ this.canvases[j].hidden = !available || !this.boxes[j].checked;
112+ }
113+
114+ setWindow(window: number): void {
115+ this.window = window;
116+ }
117+
118+ // Show times t0 .. t0 + span across the width (no scrolling); null to scroll.
119+ setFixed(range: { t0: number; span: number } | null): void {
120+ this.fixed = range;
121+ }
122+
123+ clear(): void {
124+ this.count = 0;
125+ this.head = 0;
126+ }
127+
128+ // Record the current value of every series at time t.
129+ sample(t: number): void {
130+ if (this.count === 0) this.tFirst = t;
131+ this.t[this.head] = t;
132+ this.groups.forEach((g, j) =>
133+ g.series.forEach((s, k) => (this.v[j][k][this.head] = s.value())),
134+ );
135+ this.head = (this.head + 1) % CAPACITY;
136+ this.count = Math.min(this.count + 1, CAPACITY);
137+ }
138+
139+ // Indices and pixel columns of the samples in the last `win` seconds.
140+ private tStart = 0;
141+ private layout(win: number): number {
142+ const newest = (this.head - 1 + CAPACITY) % CAPACITY;
143+ const tStart = (this.tStart = this.fixed ? this.fixed.t0 : this.t[newest] - win);
144+ let n = 0;
145+ while (n < this.count && this.t[(newest - n + CAPACITY) % CAPACITY] >= tStart) n++;
146+ const first = (newest - n + 1 + CAPACITY) % CAPACITY;
147+ for (let m = 0; m < n; m++) {
148+ const i = first + m < CAPACITY ? first + m : first + m - CAPACITY;
149+ this.idx[m] = i;
150+ this.col[m] = Math.floor(((this.t[i] - tStart) / win) * this.width);
151+ }
152+ return n;
153+ }
154+
155+ draw(): void {
156+ if (this.count === 0) return;
157+ const newest = (this.head - 1 + CAPACITY) % CAPACITY;
158+ const { idx, col } = this;
159+ let laidOut = NaN, n = 0;
160+
161+ this.groups.forEach((g, j) => {
162+ const canvas = this.canvases[j];
163+ if (canvas.hidden) return;
164+ const win = this.fixed ? this.fixed.span : (g.window ?? this.window);
165+ if (win !== laidOut) n = this.layout((laidOut = win));
166+ this.ranges[j] = { t0: this.tStart, win };
167+ // Sample nearest the cursor, if the cursor is on this chart's time range.
168+ const ct = this.cursor.t;
169+ let cm = -1;
170+ if (ct !== null && ct >= this.tStart && ct <= this.tStart + win && n > 0) {
171+ let a = 0, b = n - 1;
172+ while (b - a > 1) {
173+ const mid = (a + b) >> 1;
174+ if (this.t[idx[mid]] < ct) a = mid;
175+ else b = mid;
176+ }
177+ cm = Math.abs(this.t[idx[a]] - ct) <= Math.abs(this.t[idx[b]] - ct) ? a : b;
178+ }
179+ const tv = g.log ? (x: number) => (x > 0 ? Math.log10(x) : NaN) : (x: number) => x;
180+ const ctx = canvas.getContext("2d")!;
181+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
182+ // Range from the samples after the transient (all of them while
183+ // there are none yet).
184+ let m0 = 0;
185+ if (g.skip) {
186+ const tSkip = this.tFirst + g.skip();
187+ while (m0 < n && this.t[idx[m0]] < tSkip) m0++;
188+ if (m0 === n) m0 = 0;
189+ }
190+ let lo = Infinity, hi = -Infinity;
191+ for (const vals of this.v[j]) {
192+ for (let m = m0; m < n; m++) {
193+ const x = tv(vals[idx[m]]);
194+ if (x < lo) lo = x;
195+ if (x > hi) hi = x;
196+ }
197+ }
198+ const empty = !(hi >= lo);
199+ if (empty) { lo = 0; hi = 1; }
200+ if (g.zero) lo = 0;
201+ if (g.log) { lo = Math.floor(lo); hi = Math.ceil(hi); }
202+ if (hi - lo <= 1e-300) { lo -= 1; hi += 1; } // constant trace
203+ const pad = g.log ? 0 : 0.1 * (hi - lo);
204+ const yMin = g.zero || g.log ? lo : lo - pad, yMax = hi + pad;
205+ const py = (x: number) => HEIGHT - ((x - yMin) / (yMax - yMin)) * (HEIGHT - TOP);
206+
207+ ctx.clearRect(0, 0, this.width, HEIGHT);
208+ // Lines stay below the legend row (a skipped transient can go off scale).
209+ ctx.save();
210+ ctx.beginPath();
211+ ctx.rect(0, TOP - 2, this.width, HEIGHT - TOP + 2);
212+ ctx.clip();
213+ ctx.lineWidth = 1.25;
214+ ctx.lineJoin = "round";
215+ const color = (k: number) => (g.color ? g.color(k, g.series.length) : COLORS[k % COLORS.length]);
216+ g.series.forEach((s, k) => {
217+ const vals = this.v[j][k];
218+ const jump = s.period === undefined ? Infinity : s.period / 2;
219+ ctx.strokeStyle = color(k);
220+ ctx.beginPath();
221+ // Current pixel column: its min and max, and whether the min came first.
222+ let cur = -1, cLo = 0, cHi = 0, loFirst = true, penDown = false;
223+ const emit = (v: number) => {
224+ if (penDown) ctx.lineTo(cur, py(v));
225+ else ctx.moveTo(cur, py(v));
226+ penDown = true;
227+ };
228+ const flush = () => {
229+ if (cur < 0) return;
230+ emit(loFirst ? cLo : cHi);
231+ if (cHi !== cLo) emit(loFirst ? cHi : cLo);
232+ };
233+ const liftPen = () => {
234+ flush();
235+ penDown = false;
236+ cur = -1;
237+ };
238+ let prev = NaN;
239+ for (let m = 0; m < n; m++) {
240+ const v = tv(vals[idx[m]]);
241+ if (!Number.isFinite(v)) {
242+ liftPen();
243+ prev = NaN;
244+ continue;
245+ }
246+ if (Math.abs(v - prev) > jump) liftPen(); // wrap
247+ prev = v;
248+ if (col[m] !== cur) {
249+ flush();
250+ cur = col[m];
251+ cLo = cHi = v;
252+ loFirst = true;
253+ } else if (v < cLo) {
254+ cLo = v;
255+ loFirst = false;
256+ } else if (v > cHi) {
257+ cHi = v;
258+ loFirst = true;
259+ }
260+ }
261+ flush();
262+ ctx.stroke();
263+ });
264+ ctx.restore();
265+
266+ if (cm >= 0) {
267+ const cx = ((ct! - this.tStart) / win) * this.width;
268+ ctx.strokeStyle = this.cursor.pinned ? "#555" : "#aaa";
269+ ctx.lineWidth = 1;
270+ ctx.beginPath();
271+ ctx.moveTo(cx, TOP);
272+ ctx.lineTo(cx, HEIGHT);
273+ ctx.stroke();
274+ }
275+
276+ ctx.font = FONT;
277+ ctx.textBaseline = "top";
278+ let x = 4;
279+ const at = cm >= 0 ? idx[cm] : newest; // sample whose values the legend shows
280+ g.series.forEach((s, k) => {
281+ if (k >= LEGEND_MAX) return;
282+ const v = this.v[j][k][at];
283+ const text = `${s.label} ${Number.isFinite(v) ? fmt(v) : "–"}`;
284+ ctx.fillStyle = color(k);
285+ ctx.fillText(text, x, 3);
286+ x += ctx.measureText(text).width + 12;
287+ });
288+ if (g.series.length > LEGEND_MAX) {
289+ const text = `… ${g.series[g.series.length - 1].label}`;
290+ ctx.fillStyle = MUTED;
291+ ctx.fillText(text, x, 3);
292+ x += ctx.measureText(text).width + 12;
293+ }
294+ if (g.unit) {
295+ ctx.fillStyle = MUTED;
296+ ctx.fillText(g.unit, x, 3);
297+ }
298+ ctx.fillStyle = MUTED;
299+ ctx.textAlign = "right";
300+ if (cm >= 0) {
301+ ctx.fillText(`at ${this.t[at].toFixed(2)} s${this.cursor.pinned ? " (pinned)" : ""}`, this.width - 4, 3);
302+ } else if (!empty) {
303+ const show = g.log ? (y: number) => `1e${y}` : (y: number) => (y === 0 ? "0" : fmt(y, 3));
304+ ctx.fillText(`${show(lo)} … ${show(hi)}${g.log ? " (log)" : ""}`, this.width - 4, 3);
305+ }
306+ // Time span, at the bottom right: the right edge's time for a fixed
307+ // range, the window length when scrolling.
308+ const secs = `${Number.isInteger(win) ? win : win.toFixed(1)} s`;
309+ const span = this.fixed ? secs : `last ${secs}`;
310+ ctx.textBaseline = "bottom";
311+ ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
312+ ctx.fillRect(this.width - ctx.measureText(span).width - 6, HEIGHT - 14, ctx.measureText(span).width + 6, 14);
313+ ctx.fillStyle = MUTED;
314+ ctx.fillText(span, this.width - 4, HEIGHT - 1);
315+ ctx.textAlign = "left";
316+ });
317+ }
318+}
319+
320+// x to `digits` significant digits (exponential when very small or large).
321+function fmt(x: number, digits = 4): string {
322+ const a = Math.abs(x);
323+ if (a !== 0 && (a < 1e-3 || a >= 1e5)) return x.toExponential(digits - 1);
324+ return a >= 10 ** digits ? x.toFixed(0) : x.toPrecision(digits);
325+}
src/fput-chain/tsconfig.jsonadded+12−0View file
@@ -0,0 +1,12 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "lib": ["ES2022", "DOM"],
7+ "strict": true,
8+ "noEmit": true,
9+ "skipLibCheck": true
10+ },
11+ "include": ["src", "vite.config.ts"]
12+}
src/fput-chain/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
src/fput-chain/vite.config.tsadded+8−0View file
@@ -0,0 +1,8 @@
1+import { defineConfig } from "vite";
2+
3+// Relative asset paths, so the build works wherever the site is served
4+// (an origin root or a /<collection>/<repo>/site/ path); ES module workers.
5+export default defineConfig({
6+ base: "./",
7+ worker: { format: "es" },
8+});
↑↓ move↵openescclose