chladni plate
[github]sand travelling to the nodal lines of a ringing plate
physics 45×23 12 fps 9.1 kB
┌───────────────────────────────────────────┐ │\\ ·| / | \ | //│ │ \\ \ .· | ·. ·· / //· │ │ · \\ '''' | · '''' ·// │ │ · \\ : ·· _/|\_ · ·// │ │''- · \\ .-''' ·| '''-. //· -''│ │ | \\// | · \\// | │ │ ·) /· \ | :/ \ ( · │ │ // / \\ | // \ \\ │ │-'· ·| \\ | ·// |· '-│ │ | · ·\\·|·// · ·| │ │---------------------+---------------------│ │· · | · ·// | \\· ·| │ │-. ·| // | \\ | .-│ │· \\ \ // |· \\ / // │ │ )· \· / | · \ / ( │ │· | //\\ · | · //\\ | │ │... ·// '-.__ | __.-' \\ ...│ │ // · · '\|/'· · \\ · ·│ │ // _.._ ·| _.._ \\ │ │ // / · ·'· ·| ' · \ \\ │ │// | \ | · / | \\│ └───────────────────────────────────────────┘┌───────────────────────────────────────────┐ │\\ ·| / | \ | //│ │ \\ \ .· | ·. ·· / //· │ │ · \\ '''' | · '''' ·// │ │ · \\ : ·· _/|\_ · ·// │ │''- · \\ .-''' ·| '''-. //· -''│ │ | \\// | · \\// | │ │ ·) /· \ | :/ \ ( · │ │ // / \\ | // \ \\ │ │-'· ·| \\ | ·// |· '-│ │ | · ·\\·|·// · ·| │ │---------------------+---------------------│ │· · | · ·// | \\· ·| │ │-. ·| // | \\ | .-│ │· \\ \ // |· \\ / // │ │ )· \· / | · \ / ( │ │· | //\\ · | · //\\ | │ │... ·// '-.__ | __.-' \\ ...│ │ // · · '\|/'· · \\ · ·│ │ // _.._ ·| _.._ \\ │ │ // / · ·'· ·| ' · \ \\ │ │// | \ | · / | \\│ └───────────────────────────────────────────┘
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="chladni"></ascii-art>
No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.
react
// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { chladni } from "ascii.rest/pieces";
<Ascii piece={chladni} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="chladni", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="chladni" />
Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.
typescript
// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { chladni } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, chladni); mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.
/*
* chladni: sand on a square plate ringing in one of its modes. It lies on the
* lines where two crossed standing waves cancel; when the tone moves on, the
* grains are shaken loose and travel to the lines of the next figure.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "chladni plate",
category: "physics",
note: "sand travelling to the nodal lines of a ringing plate",
cols: 45,
rows: 23,
fps: 12,
} satisfies Meta;
/*
* Each mode is cos(n pi x) cos(m pi y) + sign cos(m pi x) cos(n pi y). With n
* and m both odd every figure is mirrored both ways.
*/
const MODES: [number, number, number][] = [
[1, 3, -1],
[1, 5, -1],
[3, 5, -1],
[3, 7, -1],
[5, 7, -1],
[3, 7, 1],
[3, 5, 1],
[1, 5, 1],
];
const MOVE = 2.8; // seconds the sand takes to find the next figure
const HOLD = 2.2; // seconds a figure rests
const CYCLE = MOVE + HOLD;
const START = 4 * CYCLE - 0.2; // the fourth figure at rest, just before the tone moves
const GRAINS = 1000;
const LOOSE = 0.1; // share of the sand lying just off the lines
const P = Math.PI;
// One mode at (x, y) on a 1 by 1 plate, and its slope each way.
function wave([n, m, s]: [number, number, number], x: number, y: number): [number, number, number] {
const a = Math.cos(n * P * x), b = Math.cos(m * P * y);
const c = Math.cos(m * P * x), d = Math.cos(n * P * y);
return [
a * b + s * c * d,
-P * (n * Math.sin(n * P * x) * b + s * m * Math.sin(m * P * x) * d),
-P * (m * a * Math.sin(m * P * y) + s * n * c * Math.sin(n * P * y)),
];
}
function mulberry32(a: number) {
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/*
* A mode's figure on an ic by ir plate: a mark in every cell a line is drawn
* through. A steep line is drawn once a row, in the cell nearer to where it
* crosses between two cell centres, and a flat one once a column; "+" where two
* lines cross, "*" where the diagonals cross as well.
*/
function figure(mode: [number, number, number], ic: number, ir: number) {
const N = ic * ir;
const V = new Float64Array(N), EX = new Float64Array(N), EY = new Float64Array(N);
for (let i = 0; i < N; i++) {
const [v, fx, fy] = wave(mode, ((i % ic) + 0.5) / ic, (Math.floor(i / ic) + 0.5) / ir);
// The slope per em each way: a column is 0.6em, a row 1.2em.
(V[i] = Math.abs(v) < 1e-9 ? 0 : v), (EX[i] = fx / ic / 0.6), (EY[i] = fy / ir / 1.2);
}
const marks = new Array<string>(N).fill("");
const at = new Float64Array(N).fill(NaN); // where a steep line crosses its row, in columns
const slant = (i: number) => (EX[i] * EY[i] > 0 ? "/" : "\\");
const steep = (i: number, x: number) => {
at[i] = x;
marks[i] = Math.abs(EY[i]) > 0.4 * Math.abs(EX[i]) ? slant(i) : "|";
};
const flat = (i: number, oy: number) => {
if (marks[i]) return;
marks[i] = Math.abs(EX[i]) > 0.7 * Math.abs(EY[i]) ? slant(i) : oy < -0.22 ? "'" : oy < 0.14 ? "-" : oy < 0.34 ? "." : "_";
};
for (let i = 0; i < N; i++) {
const c = i % ic, r = Math.floor(i / ic);
if (V[i] === 0) {
// A line through the cell's centre, or two crossing there.
if (Math.hypot(EX[i], EY[i]) < 1e-6) marks[i] = Math.abs(wave(mode, (c + 0.85) / ic, (r + 0.85) / ir)[0]) < 1e-6 ? "*" : "+";
else if (Math.abs(EX[i]) >= Math.abs(EY[i])) steep(i, c + 0.5);
else flat(i, 0);
continue;
}
const j = i + 1, k = i + ic;
if (c < ic - 1 && V[i] * V[j] < 0 && Math.abs(EX[i] + EX[j]) >= 0.8 * Math.abs(EY[i] + EY[j])) {
const u = V[i] / (V[i] - V[j]);
steep(u < 0.5 ? i : j, c + 0.5 + u);
}
if (r < ir - 1 && V[i] * V[k] < 0 && Math.abs(EY[i] + EY[k]) >= 0.8 * Math.abs(EX[i] + EX[k])) {
const u = V[i] / (V[i] - V[k]);
u < 0.5 ? flat(i, u) : flat(k, u - 1);
}
}
// Near upright, a bracket where the line bows against its crossings in the rows above and below.
const near = (r: number, x: number) => {
for (let c = Math.floor(x) - 1; c <= Math.floor(x) + 1; c++) if (r >= 0 && r < ir && c >= 0 && c < ic && at[r * ic + c] === at[r * ic + c]) return at[r * ic + c];
return NaN;
};
for (let i = 0; i < N; i++) {
if (marks[i] !== "|") continue;
const r = Math.floor(i / ic), bow = at[i] - (near(r - 1, at[i]) + near(r + 1, at[i])) / 2;
if (bow > 0.2) marks[i] = ")";
else if (bow < -0.2) marks[i] = "(";
}
return { marks, EX, EY };
}
export default function chladni(): Frame {
const { cols, rows } = meta;
const IC = cols - 2, IR = rows - 2; // the plate inside its rim, about square
const N = IC * IR;
const rnd = mulberry32(18);
const figs = MODES.map((m) => figure(m, IC, IR));
// Where the sand lies on each figure, in cells: spread evenly along its lines, a little loose beside them.
const rest = figs.map(({ marks, EX, EY }) => {
const on: number[] = [];
marks.forEach((m, i) => m && on.push(i));
for (let i = on.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[on[i], on[j]] = [on[j], on[i]];
}
const xy = new Float64Array(2 * GRAINS);
for (let g = 0; g < GRAINS; g++) {
const loose = rnd() < LOOSE;
const i = loose ? on[Math.floor(rnd() * on.length)] : on[g % on.length];
let c = i % IC, r = Math.floor(i / IC);
if (loose) {
// One cell off the line, across it.
const ex = EX[i], ey = EY[i], side = rnd() < 0.5 ? -1 : 1;
if (Math.abs(ex) >= Math.abs(ey)) c += side;
else r += side;
if (Math.abs(ex) > 0.4 * Math.abs(ey) && Math.abs(ey) > 0.4 * Math.abs(ex)) (c += side), (r -= side * Math.sign(ex * ey));
c = Math.min(IC - 1, Math.max(0, c));
r = Math.min(IR - 1, Math.max(0, r));
}
xy[2 * g] = c + 0.15 + 0.7 * rnd();
xy[2 * g + 1] = r + 0.15 + 0.7 * rnd();
}
return xy;
});
/*
* For each change of figure, which resting place each grain makes for: the
* nearest one still free, taken in a shuffled order, and when it sets off.
*/
const trips = MODES.map((_, k) => {
const a = rest[(k + MODES.length - 1) % MODES.length], b = rest[k];
const to = new Int32Array(GRAINS), free = new Uint8Array(GRAINS).fill(1);
const order = Array.from({ length: GRAINS }, (_, g) => g);
for (let g = GRAINS - 1; g > 0; g--) {
const j = Math.floor(rnd() * (g + 1));
[order[g], order[j]] = [order[j], order[g]];
}
for (const g of order) {
let best = -1, bd = Infinity;
for (let h = 0; h < GRAINS; h++) {
if (!free[h]) continue;
const dx = (b[2 * h] - a[2 * g]) * 0.6, dy = (b[2 * h + 1] - a[2 * g + 1]) * 1.2;
const d = dx * dx + dy * dy;
if (d < bd) (bd = d), (best = h);
}
free[best] = 0;
to[g] = best;
}
// Sand where the new tone shakes hardest leaves first; a grain already on a line barely stirs.
const delay = new Float64Array(GRAINS), span = new Float64Array(GRAINS), sway = new Float64Array(GRAINS);
for (let g = 0; g < GRAINS; g++) {
const shake = Math.min(1, Math.abs(wave(MODES[k], a[2 * g] / IC, a[2 * g + 1] / IR)[0]) / 0.9);
const len = Math.hypot((b[2 * to[g]] - a[2 * g]) * 0.6, (b[2 * to[g] + 1] - a[2 * g + 1]) * 1.2);
delay[g] = 0.15 + 0.8 * (1 - shake) + 0.3 * rnd();
span[g] = len < 0.8 ? 1e-6 : Math.min(MOVE - 0.2 - delay[g], 0.35 + 0.16 * len + 0.2 * rnd());
sway[g] = (rnd() - 0.5) * 1.6;
}
return { a, b, to, delay, span, sway };
});
// Grains in each cell: still lying where they were, landed, or in the air.
const lying = new Uint16Array(N), landed = new Uint16Array(N), all = new Uint16Array(N);
return (t) => {
const tt = START + t;
const n = Math.floor(tt / CYCLE), tau = tt - n * CYCLE;
const k = n % MODES.length;
const { a, b, to, delay, span, sway } = trips[k];
lying.fill(0), landed.fill(0), all.fill(0);
for (let g = 0; g < GRAINS; g++) {
const u = Math.min(1, Math.max(0, (tau - delay[g]) / span[g]));
const e = u * u * (3 - 2 * u);
const x0 = a[2 * g], y0 = a[2 * g + 1], x1 = b[2 * to[g]], y1 = b[2 * to[g] + 1];
// Thrown a little off the straight way while it hops, landing true.
const dx = x1 - x0, dy = y1 - y0, len = Math.hypot(dx, dy) + 1e-9;
const s = sway[g] * Math.sin(P * e) * Math.min(1, len / 3);
const x = x0 + dx * e - (dy / len) * s * 0.5, y = y0 + dy * e + (dx / len) * s * 0.25;
const c = Math.min(IC - 1, Math.max(0, Math.floor(x))), r = Math.min(IR - 1, Math.max(0, Math.floor(y)));
const i = r * IC + c;
all[i]++;
if (u === 0) lying[i]++;
else if (u === 1) landed[i]++;
}
const was = figs[(k + MODES.length - 1) % MODES.length].marks, is = figs[k].marks;
const lines = ["┌" + "─".repeat(IC) + "┐"];
for (let r = 0; r < IR; r++) {
let row = "│";
for (let c = 0; c < IC; c++) {
const i = r * IC + c, q = all[i];
row += (landed[i] && is[i]) || (lying[i] && was[i]) || (q === 0 ? " " : q < 3 ? "·" : ":");
}
lines.push(row + "│");
}
lines.push("└" + "─".repeat(IC) + "┘");
return lines.join("\n");
};
}