Chart legends fit left of the range text; the rest counted as +n
2 changed files+29−24
src/common/traces.tsmodified+28−23View file
@@ -33,7 +33,7 @@ const CAPACITY = 16384; // samples kept per series (ring buffer)
3333 const HEIGHT = 92; // CSS pixels; drawn at devicePixelRatio
3434 const TOP = 18; // legend row height; the plot is below it
3535 const COLORS = ["#2f5d8a", "#c8742c", "#4a8c5c", "#a8476e"];
36-const LEGEND_MAX = 8; // series named in the legend; the rest are summarized
36+const LEGEND_MAX = 8; // series named in the legend, at most (fewer if they do not fit); the rest are counted
3737 const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
3838 const MUTED = "#999";
3939
@@ -275,34 +275,39 @@ export class TracePanel {
275275
276276 ctx.font = FONT;
277277 ctx.textBaseline = "top";
278- let x = 4;
278+ // Right of the legend row: the cursor's time, or the y range.
279279 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;
280+ const show = g.log ? (y: number) => `1e${y}` : (y: number) => (y === 0 ? "0" : fmt(y, 3));
281+ const right = cm >= 0
282+ ? `at ${this.t[at].toFixed(2)} s${this.cursor.pinned ? " (pinned)" : ""}`
283+ : empty ? "" : `${show(lo)} … ${show(hi)}${g.log ? " (log)" : ""}`;
284+ // Legend: as many series (at most LEGEND_MAX) as fit left of that text,
285+ // then "+n" for the rest, then the unit.
286+ const w = (text: string) => ctx.measureText(text).width;
287+ const room = this.width - 8 - (right ? w(right) + 12 : 0) - (g.unit ? w(g.unit) : 0);
288+ const texts = g.series.slice(0, LEGEND_MAX).map((s, k) => {
282289 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;
290+ return `${s.label} ${Number.isFinite(v) ? fmt(v) : "–"}`;
287291 });
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);
292+ const more = (c: number) => (c < g.series.length ? `+${g.series.length - c}` : "");
293+ let c = texts.length;
294+ const used = (c: number) =>
295+ texts.slice(0, c).reduce((sum, t) => sum + w(t) + 12, 0) + (more(c) ? w(more(c)) + 12 : 0);
296+ while (c > 0 && used(c) > room) c--;
297+ let x = 4;
298+ for (let k = 0; k < c; k++) {
299+ ctx.fillStyle = color(k);
300+ ctx.fillText(texts[k], x, 3);
301+ x += w(texts[k]) + 12;
297302 }
298303 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);
304+ if (more(c)) {
305+ ctx.fillText(more(c), x, 3);
306+ x += w(more(c)) + 12;
305307 }
308+ if (g.unit) ctx.fillText(g.unit, x, 3);
309+ ctx.textAlign = "right";
310+ if (right) ctx.fillText(right, this.width - 4, 3);
306311 // Time span, at the bottom right: the right edge's time for a fixed
307312 // range, the window length when scrolling.
308313 const secs = `${Number.isInteger(win) ? win : win.toFixed(1)} s`;
src/fput-chain/README.mdmodified+1−1View file
@@ -2,7 +2,7 @@
22
33 Struck FPUT-beta chain, step 2 of "Listening for chaos" (after double-pendulum-chaos). Work in progress.
44
5-Current state: N masses (2 to 64, default 2) between two fixed walls, joined by springs with tension k d + k3 d^3. The chain sits at rest until Strike, which gives it energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, either on one mass j alone (default mass 1, a hammer blow at one end) or in the shape of one normal mode n. The left card animates the chain (top: to scale, rest spacing a; below: the same displacements drawn sideways as a profile, scaled to fit, with the scale given) with the time and the relative energy error. Speed: 0.1x to 20x real time (default 1x). The time and current energy show at the top left. The Ghost chip (as in the pendulum app; controls in `../common/ghost.ts`) adds a second chain started from the same state with every mass moved by the same amount, 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10; moving only the struck mass would put the offset along the motion itself, which does not grow), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost" (from the chain's current state), when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started, and a "Separation" chart (log, last 60 s; offered only with the ghost on) plots it. A "Lyapunov" chart (last 60 s; the first 5 s after a strike left out of its y range) shows the largest Lyapunov exponent over the last 10 s and since the strike (`../common/lyapunov.ts`, two-trajectory Benettin method shared with the pendulum app, with the chain's shadow in `lyapunov.ts`: a hidden shadow chain 1e-8 away, displacements in units of d0 and momenta in units of sqrt(m k) d0, renormalized every step). Checked (N = 4, mass-1 strike, 300 s): without damping 0.1 J ~0.05 to 0.1 /s (weak chaos; N = 4 onset is near 3 k d0^2 = 75 mJ), 0.5 J ~0.5 /s, 2.51 J ~0.9 /s, 25 J ~1.7 /s; with tau 540 s, 0.1 J falls to ~0.01 /s and 2.51 J falls from ~1.0 to ~0.3 /s by 300 s. The slider is labelled "Strike energy". Parameters (m, k, k3, a, tau) fold out underneath with derived values (mode frequencies, d0, nonlinear energy scale, integrator step); Apply or changing N returns the chain to rest. The "Real time" card on the right has toggle chips for its charts. "Mode history" (off by default) is a scrolling heat map of each mode's share of the harmonic energy, log10(E_n / sum E) over 6 decades (inferno colors, mode 1 at the bottom), over the last 300 s (`LiveModeHistory` in `../common/modehistory.ts`); the cascade of energy up the modes shows here (e.g. N = 32, mode-1 strike at 10 mJ: the top mode's share goes from ~1e-28 to ~1e-12 in 10 periods of mode 1 and ~3e-5 in 50 without damping; with tau 540 s the damping wins and the spread stalls near mode 17). "Mode bars" (`../common/modebars.ts`) shows the current harmonic energy of each normal mode relative to E on a log scale (12 decades), with the number of modes sharing the energy (inverse participation, 1 / sum (E_n / sum E)^2). The other chips are charts over time (from `../common/traces.ts` and `../common/cursor.ts`, with per-group colors and a legend capped at 8 series): displacements, velocities, spring stretches, mode energies (log, last 60 s; values below 1e-12 of E left out), energies (E, T, V, W dissipated) and the energy balance error ((E + W - E0) / E0); others last 10 s; sampled every 10 ms; hover for values, click to pin. Mode bars, displacements and mode energies are on by default. Masses, springs and modes share one color per index (four fixed colors for up to 4, a blue-to-red sweep beyond).
5+Current state: N masses (2 to 64, default 2) between two fixed walls, joined by springs with tension k d + k3 d^3. The chain sits at rest until Strike, which gives it energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, either on one mass j alone (default mass 1, a hammer blow at one end) or in the shape of one normal mode n. The left card animates the chain (top: to scale, rest spacing a; below: the same displacements drawn sideways as a profile, scaled to fit, with the scale given) with the time and the relative energy error. Speed: 0.1x to 20x real time (default 1x). The time and current energy show at the top left. The Ghost chip (as in the pendulum app; controls in `../common/ghost.ts`) adds a second chain started from the same state with every mass moved by the same amount, 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10; moving only the struck mass would put the offset along the motion itself, which does not grow), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost" (from the chain's current state), when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started, and a "Separation" chart (log, last 60 s; offered only with the ghost on) plots it. A "Lyapunov" chart (last 60 s; the first 5 s after a strike left out of its y range) shows the largest Lyapunov exponent over the last 10 s and since the strike (`../common/lyapunov.ts`, two-trajectory Benettin method shared with the pendulum app, with the chain's shadow in `lyapunov.ts`: a hidden shadow chain 1e-8 away, displacements in units of d0 and momenta in units of sqrt(m k) d0, renormalized every step). Checked (N = 4, mass-1 strike, 300 s): without damping 0.1 J ~0.05 to 0.1 /s (weak chaos; N = 4 onset is near 3 k d0^2 = 75 mJ), 0.5 J ~0.5 /s, 2.51 J ~0.9 /s, 25 J ~1.7 /s; with tau 540 s, 0.1 J falls to ~0.01 /s and 2.51 J falls from ~1.0 to ~0.3 /s by 300 s. The slider is labelled "Strike energy". Parameters (m, k, k3, a, tau) fold out underneath with derived values (mode frequencies, d0, nonlinear energy scale, integrator step); Apply or changing N returns the chain to rest. The "Real time" card on the right has toggle chips for its charts. "Mode history" (off by default) is a scrolling heat map of each mode's share of the harmonic energy, log10(E_n / sum E) over 6 decades (inferno colors, mode 1 at the bottom), over the last 300 s (`LiveModeHistory` in `../common/modehistory.ts`); the cascade of energy up the modes shows here (e.g. N = 32, mode-1 strike at 10 mJ: the top mode's share goes from ~1e-28 to ~1e-12 in 10 periods of mode 1 and ~3e-5 in 50 without damping; with tau 540 s the damping wins and the spread stalls near mode 17). "Mode bars" (`../common/modebars.ts`) shows the current harmonic energy of each normal mode relative to E on a log scale (12 decades), with the number of modes sharing the energy (inverse participation, 1 / sum (E_n / sum E)^2). The other chips are charts over time (from `../common/traces.ts` and `../common/cursor.ts`, with per-group colors and a legend of at most 8 series, fewer when they would run into the range text at the right, the rest counted as "+n"): displacements, velocities, spring stretches, mode energies (log, last 60 s; values below 1e-12 of E left out), energies (E, T, V, W dissipated) and the energy balance error ((E + W - E0) / E0); others last 10 s; sampled every 10 ms; hover for values, click to pin. Mode bars, displacements and mode energies are on by default. Masses, springs and modes share one color per index (four fixed colors for up to 4, a blue-to-red sweep beyond).
66
77 "Sped up · listen" (open by default, below the cards; otherwise as in the pendulum app): the header says how much faster it runs ("the whole decay, 321× faster: 62 min of motion in 12 s"; without damping "321× faster than the chain above"). The page starts muted (the mute button is tinted red and reads "Muted"; muted notes are still computed and shown); the audio context is created at the first Strike; closing the panel stops the note. While it is open each Strike plays a note, the same strike rendered in a Web Worker (`render-worker.ts`, on the loop shared with the beam app in `../common/note-render.ts`) sped up so the lowest linear mode sounds at the Pitch (5 to 1000 Hz, default 100; below ~20 Hz the lowest modes are below hearing but show in the spectrogram; the readout gives the lowest and highest mode pitches). A damped note is the whole decay, ending when its energy has fallen to 1e-6 of the strike's (-60 dB; predicted length (tau/2) ln 1e6 of physical time, 11.6 s of sound by default, measured 11.3 s; capped at twice that); undamped notes last 10 s. "Strike to hear the whole decay." shows until the first note; the status line gives the signal and note length ("computing…" while rendering). Replay plays the last strike again with the current settings (to compare signals). Signal (`signals.ts`): velocity (default), displacement or acceleration of mass j (default 1, the end mass, which is never at a node), force on the left wall (the first spring's tension), or sum of accelerations (the net wall force, in which even modes cancel). In one mass's motion mode n is weighted by sqrt(E_n) / omega_n, sqrt(E_n) or sqrt(E_n) omega_n. Each note is made zero-mean, scaled to a 0.9 peak (all signals equally loud; Volume sets the level) and faded out over its last 20 ms (`../common/sound.ts`). Save WAV saves the last note as heard (16-bit mono PCM, peak at 0.9; `../common/wav.ts`; named e.g. fput-N4-mass1-2.51J-v1-100Hz.wav). Chips (as in the pendulum app, plus Modes): Spectrogram (on; `NoteSpectrogram` in `../common/spectrogram.ts`: 2048-sample Hann, zero-pad 4096, hop 512, for pitches down to 30 Hz; below, the window lengthens in proportion (up to 16384 samples at 5 Hz, zero-pad 2x, hop a quarter window) so neighbouring low modes stay resolved; dashed lines at the linear mode pitches, each drawn only while at least 6 px from its neighbours, since crowded together their dashes merged into a solid on-off bar; frequency axis logarithmic by default, from a round number at or below half the lowest mode pitch, with a "log f" chip to switch to linear from 0; each pixel row shows the loudest FFT bin in its band), Modes (off by default; the note's mode-energy history, each mode's share of the energy over the whole note, from the render worker, one column per 256 samples; `ModeHistory` in `../common/modehistory.ts`; a cascade that takes tens of mode-1 periods occupies only the first few percent of a whole-decay note, so Mode history in the Real time panel shows it better), Lyapunov (the note's largest Lyapunov exponent over the last 10 s of physical time, from a shadow run in the worker alongside the note; the first 5 s of physical time left out of the y range), Energy (log scale, so the decay is a straight line) and λ vs E (`../common/scatter.ts`: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note on a log scale, earlier notes faded, hover for values, Clear button). The first three span the whole note (strike at left, end at right) and fill in as the audio clock reaches each sample; one shared cursor (playhead while playing; hover for values, click to pin). Pause (or space) pauses the animation and the sound (audio context suspended). The note's Lyapunov shadow runs only while the Lyapunov chart or λ vs E is shown (it doubles the computation). Render time in the browser pane (with the Lyapunov shadow): ~0.6 s for N = 4, ~2 s for N = 32 (default strike). First look (N = 4, 2.51 J): λ ~1.3 /s at the strike, falling to ~0 by ~4 s of sound as the spectrogram resolves into lines; on λ vs E the note's points sit near 0 below ~0.1 J and rise to ~1.4 /s at the top.
88