hilbert curve
[github]a closed loop of two hilbert curves, with two lit runners
generative 65×17 20 fps 5.5 kB
╭───────╮ ╭───────╮ ╭───────────╮ ╭───────╮ ╭───────╮ │ │ │ │ │ │ │ │ │ │ ╰───╮ ╰───╯ ╭───╯ ╰╍━━┓ ╭───╯ ╰───╮ ╰───╯ ╭───╯ │ │ ┃ │ │ │ ╭───╯ ╭───╮ │ ┏━━━┓ ┃ │ ╭───╮ │ ╭───╮ ╰───╮ │ │ │ │ ┃ ┃ ┃ │ │ │ │ │ │ │ ╰───────╯ │ ╰━━━┛ ┗━━━┛ ╰───╯ ╰───╯ │ ╰───────╯ │ │ ╭───────╮ │ ╭───╮ ╭───╮ ┏━━━┓ ┏━━━╮ │ ╭───────╮ │ │ │ │ │ │ │ ┃ ┃ ┃ │ │ │ │ ╰───╮ ╰───╯ │ ╰───╯ │ ┃ ┗━━━┛ │ ╰───╯ ╭───╯ │ │ │ ┃ │ │ ╭───╯ ╭───╮ ╰───╮ ╭───╯ ┗━━╍╮ ╭───╯ ╭───╮ ╰───╮ │ │ │ │ │ │ │ │ │ │ ╰───────╯ ╰───────╯ ╰───────────╯ ╰───────╯ ╰───────╯ ╭───────╮ ╭───────╮ ╭───────────╮ ╭───────╮ ╭───────╮ │ │ │ │ │ │ │ │ │ │ ╰───╮ ╰───╯ ╭───╯ ╰╍━━┓ ╭───╯ ╰───╮ ╰───╯ ╭───╯ │ │ ┃ │ │ │ ╭───╯ ╭───╮ │ ┏━━━┓ ┃ │ ╭───╮ │ ╭───╮ ╰───╮ │ │ │ │ ┃ ┃ ┃ │ │ │ │ │ │ │ ╰───────╯ │ ╰━━━┛ ┗━━━┛ ╰───╯ ╰───╯ │ ╰───────╯ │ │ ╭───────╮ │ ╭───╮ ╭───╮ ┏━━━┓ ┏━━━╮ │ ╭───────╮ │ │ │ │ │ │ │ ┃ ┃ ┃ │ │ │ │ ╰───╮ ╰───╯ │ ╰───╯ │ ┃ ┗━━━┛ │ ╰───╯ ╭───╯ │ │ │ ┃ │ │ ╭───╯ ╭───╮ ╰───╮ ╭───╯ ┗━━╍╮ ╭───╯ ╭───╮ ╰───╮ │ │ │ │ │ │ │ │ │ │ ╰───────╯ ╰───────╯ ╰───────────╯ ╰───────╯ ╰───────╯
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="hilbert-curve"></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 { hilbertCurve } from "ascii.rest/pieces";
<Ascii piece={hilbertCurve} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="hilbert-curve", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="hilbert-curve" />
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 { hilbertCurve } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, hilbertCurve); 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.
/*
* hilbert curve: two Hilbert curves, each through every point of an 8 by 8
* grid, joined end to end into one closed loop. Two lit runners circle it.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "hilbert curve",
category: "generative",
note: "a closed loop of two hilbert curves, with two lit runners",
cols: 65,
rows: 17,
fps: 20,
} satisfies Meta;
const GW = 16, GH = 8; // grid points across and down
const SX = 4, SY = 2; // characters from one point to the next: a square step
const OX = 2, OY = 1; // the margin round the drawing
const LEN = 30; // a runner's heavy stretch, in column widths (a row is two)
const TAIL = 4; // and its dashed tail behind that
const SPEED = 16; // column widths a second
// Jakub Cerveny's generalized Hilbert curve (gilbert2d), for any w by h grid.
function gilbert(pts: [number, number][], x: number, y: number, ax: number, ay: number, bx: number, by: number): void {
const w = Math.abs(ax + ay), h = Math.abs(bx + by);
const [dax, day, dbx, dby] = [ax, ay, bx, by].map(Math.sign);
if (h === 1 || w === 1) {
const [dx, dy, n] = h === 1 ? [dax, day, w] : [dbx, dby, h];
for (let i = 0; i < n; i++, x += dx, y += dy) pts.push([x, y]);
return;
}
let [ax2, ay2, bx2, by2] = [ax, ay, bx, by].map((v) => Math.floor(v / 2));
if (2 * w > 3 * h) {
if (Math.abs(ax2 + ay2) % 2 && w > 2) (ax2 += dax), (ay2 += day);
gilbert(pts, x, y, ax2, ay2, bx, by);
gilbert(pts, x + ax2, y + ay2, ax - ax2, ay - ay2, bx, by);
} else {
if (Math.abs(bx2 + by2) % 2 && h > 2) (bx2 += dbx), (by2 += dby);
gilbert(pts, x, y, bx2, by2, ax2, ay2);
gilbert(pts, x + bx2, y + by2, ax, ay, bx - bx2, by - by2);
gilbert(pts, x + (ax - dax) + (bx2 - dbx), y + (ay - day) + (by2 - dby), -bx2, -by2, -(ax - ax2), -(ay - ay2));
}
}
// Arms: left 1, right 2, up 4, down 8. For each pair of arms, the glyph with
// neither, the first, the second or both drawn heavy.
const L = 1, R = 2, U = 4, D = 8;
const GLYPHS: Record<number, string[]> = {
[L | R]: ["─", "╾", "╼", "━"],
[U | D]: ["│", "╿", "╽", "┃"],
[R | D]: ["╭", "┍", "┎", "┏"],
[L | D]: ["╮", "┑", "┒", "┓"],
[R | U]: ["╰", "┕", "┖", "┗"],
[L | U]: ["╯", "┙", "┚", "┛"],
};
const FIRST: Record<number, number> = { [L | R]: L, [U | D]: U, [R | D]: R, [L | D]: L, [R | U]: R, [L | U]: L };
const BACK: Record<number, number> = { [L]: R, [R]: L, [U]: D, [D]: U };
const DASH: Record<number, string> = { [L | R]: "╍", [U | D]: "╏" }; // a straight cell in a tail
// A cell of the loop: where it is, the arms it comes in and leaves by, where
// along the loop each arm's middle is, and for a straight cell the next corner's.
interface Cell {
r: number;
c: number;
into: number;
out: number;
a?: number;
b?: number;
corner?: number;
}
export default function hilbertCurve(): Frame {
const { cols, rows } = meta;
// The left half runs down its right edge's side and the right half back up
// its left edge's, so each ends one step from where the other starts.
const pts: [number, number][] = [];
gilbert(pts, GW / 2 - 1, 0, 0, GH, -GW / 2, 0);
gilbert(pts, GW / 2, GH - 1, 0, -GH, GW / 2, 0);
// Every cell the loop passes through, in order, with the arm it comes in by
// and the arm it leaves by, and where along the loop each arm's middle is.
const cells: Cell[] = [];
const dirOf = (dx: number, dy: number) => (dx < 0 ? L : dx > 0 ? R : dy < 0 ? U : D);
const n = pts.length;
for (let k = 0; k < n; k++) {
const [x, y] = pts[k], next = pts[(k + 1) % n], prev = pts[(k + n - 1) % n];
const out = dirOf(next[0] - x, next[1] - y);
cells.push({ r: OY + y * SY, c: OX + x * SX, into: dirOf(prev[0] - x, prev[1] - y), out });
const [dx, dy] = out === L ? [-1, 0] : out === R ? [1, 0] : out === U ? [0, -1] : [0, 1];
for (let s = 1; s < (out & (L | R) ? SX : SY); s++)
cells.push({ r: OY + y * SY + dy * s, c: OX + x * SX + dx * s, into: BACK[out], out });
}
let d = 0;
const half = (arm: number) => (arm & (L | R) ? 0.5 : 1);
for (const cell of cells) {
cell.a = d + half(cell.into) / 2;
d += half(cell.into);
cell.b = d + half(cell.out) / 2;
d += half(cell.out);
}
const LAP = d;
// For each straight cell, where the next corner ahead of it leaves by.
let corner = 0;
for (let k = 2 * cells.length - 1; k >= 0; k--) {
const cell = cells[k % cells.length];
if (!DASH[cell.into | cell.out]) corner = cell.b!;
else cell.corner = corner;
}
// How far behind the nearer runner a point is: 0 at a head, up to half a lap.
const behind = (at: number, head: number) => (((head - at) % (LAP / 2)) + LAP / 2) % (LAP / 2);
const tail = (b: number) => b >= LEN && b < LEN + TAIL;
const grid = Array.from({ length: rows }, () => Array<string>(cols).fill(" "));
return (t) => {
const head = 40 + t * SPEED;
for (const cell of cells) {
const mask = cell.into | cell.out;
const bi = behind(cell.a!, head), bo = behind(cell.b!, head);
const hi = bi < LEN, ho = bo < LEN;
// Just behind a runner, straight cells go dashed, a weight between heavy
// and light. Corners have no dashed form, so a tail stops at one.
if (DASH[mask] && tail(bi) && tail(bo) && !tail(behind(cell.corner!, head))) {
grid[cell.r][cell.c] = DASH[mask];
continue;
}
const [first, second] = FIRST[mask] === cell.into ? [hi, ho] : [ho, hi];
grid[cell.r][cell.c] = GLYPHS[mask][(first ? 1 : 0) + (second ? 2 : 0)];
}
return grid.map((row) => row.join("")).join("\n");
};
}