Driven plate: mode map (every mode's energy against time, averaged over time bins, log colour, hover readout); energy chart reads the same averaged bins
7 changed files+277−28
src/driven-plate/README.mdmodified+5−1View file
@@ -1,6 +1,6 @@
11 # driven-plate
22
3-A rectangular plate driven in one mode, without damping, its energy raised slowly, to watch the energy move from the driven mode into the others. Step 1 (2026-10-09): simply supported Nx × Ny plate with von Kármán stretching, modal drive with an energy-target controller, log ramp, height map, energy chart.
3+A rectangular plate driven in one mode, without damping, its energy raised slowly, to watch the energy move from the driven mode into the others. Step 1 (2026-10-09): simply supported Nx × Ny plate with von Kármán stretching, modal drive with an energy-target controller, log ramp, height map, energy chart. Step 2 (2026-10-09): mode map (energy of every mode against time).
44
55 Plate (`plate.ts`): the square plate of `../plate` (simply supported, flat) generalized to a rectangle, with spacings ax and ay. Nx follows from the masses across the short side and the aspect so the cells are nearly square (default 8 across, aspect 1.3: 11 × 8). Units scaled away (D = ρh = h = 1, short side 1): with energy given against the nonlinear scale, displacement against h and time in driven periods, only the aspect and ν = 0.3 remain. Bending on the five-point Laplacian, exact sine modes ω_mn = l_m / ax² + l_n / ay²; stretching by the stress function, L⁻² by two sine transforms (plain matrix products, no mirror trick yet), force the exact gradient.
66
@@ -11,3 +11,7 @@ Page (`main.ts`): runs on the main thread, advancing speed × frame time in driv
1111 Checks (2026-10-09, Node/bun scratch script): force against central differences of the energy, 5e-10 relative (Nx 9, Ny 6); stretching over bending of the (1, 1) shape at amplitude h on the square, 0.2243, 0.2134, 0.2094 at N = 8, 16, 64 (`../plate`: 0.224, 0.213, 0.2094; continuum 0.2091); linear period of (1, 1) against the Verlet-corrected prediction, 7e-9; undriven energy error over 200 periods at 0.1, 1, 10, 100 E_nl: 4e-6, 1e-4, 6e-4, 9e-4 (bounded); controller on the default ramp (10⁻³ to 10² over 2000 periods) tracks to 1%, energy error 6e-4. Cost 4.3 µs per step (11 × 8); steps per driven period 1700 at 0.1 E_nl, 6000 at 10, 18000 at 100 (the plate stiffens: the driven mode's period falls to 0.1 of its linear value at 100 E_nl), so the page keeps up with 20 periods/s only to a few E_nl.
1212
1313 First look (default settings): the other modes' energy rises steadily with the drive as the driven mode's own nonlinear response (∝ E³), then jumps by about two decades near 0.5 E_nl, after which they hold a third or more of the energy; the driven mode's pitch rises (1.4 × linear at 2 E_nl). Without the perturbation the route differs (42% left in the driven mode at 1.7 E_nl against 70% at 2 E_nl with it).
14+
15+Mode map (step 2, `history.ts`, `modemap.ts`): `drive.ts` samples every mode's linear energy (sine transforms of u and p) 16 times per driven period and averages them; the page closes a time bin every 0.5 periods (`History`), merging neighbours in pairs and doubling the bin length when there are 1200, so a run of any length fits. The energy chart now reads the same bins (smooth: the per-frame sampling of the swinging off-resonance energies made the other-modes curve a band). The map has one row per mode, ordered by pitch, labelled (m, n) and pitch relative to the driven mode, colour log energy (inferno-like) from 12 decades below the largest so far; new bins are painted incrementally, the whole image only on a merge or a change of the colour scale's top decade. Hover reads a mode and its energy.
16+
17+Seen (default settings, 2026-10-09; the map, and a bun script listing the leading modes every 100 periods of the default ramp): up to about 0.2 E_nl the other modes that hold energy are the (odd, odd) family of (1, 1), (3, 1) first, then (1, 3), (3, 3), (5, 1), (1, 5), (5, 3), their energies growing as about E³ (the driven mode's own nonlinear response, bound to it); the rest stay at the perturbation's level. Near 0.3 E_nl (1, 3) takes off and passes (3, 1) (6e-3 E_nl at 0.31, 0.2 at 0.56), then (1, 5) (0.1 at 1 E_nl). With the perturbation, modes of other symmetry ((2, 5), (4, 3), (4, 5)) lead by 1.8 E_nl; without it the leaders at 1.8 E_nl are still all (odd, odd) ((1, 5), (3, 7), (5, 3), (5, 7)), up to then identical to the perturbed run. On the map the whole band brightens within a few tens of periods around 1100 periods (about 0.5 E_nl) and stays filled to the grid's top mode (no sink: undamped).
src/driven-plate/drive.tsmodified+25−0View file
@@ -16,12 +16,16 @@
1616 // control interval from E; Q is held fixed over each step, and its work Q dq
1717 // is added to Wd, so E - Wd stays constant up to the integrator's error.
1818 // Time: velocity Verlet at the step of plate.ts, recomputed from the energy.
19+// Mode energies for the displays are averaged over time (sampled every
20+// 1 / SAMPLES_PER_PERIOD of a driven period): a mode's linear energy swings
21+// within each cycle when it is driven off resonance by the others.
1922
2023 import { Modes, Plate, gridFor, modeShape, nonlinearScale, omegaOf, stepFor, type Grid, type Params } from "./plate";
2124
2225 const TAU_C = 2; // controller time constant, in driven periods
2326 const G_MAX = 0.2; // cap on G, times the driven mode's omega
2427 const PERTURB = 1e-6; // energy of the perturbation, relative to the starting energy
28+const SAMPLES_PER_PERIOD = 16;
2529
2630 export interface DriveOptions {
2731 m: number; // driven mode (m, n)
@@ -58,6 +62,11 @@ export class DrivenPlate {
5862 // Measured period of the driven mode: upward zero crossings of q.
5963 private lastCross = NaN;
6064 measuredPeriod = NaN;
65+ // Sum of the mode energies over the samples since the last takeMean.
66+ private acc: Float64Array;
67+ private sample: Float64Array;
68+ private accN = 0;
69+ private nextSample = 0;
6170
6271 constructor(P: Params, opts: DriveOptions) {
6372 this.P = P;
@@ -74,6 +83,8 @@ export class DrivenPlate {
7483 this.u = new Float64Array(this.g.n);
7584 this.p = new Float64Array(this.g.n);
7685 this.F = new Float64Array(this.g.n);
86+ this.acc = new Float64Array(this.g.n);
87+ this.sample = new Float64Array(this.g.n);
7788 // Start in the driven mode at E0 (at rest, displaced), plus the perturbation.
7889 const E0 = opts.E0 * this.Enl, q0 = Math.sqrt(2 * E0) / this.omega;
7990 for (let k = 0; k < this.g.n; k++) this.u[k] = (q0 * this.S[k]) / this.sqM;
@@ -157,6 +168,12 @@ export class DrivenPlate {
157168 this.lastCross = tc;
158169 }
159170 this.t += h;
171+ if (this.t >= this.nextSample) {
172+ this.modalEnergies(this.sample);
173+ for (let k = 0; k < n; k++) this.acc[k] += this.sample[k];
174+ this.accN++;
175+ this.nextSample = this.t + this.period / SAMPLES_PER_PERIOD;
176+ }
160177 }
161178 this.E = this.kinetic() + this.V;
162179 return steps;
@@ -186,6 +203,14 @@ export class DrivenPlate {
186203 const W = this.omegaGrid, M = g.mass;
187204 for (let k = 0; k < n; k++) out[k] = (Pp[k] * Pp[k]) / (2 * M) + (W[k] * W[k] * M * U[k] * U[k]) / 2;
188205 }
206+ // Mean mode energies since the last call into out (as modalEnergies; the
207+ // energies now if there was no sample), and start a new mean.
208+ takeMean(out: Float64Array): void {
209+ if (this.accN === 0) this.modalEnergies(out);
210+ else for (let k = 0; k < this.g.n; k++) out[k] = this.acc[k] / this.accN;
211+ this.acc.fill(0);
212+ this.accN = 0;
213+ }
189214 // Index of the driven mode in modalEnergies' output.
190215 get drivenIndex(): number {
191216 return this.opts.m - 1 + this.g.Nx * (this.opts.n - 1);
src/driven-plate/history.tsadded+57−0View file
@@ -0,0 +1,57 @@
1+// History of a run, for the energy chart and the mode map: time bins, each
2+// with the time at its end (driven periods), the total and target energies
3+// then, and every mode's linear energy averaged over the bin (by grid index,
4+// (m - 1) + Nx (n - 1)), all in units of E_nl. Bins start BIN0 periods long;
5+// when there are MAX_BINS, neighbours are merged in pairs (energies
6+// averaged) and the bin length doubles, so a run of any length fits.
7+
8+import type { DrivenPlate } from "./drive";
9+
10+const BIN0 = 0.5;
11+export const MAX_BINS = 1200;
12+
13+export interface Bin {
14+ t: number;
15+ E: number;
16+ Et: number;
17+ modes: Float32Array;
18+}
19+
20+export class History {
21+ bins: Bin[] = [];
22+ span = BIN0; // bin length (periods)
23+ merges = 0; // how many times the bins were merged (the map repaints)
24+ private start = 0; // start of the open bin (periods)
25+ private tmp: Float64Array;
26+ constructor(sim: DrivenPlate) {
27+ this.tmp = new Float64Array(sim.g.n);
28+ this.push(sim);
29+ }
30+ // Close the open bin if it is long enough.
31+ update(sim: DrivenPlate): void {
32+ if (sim.t / sim.period - this.start >= this.span) this.push(sim);
33+ }
34+ private push(sim: DrivenPlate): void {
35+ const t = sim.t / sim.period, s = 1 / sim.Enl;
36+ sim.takeMean(this.tmp);
37+ this.bins.push({ t, E: sim.E * s, Et: sim.Et * s, modes: Float32Array.from(this.tmp, (x) => x * s) });
38+ this.start = t;
39+ if (this.bins.length >= MAX_BINS) this.merge();
40+ }
41+ private merge(): void {
42+ const out: Bin[] = [], b = this.bins;
43+ // Pair from the end, so the newest bin stays whole if the count is odd.
44+ let i = b.length % 2;
45+ if (i) out.push(b[0]);
46+ for (; i + 1 < b.length; i += 2) {
47+ const m = b[i].modes.map((x, k) => (x + b[i + 1].modes[k]) / 2);
48+ out.push({ ...b[i + 1], modes: m });
49+ }
50+ this.bins = out;
51+ this.span *= 2;
52+ this.merges++;
53+ }
54+ get last(): Bin {
55+ return this.bins[this.bins.length - 1];
56+ }
57+}
src/driven-plate/index.htmlmodified+10−1View file
@@ -55,6 +55,10 @@
5555 </section>
5656 </div>
5757 </div>
58+ <section class="card wide">
59+ <div class="card-head">Modes <button type="button" class="help" data-help="modes" title="About the mode map" aria-label="About the mode map">?</button></div>
60+ <canvas id="modemap"></canvas>
61+ </section>
5862 </main>
5963 <dialog id="help">
6064 <div id="help-body"></div>
@@ -75,7 +79,12 @@
7579 </template>
7680 <template id="help-energy">
7781 <h2>Energy</h2>
78- <p>Against time in driven periods, on a log scale in units of E<sub>nl</sub>: the total energy (black), the target (dashed), the driven mode's energy (blue) and the sum over all other modes (orange). The mode energies are the linear ones, (velocity² + ω² displacement²) / 2 per mode; once stretching matters they no longer add up exactly to the total.</p>
82+ <p>Against time in driven periods, on a log scale in units of E<sub>nl</sub>: the total energy (black), the target (dashed), the driven mode's energy (blue) and the sum over all other modes (orange). The mode energies are the linear ones, (velocity² + ω² displacement²) / 2 per mode, averaged over the time steps of the mode map below; once stretching matters they no longer add up exactly to the total.</p>
83+ </template>
84+ <template id="help-modes">
85+ <h2>Modes</h2>
86+ <p>Every normal mode of the plate, one row each, ordered by pitch with the lowest at the bottom; the label gives (m, n) and the pitch relative to the driven mode (marked ▸). Time runs across, the whole run as in the energy chart. Colour is the mode's linear energy, (velocity² + ω² displacement²) / 2, averaged over each time step of the map (sampled 16 times per driven period), on a log scale reaching 12 factors of ten below the largest so far: dark is next to nothing, pale yellow the most. Averaging matters because a mode pushed off its own pitch by the others swings in energy within each cycle.</p>
87+ <p>Hover over the map to read a mode and its energy at that time. The time steps start at half a period and double whenever the map fills, so a long run still fits.</p>
7988 </template>
8089 <script type="module" src="./main.ts"></script>
8190 </body>
src/driven-plate/main.tsmodified+29−26View file
@@ -3,6 +3,8 @@ import "./style.css";
33 import { fit } from "../common/canvas";
44 import { setupHelp } from "../common/help";
55 import { DrivenPlate } from "./drive";
6+import { History } from "./history";
7+import { ModeMap } from "./modemap";
68 import { Modes, gridFor, type Params } from "./plate";
79
810 setupHelp();
@@ -13,6 +15,7 @@ const BUDGET_MS = 14; // compute per frame at most
1315 const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
1416 const plateCanvas = $<HTMLCanvasElement>("plate");
1517 const chartCanvas = $<HTMLCanvasElement>("chart");
18+const modeMap = new ModeMap($<HTMLCanvasElement>("modemap"));
1619 const readout = $("readout");
1720 const runButton = $<HTMLButtonElement>("run");
1821 const rampBox = $<HTMLInputElement>("ramp");
@@ -34,9 +37,7 @@ let running = true;
3437 let logTarget = LOG_START;
3538 let actualSpeed = 0; // periods per second, smoothed
3639 let ampl = 0; // colour scale: recent largest |u|
37-let modal = new Float64Array(0);
38-// History for the chart: time (periods) and log10 of E, target, driven, others (in E_nl).
39-let hist: { t: number[]; E: number[]; Et: number[]; Ed: number[]; Eo: number[] };
40+let hist: History;
4041
4142 const fmt = (x: number, d = 3) => (x === 0 ? "0" : Math.abs(x) >= 1e-2 && Math.abs(x) < 1e4 ? x.toPrecision(d) : x.toExponential(d - 1));
4243 const speed = () => 10 ** Number(speedSlider.value);
@@ -61,10 +62,9 @@ function reset(): void {
6162 sim = new DrivenPlate(P, { m, n, E0: 10 ** LOG_START, perturb: perturbBox.checked });
6263 logTarget = LOG_START;
6364 targetSlider.value = String(LOG_START);
64- modal = new Float64Array(sim.g.n);
65- hist = { t: [], E: [], Et: [], Ed: [], Eo: [] };
65+ hist = new History(sim);
66+ modeMap.reset(sim);
6667 ampl = 0;
67- record();
6868 showParams();
6969 }
7070
@@ -79,19 +79,12 @@ function showParams(): void {
7979 paramsDiv.textContent = lines.join("\n");
8080 }
8181
82-function record(): void {
83- sim.modalEnergies(modal);
84- let others = 0;
82+// Driven mode's and the other modes' energies in a bin (E_nl).
83+function split(modes: Float32Array): { driven: number; others: number } {
8584 const d = sim.drivenIndex;
86- for (let k = 0; k < modal.length; k++) if (k !== d) others += modal[k];
87- const L = (x: number) => Math.log10(Math.max(x / sim.Enl, 1e-30));
88- hist.t.push(sim.t / sim.period);
89- hist.E.push(L(sim.E));
90- hist.Et.push(L(sim.Et));
91- hist.Ed.push(L(modal[d]));
92- hist.Eo.push(L(others));
93- // Keep the history short: drop every other point when it grows long.
94- if (hist.t.length > 4000) for (const key of ["t", "E", "Et", "Ed", "Eo"] as const) hist[key] = hist[key].filter((_, i) => i % 2 === 0);
85+ let all = 0;
86+ for (let k = 0; k < modes.length; k++) all += modes[k];
87+ return { driven: modes[d], others: all - modes[d] };
9588 }
9689
9790 function setTarget(): void {
@@ -122,7 +115,7 @@ function frame(now: number): void {
122115 msPerPeriod = 0.8 * msPerPeriod + 0.2 * Math.max(ms / periods, 1e-3);
123116 }
124117 actualSpeed = 0.9 * actualSpeed + 0.1 * (wall > 0 ? periods / wall : 0);
125- record();
118+ hist.update(sim);
126119 } else actualSpeed = 0;
127120 draw();
128121 requestAnimationFrame(frame);
@@ -131,8 +124,9 @@ function frame(now: number): void {
131124 function draw(): void {
132125 drawPlate();
133126 drawChart();
127+ modeMap.draw(hist);
134128 speedValue.textContent = `${fmt(speed(), 2)} /s`;
135- const others = 10 ** hist.Eo[hist.Eo.length - 1], driven = 10 ** hist.Ed[hist.Ed.length - 1], E = sim.E / sim.Enl;
129+ const { driven, others } = split(hist.last.modes), E = sim.E / sim.Enl;
136130 const T = sim.measuredPeriod / sim.period;
137131 readout.textContent =
138132 `t ${(sim.t / sim.period).toFixed(0)} periods · E ${fmt(E)} E_nl (target ${fmt(sim.Et / sim.Enl)}) · driven mode ${fmt((100 * driven) / E, 3)}% · other modes ${fmt((100 * others) / E, 2)}%\n` +
@@ -196,7 +190,8 @@ function drawPlate(): void {
196190 ctx.textAlign = "left";
197191 }
198192
199-// Energy chart: log10(E / E_nl) against time in periods.
193+// Energy chart: log10(E / E_nl) against time in periods, from the history's
194+// bins (mode energies averaged over each bin).
200195 const SERIES = [
201196 { key: "E", color: "#23262b", dash: [] as number[], label: "total" },
202197 { key: "Et", color: "#7a7d82", dash: [4, 4], label: "target" },
@@ -206,12 +201,20 @@ const SERIES = [
206201 function drawChart(): void {
207202 const ctx = fit(chartCanvas), W = chartCanvas.clientWidth, H = chartCanvas.clientHeight;
208203 ctx.clearRect(0, 0, W, H);
209- const n = hist.t.length;
204+ const bins = hist.bins, n = bins.length;
210205 if (n < 2) return;
206+ const lg = (x: number) => Math.log10(Math.max(x, 1e-30));
207+ const ts = bins.map((b) => b.t), parts = bins.map((b) => split(b.modes));
208+ const series = {
209+ E: bins.map((b) => lg(b.E)),
210+ Et: bins.map((b) => lg(b.Et)),
211+ Ed: parts.map((p) => lg(p.driven)),
212+ Eo: parts.map((p) => lg(p.others)),
213+ };
211214 const l = 40, r = 8, t = 22, b = 22, pw = W - l - r, ph = H - t - b;
212- const tMax = Math.max(hist.t[n - 1], 1);
215+ const tMax = Math.max(ts[n - 1], 1);
213216 let lo = Infinity, hi = -Infinity;
214- for (const s of SERIES) for (const v of hist[s.key]) if (v > -30) {
217+ for (const s of SERIES) for (const v of series[s.key]) if (v > -30) {
215218 lo = Math.min(lo, v);
216219 hi = Math.max(hi, v);
217220 }
@@ -234,12 +237,12 @@ function drawChart(): void {
234237 ctx.textAlign = "center";
235238 ctx.fillText(`time (driven periods), 0 to ${tMax.toFixed(0)}`, l + pw / 2, H - 6);
236239 for (const s of SERIES) {
237- const v = hist[s.key];
240+ const v = series[s.key];
238241 ctx.strokeStyle = s.color;
239242 ctx.setLineDash(s.dash);
240243 ctx.lineWidth = 1.5;
241244 ctx.beginPath();
242- for (let i = 0; i < n; i++) (i ? ctx.lineTo : ctx.moveTo).call(ctx, X(hist.t[i]), Y(v[i]));
245+ for (let i = 0; i < n; i++) (i ? ctx.lineTo : ctx.moveTo).call(ctx, X(ts[i]), Y(v[i]));
243246 ctx.stroke();
244247 }
245248 ctx.setLineDash([]);
src/driven-plate/modemap.tsadded+149−0View file
@@ -0,0 +1,149 @@
1+// The mode map: one row per mode, ordered by pitch (lowest at the bottom),
2+// time across (the whole run, as the energy chart), colour the mode's linear
3+// energy averaged over each time bin, on a log scale DECADES deep below the
4+// largest so far. The image holds one pixel per bin and mode and is
5+// stretched to the plot; new bins are painted as they come, and the whole
6+// image is repainted when the bins are merged or the colour scale moves.
7+// Hover names the mode under the cursor and its energy.
8+
9+import { fit } from "../common/canvas";
10+import type { DrivenPlate } from "./drive";
11+import { MAX_BINS, type History } from "./history";
12+
13+const DECADES = 12;
14+const L = 92, R = 8, T = 30, B = 22; // margins (px)
15+
16+// Inferno-like colour map, dark (low) to pale yellow (high), as a lookup table.
17+const STOPS = [
18+ [0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99], [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164],
19+];
20+const LUT = new Uint8ClampedArray(256 * 3);
21+for (let i = 0; i < 256; i++) {
22+ const x = (i / 255) * (STOPS.length - 1), k = Math.min(STOPS.length - 2, Math.floor(x)), f = x - k;
23+ for (let c = 0; c < 3; c++) LUT[3 * i + c] = STOPS[k][c] + f * (STOPS[k + 1][c] - STOPS[k][c]);
24+}
25+const cssColor = (v: number) => {
26+ const i = 3 * Math.max(0, Math.min(255, Math.round(v * 255)));
27+ return `rgb(${LUT[i]}, ${LUT[i + 1]}, ${LUT[i + 2]})`;
28+};
29+
30+export class ModeMap {
31+ private canvas: HTMLCanvasElement;
32+ private img: HTMLCanvasElement;
33+ private ictx: CanvasRenderingContext2D;
34+ private data: ImageData | null = null;
35+ private painted = 0; // bins painted
36+ private merges = -1;
37+ private top = NaN; // log10 of the top of the colour scale
38+ private rows: Int32Array = new Int32Array(0); // row (from the bottom) -> grid index
39+ private sim: DrivenPlate | null = null;
40+ private hover: { x: number; y: number } | null = null;
41+
42+ constructor(canvas: HTMLCanvasElement) {
43+ this.canvas = canvas;
44+ this.img = document.createElement("canvas");
45+ this.ictx = this.img.getContext("2d")!;
46+ canvas.addEventListener("mousemove", (e) => {
47+ const r = canvas.getBoundingClientRect();
48+ this.hover = { x: e.clientX - r.left, y: e.clientY - r.top };
49+ });
50+ canvas.addEventListener("mouseleave", () => (this.hover = null));
51+ }
52+
53+ // A new run (new plate or reset).
54+ reset(sim: DrivenPlate): void {
55+ this.sim = sim;
56+ const M = sim.modes, Nx = sim.g.Nx;
57+ this.rows = Int32Array.from({ length: M.count }, (_, k) => M.m[k] - 1 + Nx * (M.n[k] - 1));
58+ this.img.width = MAX_BINS;
59+ this.img.height = M.count;
60+ this.data = this.ictx.createImageData(MAX_BINS, M.count);
61+ this.painted = 0;
62+ this.merges = -1;
63+ this.top = NaN;
64+ }
65+
66+ draw(hist: History): void {
67+ const sim = this.sim!, data = this.data!, bins = hist.bins, count = this.rows.length;
68+ // Colour scale: top at the decade above the largest energy so far.
69+ let mx = 0;
70+ for (const b of bins) for (let k = 0; k < count; k++) if (b.modes[k] > mx) mx = b.modes[k];
71+ const top = Math.ceil(Math.log10(Math.max(mx, 1e-30)));
72+ if (top !== this.top || hist.merges !== this.merges) {
73+ this.top = top;
74+ this.merges = hist.merges;
75+ this.painted = 0;
76+ }
77+ const d = data.data, W = MAX_BINS;
78+ for (let c = this.painted; c < bins.length; c++) {
79+ const e = bins[c].modes;
80+ for (let r = 0; r < count; r++) {
81+ const v = (Math.log10(Math.max(e[this.rows[r]], 1e-30)) - (top - DECADES)) / DECADES;
82+ const i = 3 * Math.max(0, Math.min(255, Math.round(v * 255))), q = 4 * (c + W * (count - 1 - r));
83+ d[q] = LUT[i];
84+ d[q + 1] = LUT[i + 1];
85+ d[q + 2] = LUT[i + 2];
86+ d[q + 3] = 255;
87+ }
88+ }
89+ if (this.painted < bins.length) this.ictx.putImageData(data, 0, 0, this.painted, 0, bins.length - this.painted, count);
90+ this.painted = bins.length;
91+
92+ const ctx = fit(this.canvas), Wc = this.canvas.clientWidth, Hc = this.canvas.clientHeight;
93+ ctx.clearRect(0, 0, Wc, Hc);
94+ const pw = Wc - L - R, ph = Hc - T - B, rowH = ph / count;
95+ ctx.imageSmoothingEnabled = false;
96+ if (bins.length > 0) ctx.drawImage(this.img, 0, 0, bins.length, count, L, T, pw, ph);
97+ ctx.strokeStyle = "#e3e2dc";
98+ ctx.strokeRect(L - 0.5, T - 0.5, pw + 1, ph + 1);
99+
100+ // Row labels: (m, n) and pitch relative to the driven mode, as many as fit;
101+ // the driven mode always, in the accent colour.
102+ const M = sim.modes, driven = this.rows.indexOf(sim.drivenIndex);
103+ const every = Math.max(1, Math.ceil(13 / rowH));
104+ ctx.font = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
105+ ctx.textAlign = "right";
106+ const label = (r: number) => `(${M.m[r]},${M.n[r]}) ${(M.omega[r] / sim.omega).toFixed(2)}`;
107+ const yOf = (r: number) => T + ph - (r + 0.5) * rowH;
108+ for (let r = 0; r < count; r += every) {
109+ if (Math.abs(r - driven) < every && r !== driven) continue;
110+ ctx.fillStyle = "#7a7d82";
111+ ctx.fillText(label(r), L - 6, yOf(r) + 4);
112+ }
113+ ctx.fillStyle = "#2f5d8a";
114+ ctx.fillText(`▸ ${label(driven)}`, L - 6, yOf(driven) + 4);
115+
116+ // Time axis and colour bar.
117+ const tMax = Math.max(hist.last.t, 1);
118+ ctx.font = "11px system-ui, sans-serif";
119+ ctx.fillStyle = "#7a7d82";
120+ ctx.textAlign = "center";
121+ ctx.fillText(`time (driven periods), 0 to ${tMax.toFixed(0)}`, L + pw / 2, Hc - 6);
122+ const cbW = Math.min(160, pw / 3), cbX = L + pw - cbW - 66;
123+ for (let i = 0; i < cbW; i++) {
124+ ctx.fillStyle = cssColor(i / (cbW - 1));
125+ ctx.fillRect(cbX + i, 8, 1.5, 10);
126+ }
127+ ctx.fillStyle = "#7a7d82";
128+ ctx.textAlign = "right";
129+ ctx.fillText(`1e${top - DECADES}`, cbX - 4, 17);
130+ ctx.textAlign = "left";
131+ ctx.fillText(`1e${top} E_nl`, cbX + cbW + 4, 17);
132+
133+ // Hover: the mode and bin under the cursor.
134+ const h = this.hover;
135+ ctx.textAlign = "left";
136+ ctx.fillStyle = "#23262b";
137+ if (h && h.x >= L && h.x < L + pw && h.y >= T && h.y < T + ph && bins.length > 0) {
138+ const c = Math.min(bins.length - 1, Math.floor(((h.x - L) / pw) * bins.length));
139+ const r = Math.min(count - 1, Math.floor((T + ph - h.y) / rowH));
140+ const e = bins[c].modes[this.rows[r]];
141+ ctx.fillText(`(${M.m[r]}, ${M.n[r]}) · ${(M.omega[r] / sim.omega).toFixed(2)} × driven · t ${bins[c].t.toFixed(0)} · ${e.toExponential(1)} E_nl`, L, 17);
142+ ctx.strokeStyle = "rgba(255, 255, 255, 0.7)";
143+ ctx.strokeRect(L, yOf(r) - rowH / 2, pw, rowH);
144+ } else {
145+ ctx.fillStyle = "#7a7d82";
146+ ctx.fillText("rows: modes by pitch (× driven); hover to read", L, 17);
147+ }
148+ }
149+}
src/driven-plate/style.cssmodified+2−0View file
@@ -12,3 +12,5 @@ input[type="number"] { width: 4.5em; font-family: var(--mono); }
1212 .label-w { min-width: 7.5em; }
1313 .value-w { min-width: 6em; text-align: right; }
1414 #params { margin-top: 12px; font: 12px/1.6 var(--mono); color: var(--muted); white-space: pre; overflow-x: auto; }
15+.wide { margin-top: 16px; }
16+#modemap { height: 420px; cursor: crosshair; }