/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Driven plate: steady views (mode grid of energy per (m, n), default; RMS displacement), live height map as an option; start paused
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 2a0d8384935e parent 206d3e9 Browse files
6 changed files+162−12
src/driven-plate/README.mdmodified+3−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. Step 2 (2026-10-09): mode map (energy of every mode against time). Loss option (2026-10-09): a per-mode sink at the top of the band. Step 3 (2026-10-09): spectrogram at a listening point. Step 4 (2026-10-09): listening (playback of the recorded run from any time, while it computes); ramp-rate slider; target up to 10³ E_nl.
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). Loss option (2026-10-09): a per-mode sink at the top of the band. Step 3 (2026-10-09): spectrogram at a listening point. Views (2026-10-09): the live height map flickered (the plate oscillates many times a second at the run speed), so the plate card now has three views, mode grid by default; the page starts paused. Step 4 (2026-10-09): listening (playback of the recorded run from any time, while it computes); ramp-rate slider; target up to 10³ E_nl.
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
@@ -29,3 +29,5 @@ Seen (default settings, no loss, 2026-10-09): one line at the driven pitch to ab
2929 Listening (step 4, `recording.ts`, `player.ts`, `marks.ts`; Jeremy: always compute ahead and play back, from any point, while the run goes on). The run computes as before (main thread, Speed); `drive.ts` records displacement, velocity and acceleration (with the drive force) at the listening point, 512 samples per driven period, each the mean over its interval, kept whole (`Recording`, Float32 chunks, 12 MB per 2000 periods). Playback (`LivePlayer`) maps one driven period to 1 / pitch s (pitch 20 to 160 Hz, default 60), resamples by a windowed sinc (Hann, 8 zero crossings, cutoff 0.9 of the lower Nyquist), renders 0.2 s chunks scheduled back to back 0.6 s ahead of the audio clock, and stops when it reaches the computed end (status says so). Level: steady (divide by a peak follower, instant attack, 1.5 s release; default) or as computed (one scale from the loudest recorded so far, tanh limit). Clicking the energy chart, spectrogram or mode map sets the start (dashed line on all three; the playhead is a solid line); changing signal, pitch or level restarts from the playhead. The spectrogram shows the signal chosen for listening (velocity by default) and is remade from the recording when it changes. Ramp rate: log slider, 5 to 2000 periods per decade (default 400); target slider to 10³ E_nl.
3030
3131 Checks (2026-10-09): resampler offline (bun, fake audio context): a recorded tone at the driven pitch plays at 60.000 Hz at pitch 60; a 37 x component keeps its 0.1 ratio (0.0999); steady level holds 0.500 per chunk while the recorded amplitude rises; the largest step between output samples (0.0168) stays below the signal's own bound (0.0185), so chunk joins are seamless. In the browser: start set by a click, playback at 60 periods per second, the playhead on all three charts, switching to displacement remakes the spectrogram, no console errors. Sample rate: velocity spectrum at the listening point (sampled 2048 per period, held energies) by octave of the driven pitch: at 100 E_nl −37 dB at 128-256 x, −64 dB above 256 x; at 10³ E_nl −31 and −46 dB; so 256 samples per period (first version) would fold the 128-256 band back at about −40 dB after the averaging; 512 was chosen. Cost at 10³ E_nl: about 10000 steps per period, 10 periods per second on the main thread.
32+
33+Views (2026-10-09; Jeremy: the flashing height map was annoying; something steadier, such as modes; start paused). The plate card has a view selector: modes (default, `modegrid.ts`): one cell per (m, n), m across and n up, colour log energy of the mode (the history's bin means smoothed over about 5 periods), 6 decades below a top that rises at once and falls slowly; the driven mode outlined; hover reads a cell. A 10-decade range made the spread-out state at high energy one flat colour; 6 shows which modes lead (at 200 E_nl: (1, 1), (1, 3), the m = 1 column). Vibration: RMS displacement over about 4 periods at each point (`drive.ts` keeps a running mean square per node, updated with each signal sample), white to dark red, the listening point marked (click to move). Plate: the live height map as before. The page starts paused (Run).
src/driven-plate/drive.tsmodified+10−0View file
@@ -45,6 +45,8 @@ const LOSS_EVERY = 8;
4545 // pitch. At 1e3 E_nl the velocity spectrum is -31 dB at 128-256 x and -46 dB
4646 // above 256 x (2026-10-09), so 256 per period would fold the 128-256 band back.
4747 export const SIGNAL_RATE = 512;
48+const RMS_PERIODS = 4;
49+const RMS_A = 1 / (RMS_PERIODS * SIGNAL_RATE);
4850
4951 export interface Loss {
5052 tauTop: number; // amplitude decay time of the top mode, in driven periods; 0 = off
@@ -108,6 +110,7 @@ export class DrivenPlate {
108110 this.u = new Float64Array(this.g.n);
109111 this.p = new Float64Array(this.g.n);
110112 this.F = new Float64Array(this.g.n);
113+ this.ms = new Float64Array(this.g.n);
111114 this.acc = new Float64Array(this.g.n);
112115 this.sample = new Float64Array(this.g.n);
113116 // Start in the driven mode at E0 (at rest, displaced), plus the perturbation.
@@ -121,6 +124,7 @@ export class DrivenPlate {
121124 this.readMode();
122125 this.setStep();
123126 this.setListen(this.listen.fx, this.listen.fy);
127+ for (let k = 0; k < this.g.n; k++) this.ms[k] = (this.u[k] * this.u[k]) / 2; // as for a cosine
124128 }
125129
126130 // Random phases, equal energy e / (count - 1) in every mode but the driven
@@ -207,6 +211,8 @@ export class DrivenPlate {
207211 if (this.t >= this.nextSig) {
208212 const c = 1 / this.sigN;
209213 this.rec.push(this.sumU * c, this.sumV * c, this.sumA * c);
214+ const ms = this.ms;
215+ for (let q = 0; q < n; q++) ms[q] += RMS_A * (u[q] * u[q] - ms[q]);
210216 this.sumU = this.sumV = this.sumA = this.sigN = 0;
211217 this.nextSig += this.period / SIGNAL_RATE;
212218 }
@@ -262,6 +268,10 @@ export class DrivenPlate {
262268 return w[0] * x[k[0]] + w[1] * x[k[1]] + w[2] * x[k[2]] + w[3] * x[k[3]];
263269 }
264270 rec = new Recording();
271+ // Mean square displacement of each node, a running mean over about
272+ // RMS_PERIODS driven periods (updated with each signal sample), for the
273+ // vibration view.
274+ ms: Float64Array = new Float64Array(0);
265275 private sumU = 0;
266276 private sumV = 0;
267277 private sumA = 0;
src/driven-plate/index.htmlmodified+9−2View file
@@ -10,11 +10,18 @@
1010 <header><h1>Driven plate</h1></header>
1111 <div class="grid">
1212 <section class="card">
13+ <div class="row view-row">
14+ <select id="view" title="What the card shows">
15+ <option value="modes" selected>modes: energy per (m, n)</option>
16+ <option value="rms">vibration: RMS displacement</option>
17+ <option value="plate">plate: live height map</option>
18+ </select>
19+ </div>
1320 <canvas id="plate"></canvas>
1421 <div id="readout" class="num"></div>
1522 <div id="controls">
1623 <div class="row">
17- <button id="run" class="primary">Pause</button>
24+ <button id="run" class="primary">Run</button>
1825 <button id="reset">Reset</button>
1926 <label class="chip" title="Raise the target energy steadily (log scale) up to the top of the slider"><input id="ramp" type="checkbox" checked /><span>Ramp</span></label>
2027 <button type="button" class="help" data-help="drive" title="About the driven plate" aria-label="About the driven plate">?</button>
@@ -111,7 +118,7 @@
111118
112119 <template id="help-drive">
113120 <h2>The driven plate</h2>
114- <p>A thin rectangular plate, simply supported at its edges, seen from above: red is up, blue down, white at rest (scaled to the recent largest displacement). The circle is the listening point for the spectrogram; click the plate to move it. It is a grid of equal masses with bending stiffness and von Kármán stretching: a bent plate stretches in its own plane, which stiffens it once it moves by about its thickness h. There is no damping.</p>
121+ <p>A thin rectangular plate, simply supported at its edges. The card shows one of three views. Modes (the default) has one cell per normal mode, m half waves along x and n along y, coloured by the mode's energy (log scale, smoothed over a few periods), the driven mode outlined; hover to read a cell. Vibration shows how much each point of the plate moves, the RMS displacement over the last few periods, white to dark red. Plate shows the plate from above as it moves, red up, blue down, white at rest; it changes many times a second and flickers. In the last two the circle is the listening point for the spectrogram and the sound; click to move it. The plate starts paused: press Run. It is a grid of equal masses with bending stiffness and von Kármán stretching: a bent plate stretches in its own plane, which stiffens it once it moves by about its thickness h. There is no damping.</p>
115122 <p>A force in the shape of one normal mode (the driven mode) pushes the plate. In a linear plate such a force moves that mode and nothing else, so any other mode that moves got its energy through the stretching. The force is always in phase with the driven mode's own velocity, so it adds energy whatever the mode's frequency (which rises as the plate stiffens), and a controller sets its strength so that the plate's total energy follows the target.</p>
116123 <p>The target is given against E<sub>nl</sub>, the energy of the driven mode at which its stretching energy equals its bending energy: well below 1 the plate is nearly linear. Ramp raises the target steadily, the same number of periods for each factor of ten (set by the Ramp slider, 5 to 2000), up to the top of the slider (10<sup>3</sup> E<sub>nl</sub>); untick it to hold the energy where it is. With very fast ramps the controller (time constant 2 periods) falls behind the target. Reset starts again in the driven mode at 10<sup>−3</sup> E<sub>nl</sub>. Time is counted in periods of the driven mode (linear), and Speed sets how many pass per second; at high energies the plate needs many more steps per period, and the actual speed (shown) can fall below the setting. The space bar pauses.</p>
117124 </template>
src/driven-plate/main.tsmodified+44−8View file
@@ -7,6 +7,7 @@ import { DrivenPlate } from "./drive";
77 import { History } from "./history";
88 import { LiveSpectrogram } from "./livespec";
99 import { ModeMap } from "./modemap";
10+import { ModeGrid } from "./modegrid";
1011 import { drawMarks, type Marks } from "./marks";
1112 import { LivePlayer, type Level } from "./player";
1213 import type { SignalKind } from "./recording";
@@ -24,6 +25,14 @@ const modeMap = new ModeMap($<HTMLCanvasElement>("modemap"));
2425 const spec = new LiveSpectrogram($<HTMLCanvasElement>("spec"));
2526 const perTimeBox = $<HTMLInputElement>("spec-per-time");
2627 const readout = $("readout");
28+const viewSelect = $<HTMLSelectElement>("view");
29+const modeGrid = new ModeGrid();
30+let plateHover: { x: number; y: number } | null = null;
31+plateCanvas.addEventListener("mousemove", (e) => {
32+ const r = plateCanvas.getBoundingClientRect();
33+ plateHover = { x: e.clientX - r.left, y: e.clientY - r.top };
34+});
35+plateCanvas.addEventListener("mouseleave", () => (plateHover = null));
2736 const runButton = $<HTMLButtonElement>("run");
2837 const rampBox = $<HTMLInputElement>("ramp");
2938 const rampRate = $<HTMLInputElement>("ramp-rate");
@@ -55,7 +64,7 @@ const marks: Marks = { start: null, play: null };
5564
5665 let P: Params = { Ny: 8, aspect: 1.3 };
5766 let sim: DrivenPlate;
58-let running = true;
67+let running = false; // starts paused
5968 let logTarget = LOG_START;
6069 let actualSpeed = 0; // periods per second, smoothed
6170 let ampl = 0; // colour scale: recent largest |u|
@@ -93,6 +102,7 @@ function reset(): void {
93102 applyLoss();
94103 drivePower = lossPower = 0;
95104 hist = new History(sim);
105+ modeGrid.reset();
96106 modeMap.reset(sim);
97107 spec.reset(sim);
98108 player.stop();
@@ -155,6 +165,7 @@ function frame(now: number): void {
155165 }
156166 actualSpeed = 0.9 * actualSpeed + 0.1 * (wall > 0 ? periods / wall : 0);
157167 hist.update(sim);
168+ modeGrid.update(hist.last.modes, hist.last.t);
158169 } else actualSpeed = 0;
159170 spec.update(sim.rec);
160171 player.tick(sim.rec);
@@ -186,17 +197,36 @@ function draw(): void {
186197
187198 // Where the plate was drawn (px), for clicks.
188199 let plateBox = { x0: 0, y0: 0, w: 1, h: 1 };
189-// Height map, as in ../plate: u bilinear between the nodes (0 at the
190-// supports), red up, blue down, scaled to the recent largest |u|.
200+// The plate card shows one of three views: the mode grid (energy per mode,
201+// default: steady), the vibration (RMS displacement over the last few
202+// periods at each point: steady), or the plate itself (the height map, live:
203+// it flickers as the plate oscillates many times a second).
204+function drawPlate(): void {
205+ const view = viewSelect.value;
206+ plateCanvas.style.cursor = view === "modes" ? "default" : "crosshair";
207+ if (view === "modes") {
208+ const ctx = fit(plateCanvas), W = plateCanvas.clientWidth, H = plateCanvas.clientHeight;
209+ ctx.clearRect(0, 0, W, H);
210+ modeGrid.draw(ctx, W, H, sim, plateHover);
211+ } else if (view === "rms") drawField(sim.ms.map(Math.sqrt), false);
212+ else drawField(sim.u, true);
213+}
214+// Height map, as in ../plate: values bilinear between the nodes (0 at the
215+// supports); signed: red up, blue down; unsigned (RMS): white to dark red;
216+// scaled to the recent largest.
191217 const RES = 200;
192218 const heat = document.createElement("canvas");
193219 const heatCtx = heat.getContext("2d")!;
194220 let heatImage: ImageData | null = null;
195-function drawPlate(): void {
221+let shownView = "";
222+function drawField(u: Float64Array, signed: boolean): void {
196223 const ctx = fit(plateCanvas), W = plateCanvas.clientWidth, H = plateCanvas.clientHeight;
197224 ctx.clearRect(0, 0, W, H);
198- const g = sim.g, u = sim.u;
199- const mx = sim.maxAbsU();
225+ const g = sim.g;
226+ let mx = 0;
227+ for (const v of u) mx = Math.max(mx, Math.abs(v));
228+ if (shownView !== viewSelect.value) ampl = 0; // new scale for a new view
229+ shownView = viewSelect.value;
200230 ampl = Math.max(mx, ampl * 0.97);
201231 const scale = ampl > 0 ? 1 / ampl : 0;
202232 const rw = RES, rh = Math.max(2, Math.round((RES * g.Ly) / g.Lx));
@@ -214,7 +244,11 @@ function drawPlate(): void {
214244 const fx = (((px + 0.5) / rw) * g.Lx) / g.ax - 1, i0 = Math.floor(fx), bx = fx - i0;
215245 const v0 = (1 - bx) * at(i0, j0) + bx * at(i0 + 1, j0), v1 = (1 - bx) * at(i0, j0 + 1) + bx * at(i0 + 1, j0 + 1);
216246 const v = Math.max(-1, Math.min(1, ((1 - by) * v0 + by * v1) * scale)), q = 4 * (px + rw * py);
217- if (v >= 0) {
247+ if (!signed) {
248+ d[q] = 255 - 75 * v;
249+ d[q + 1] = 255 - 235 * v;
250+ d[q + 2] = 255 - 215 * v;
251+ } else if (v >= 0) {
218252 d[q] = 255;
219253 d[q + 1] = 255 - 200 * v;
220254 d[q + 2] = 255 - 210 * v;
@@ -245,7 +279,7 @@ function drawPlate(): void {
245279 ctx.fillStyle = "#7a7d82";
246280 ctx.font = "11px system-ui, sans-serif";
247281 ctx.textAlign = "right";
248- ctx.fillText(ampl > 0 ? `colour full at ±${fmt(ampl, 2)} h` : "", x0 + w, H - 6);
282+ ctx.fillText(ampl > 0 ? (signed ? `colour full at ±${fmt(ampl, 2)} h` : `RMS displacement, last ~4 periods; full at ${fmt(ampl, 2)} h`) : "", x0 + w, H - 6);
249283 ctx.textAlign = "left";
250284 }
251285
@@ -357,6 +391,7 @@ aspectInput.addEventListener("change", applyGrid);
357391 modeSelect.addEventListener("change", reset);
358392 // Click the plate to move the listening point (the run goes on).
359393 plateCanvas.addEventListener("click", (e) => {
394+ if (viewSelect.value === "modes") return;
360395 const r = plateCanvas.getBoundingClientRect(), b = plateBox;
361396 const fx = (e.clientX - r.left - b.x0) / b.w, fy = 1 - (e.clientY - r.top - b.y0) / b.h;
362397 if (fx <= 0 || fx >= 1 || fy <= 0 || fy >= 1) return;
@@ -404,4 +439,5 @@ for (const el of [signalSelect, pitchSlider, levelSelect]) {
404439 fillModes();
405440 reset();
406441 setTarget();
442+setRunning(false);
407443 requestAnimationFrame(frame);
src/driven-plate/modegrid.tsadded+95−0View file
@@ -0,0 +1,95 @@
1+// The mode grid: one cell per mode (m across, n up), coloured by the mode's
2+// linear energy on a log scale, DECADES deep below a top that follows the
3+// largest (rising at once, falling slowly, so the colours do not jump). The
4+// energies are the history's bin means, smoothed over about SMOOTH driven
5+// periods. The driven mode is outlined; the hovered cell is named above.
6+
7+import { colormap } from "../common/spectrogram";
8+import type { DrivenPlate } from "./drive";
9+
10+const DECADES = 6;
11+const SMOOTH = 5; // periods
12+
13+export class ModeGrid {
14+ private e = new Float64Array(0); // smoothed energies, by grid index (E_nl)
15+ private lastT = 0;
16+ private top = NaN; // log10 of the colour scale's top
17+ // Cell layout of the last draw, for hover.
18+ private box = { x0: 0, y0: 0, cw: 1, ch: 1, nx: 1, ny: 1 };
19+
20+ reset(): void {
21+ this.e = new Float64Array(0);
22+ this.top = NaN;
23+ }
24+
25+ // Take in the latest bin (mode energies in E_nl, by grid index) at run time t.
26+ update(modes: Float32Array, t: number): void {
27+ if (this.e.length !== modes.length) {
28+ this.e = Float64Array.from(modes);
29+ this.lastT = t;
30+ return;
31+ }
32+ const a = 1 - Math.exp(-Math.max(0, t - this.lastT) / SMOOTH);
33+ this.lastT = t;
34+ for (let k = 0; k < modes.length; k++) this.e[k] += a * (modes[k] - this.e[k]);
35+ }
36+
37+ // Grid index under (x, y) px, or -1.
38+ at(x: number, y: number): number {
39+ const b = this.box, i = Math.floor((x - b.x0) / b.cw), j = Math.floor((b.y0 + b.ny * b.ch - y) / b.ch);
40+ return i >= 0 && j >= 0 && i < b.nx && j < b.ny ? i + b.nx * j : -1;
41+ }
42+
43+ draw(ctx: CanvasRenderingContext2D, W: number, H: number, sim: DrivenPlate, hover: { x: number; y: number } | null): void {
44+ const { Nx, Ny } = sim.g, e = this.e;
45+ if (e.length !== Nx * Ny) return;
46+ let mx = 0;
47+ for (const v of e) mx = Math.max(mx, v);
48+ const want = Math.log10(Math.max(mx, 1e-30));
49+ this.top = Number.isNaN(this.top) || want > this.top ? want : this.top + 0.02 * (want - this.top);
50+ const left = 34, right = 12, top = 22, bottom = 34;
51+ const cell = Math.min((W - left - right) / Nx, (H - top - bottom) / Ny);
52+ const x0 = left + (W - left - right - cell * Nx) / 2, y0 = top;
53+ this.box = { x0, y0, cw: cell, ch: cell, nx: Nx, ny: Ny };
54+ const rgb = new Uint8ClampedArray(4);
55+ for (let j = 0; j < Ny; j++) {
56+ for (let i = 0; i < Nx; i++) {
57+ const v = (Math.log10(Math.max(e[i + Nx * j], 1e-30)) - (this.top - DECADES)) / DECADES;
58+ colormap(v, rgb, 0);
59+ ctx.fillStyle = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
60+ ctx.fillRect(x0 + i * cell, y0 + (Ny - 1 - j) * cell, cell - 1, cell - 1);
61+ }
62+ }
63+ // Driven mode outlined.
64+ const di = sim.opts.m - 1, dj = sim.opts.n - 1;
65+ ctx.strokeStyle = "#2f5d8a";
66+ ctx.lineWidth = 2.5;
67+ ctx.strokeRect(x0 + di * cell - 1, y0 + (Ny - 1 - dj) * cell - 1, cell + 1, cell + 1);
68+ // Axes: m along x, n along y.
69+ ctx.fillStyle = "#7a7d82";
70+ ctx.font = "11px system-ui, sans-serif";
71+ ctx.textAlign = "center";
72+ for (let i = 0; i < Nx; i++) ctx.fillText(`${i + 1}`, x0 + (i + 0.5) * cell, y0 + Ny * cell + 13);
73+ ctx.fillText("m (half waves along x)", x0 + (Nx * cell) / 2, y0 + Ny * cell + 28);
74+ ctx.textAlign = "right";
75+ for (let j = 0; j < Ny; j++) ctx.fillText(`${j + 1}`, x0 - 5, y0 + (Ny - 1 - j + 0.5) * cell + 4);
76+ ctx.save();
77+ ctx.translate(10, y0 + (Ny * cell) / 2);
78+ ctx.rotate(-Math.PI / 2);
79+ ctx.textAlign = "center";
80+ ctx.fillText("n", 0, 0);
81+ ctx.restore();
82+ // Header: hovered mode, or the colour scale.
83+ ctx.textAlign = "left";
84+ const k = hover ? this.at(hover.x, hover.y) : -1;
85+ if (k >= 0) {
86+ const m = (k % Nx) + 1, n = Math.floor(k / Nx) + 1;
87+ const w = sim.modes.omega[Array.from(sim.modes.m).findIndex((mm, q) => mm === m && sim.modes.n[q] === n)];
88+ ctx.fillStyle = "#23262b";
89+ ctx.fillText(`(${m}, ${n}) · pitch ${(w / sim.omega).toFixed(2)} × driven · ${e[k].toExponential(1)} E_nl`, x0, 14);
90+ } else {
91+ ctx.fillStyle = "#7a7d82";
92+ ctx.fillText(`energy per mode, 1e${Math.round(this.top - DECADES)} to 1e${Math.round(this.top)} E_nl · driven mode outlined`, x0, 14);
93+ }
94+ }
95+}
src/driven-plate/style.cssmodified+1−1View file
@@ -14,5 +14,5 @@ input[type="number"] { width: 4.5em; font-family: var(--mono); }
1414 #params { margin-top: 12px; font: 12px/1.6 var(--mono); color: var(--muted); white-space: pre; overflow-x: auto; }
1515 .wide { margin-top: 16px; }
1616 #spec { height: 380px; cursor: crosshair; }
17-#plate { cursor: crosshair; }
17+.view-row { margin-bottom: 6px; }
1818 #modemap { height: 420px; cursor: crosshair; }
↑↓ move↵openescclose