harmonograph
[github]a pen on two decaying pendulums winding a figure inward
physics 64×28 30 fps 5.8 kB
_.---'''''''''---._ _.-'' __.----------.._'-._ .-' _.--'' __....-...__'-._-. _-' _.-' _.-''' ___..__ '--_'._\ _/' .-' _.-' _.--'' _ ''-._'\'\_\ ./ // .-' _.-' _.--'''' '''-._'\ \.\.\ /' _/ /' _.-' _.-''__.--'''''-. \\ \ \.\.\_ /' /' ./ _/' _.-' _--' '| | | | | \ / /' /' _/' _-' _-' | | | | | | | /' _/ // _/' .-' _| | | | | | | | / /' // ./' _/ ./ | | | | | | | /' /' o' // ./ _/ | | | | | | /' _/' /' _/ / | | | | | | _/' _/' ./ /' / | | | | | _.-' _/' _/' /' / \ \ | |_ \ _.-' _.'' _/' ./ /' '\'\'\ \_ \.__ __.--'' _.-' .-' _/' _/ '\'\_\_'-._ '''' __.--' _.-' .-' _/' '\'.''-__'''''''' __.-'' _.-' _-' '-'-._'''------''' _.--' _.-' ''-.''---.....---''' _.-'' ''--...______..---'' _.---'''''''''---._ _.-'' __.----------.._'-._ .-' _.--'' __....-...__'-._-. _-' _.-' _.-''' ___..__ '--_'._\ _/' .-' _.-' _.--'' _ ''-._'\'\_\ ./ // .-' _.-' _.--'''' '''-._'\ \.\.\ /' _/ /' _.-' _.-''__.--'''''-. \\ \ \.\.\_ /' /' ./ _/' _.-' _--' '| | | | | \ / /' /' _/' _-' _-' | | | | | | | /' _/ // _/' .-' _| | | | | | | | / /' // ./' _/ ./ | | | | | | | /' /' o' // ./ _/ | | | | | | /' _/' /' _/ / | | | | | | _/' _/' ./ /' / | | | | | _.-' _/' _/' /' / \ \ | |_ \ _.-' _.'' _/' ./ /' '\'\'\ \_ \.__ __.--'' _.-' .-' _/' _/ '\'\_\_'-._ '''' __.--' _.-' .-' _/' '\'.''-__'''''''' __.-'' _.-' _-' '-'-._'''------''' _.--' _.-' ''-.''---.....---''' _.-'' ''--...______..---''
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="harmonograph"></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 { harmonograph } from "ascii.rest/pieces";
<Ascii piece={harmonograph} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="harmonograph", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="harmonograph" />
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 { harmonograph } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, harmonograph); 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.
/*
* harmonograph: a pen on two pendulums, one swinging across and one up and
* down, both slowly running down, so the figure winds in on itself to a dense
* centre. While the next figure starts, the last is lifted from the outside in.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "harmonograph",
category: "physics",
note: "a pen on two decaying pendulums winding a figure inward",
cols: 64,
rows: 28,
fps: 30,
} satisfies Meta;
const W = 0.6, H = 1.2; // a cell in em
const STEP = 0.0015; // pendulum time per sample, too short to skip a cell
const NEAR = 0.25; // pendulum time within which the pen coming back to a cell is the same stroke
/*
* Swings across and up and down: frequency and phase each, how much faster the
* up and down swing runs down, how many passes, and how far in it winds.
*/
const FIGURES = [
{ fx: 1, px: Math.PI / 2, fy: 1.02, py: 0, lean: 1.15, passes: 9, end: 0.12 },
{ fx: 2.006, px: 0, fy: 1, py: 0, lean: 1, passes: 6, end: 0.15 },
{ fx: 1, px: Math.PI / 4, fy: 0.985, py: 0, lean: 0.9, passes: 8, end: 0.15 },
];
const [DRAW, SHOW, LIFT] = [10, 2.5, 3]; // seconds; the lift runs under the next figure's start
const PERIOD = DRAW + SHOW;
const START = 6; // the first figure, a little over half drawn
interface Stroke {
c: number;
t0: number;
t1: number;
ex: number;
ey: number;
lx: number;
ly: number;
x0: number;
x1: number;
y0: number;
y1: number;
sum: number;
n: number;
len: number;
g?: string;
}
/*
* The mark for one stroke through a cell, from where it came in and left and
* how far it reached each way, all in cells. A stroke that turns back across
* is a bracket, one that turns back up or down a flat mark at its height.
*/
function glyph(s: Stroke): string {
const m = 0.06;
const right = s.x1 - Math.max(s.ex, s.lx) > m, left = Math.min(s.ex, s.lx) - s.x0 > m;
const turnY = s.y1 - Math.max(s.ey, s.ly) > m || Math.min(s.ey, s.ly) - s.y0 > m;
const span = s.y1 - s.y0, mid = s.sum / s.n;
const flat = mid < 0.3 ? "'" : mid < 0.6 ? "-" : mid < 0.82 ? "." : "_";
if ((right || left) && !(right && left) && span * H > 0.25 * (s.x1 - s.x0) * W) return right ? ")" : "(";
if (turnY) return flat;
const dx = (s.lx - s.ex) * W, dy = (s.ly - s.ey) * H;
// A steep line is drawn once a row, in the cell where it crosses the row's middle.
if (Math.abs(dx) < 0.35 * Math.abs(dy)) return s.y0 < 0.5 && s.y1 >= 0.5 ? "|" : "";
if (span < 0.45) return flat;
return dx * dy > 0 ? "\\" : "/";
}
// One figure, as every stroke the pen lays, in order: the cell, when, and its mark.
function sheet(f: (typeof FIGURES)[number], cols: number, rows: number) {
const end = 2 * Math.PI * f.passes;
const n = Math.floor(end / STEP) + 1;
const xs = new Float64Array(n), ys = new Float64Array(n);
let [x0, x1, y0, y1] = [0, 0, 0, 0];
for (let i = 0; i < n; i++) {
const t = i * STEP;
// Friction at the pivots takes the same off each swing, so the passes come in evenly.
const a = 1 - ((1 - f.end) * t) / end;
xs[i] = a * Math.sin(f.fx * t + f.px);
ys[i] = -(a ** f.lean) * Math.sin(f.fy * t + f.py);
[x0, x1, y0, y1] = [Math.min(x0, xs[i]), Math.max(x1, xs[i]), Math.min(y0, ys[i]), Math.max(y1, ys[i])];
}
// Centred in the frame, four fifths of it across and down.
const sx = (0.8 * cols - 1) / (x1 - x0), sy = (0.8 * rows - 0.6) / (y1 - y0);
for (let i = 0; i < n; i++) (xs[i] = cols / 2 + (xs[i] - (x0 + x1) / 2) * sx), (ys[i] = rows / 2 + (ys[i] - (y0 + y1) / 2) * sy);
const strokes: Stroke[] = [];
const open = new Int32Array(cols * rows).fill(-1);
let px = xs[0], py = ys[0];
for (let i = 0; i < n; i++) {
const x = xs[i], y = ys[i], t = i * STEP;
const c = Math.floor(y) * cols + Math.floor(x);
const lx = x - Math.floor(x), ly = y - Math.floor(y);
let s = open[c] >= 0 ? strokes[open[c]] : null;
if (!s || t - s.t1 > NEAR) {
s = { c, t0: t, t1: t, ex: lx, ey: ly, lx, ly, x0: lx, x1: lx, y0: ly, y1: ly, sum: 0, n: 0, len: 0 };
open[c] = strokes.push(s) - 1;
}
s.len += Math.hypot((x - px) * W, (y - py) * H);
[s.t1, s.lx, s.ly, s.sum, s.n] = [t, lx, ly, s.sum + ly, s.n + 1];
[s.x0, s.x1, s.y0, s.y1] = [Math.min(s.x0, lx), Math.max(s.x1, lx), Math.min(s.y0, ly), Math.max(s.y1, ly)];
(px = x), (py = y);
}
// A corner the pen only clipped is left blank.
const keep = strokes.filter((s) => s.len > 0.12 && (s.g = glyph(s)));
const ink: string[] = new Array(cols * rows).fill(" ");
const when = new Float64Array(cols * rows).fill(-1);
keep.forEach((s) => ((ink[s.c] = s.g!), (when[s.c] = s.t0)));
return { end, xs, ys, cells: keep.map((s) => s.c), times: keep.map((s) => s.t0), marks: keep.map((s) => s.g!), ink, when };
}
export default function harmonograph(): Frame {
const { cols, rows } = meta;
const out: string[] = new Array(cols * rows);
const sheets = FIGURES.map((f) => sheet(f, cols, rows));
return (t) => {
const tt = START + t;
const idx = Math.floor(tt / PERIOD);
const local = tt - idx * PERIOD;
const s = sheets[idx % sheets.length];
out.fill(" ");
if (local < LIFT) {
// The last figure, lifted in the order it went down, outer swings first.
const old = sheets[(idx + sheets.length - 1) % sheets.length];
const u = (local / LIFT) * old.end;
for (let c = 0; c < cols * rows; c++) if (old.when[c] > u) out[c] = old.ink[c];
}
const tau = (Math.min(local, DRAW) / DRAW) * s.end;
for (let i = 0; i < s.cells.length && s.times[i] <= tau; i++) out[s.cells[i]] = s.marks[i];
if (local < DRAW) {
const j = Math.min(s.xs.length - 1, Math.floor(tau / STEP));
out[Math.floor(s.ys[j]) * cols + Math.floor(s.xs[j])] = "o";
}
const lines: string[] = [];
for (let r = 0; r < rows; r++) lines.push(out.slice(r * cols, (r + 1) * cols).join(""));
return lines.join("\n");
};
}