flag

[github]

a striped flag rippling on a pole, folds catching the light

physics 62×21 30 fps 5.0 kB

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="flag" options='{"stripes":5}'></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 { flag } from "ascii.rest/pieces";

<Ascii piece={flag} options={{"stripes":5}} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="flag", to fetch the piece only when it mounts.

astro

---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---

<Ascii piece="flag" options={{"stripes":5}} />

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 { flag } from "ascii.rest/pieces";

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, flag, {"stripes":5});

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.

flag.ts

[js] [github]
/*
 * flag: a striped flag on a pole in a steady wind. Folds run from the pole to the free
 * edge and grow as they go; faces turned to the light are bright, the far sides of folds dark.
 */
import type { Frame, Meta } from "../types.ts";

export interface FlagOptions {
  [key: string]: unknown;
  stripes: number;
}

export const meta = {
  name: "flag",
  category: "physics",
  note: "a striped flag rippling on a pole, folds catching the light",
  cols: 62,
  rows: 21,
  fps: 30,
  options: { stripes: 5 },
} satisfies Meta<FlagOptions>;

const RAMP = " .:-=+*#%@";
const RISE = "▁▂▃▄▅▆▇"; // a top hem part way down a cell
const LOOP = 12; // seconds; every rate below fits a whole number of times
const WAVES = 2.3; // folds along the cloth
const RATE = 17 / LOOP; // folds passing a point each second
const SKEW = 1.4; // radians the folds lag from the top hem to the bottom one, so they run on a slant
const LONG = 29; // the cloth's length, in rows (a column is half a row)
const HIGH = 13; // its height at the pole, in rows
const SWING = 4; // how far the free edge billows toward and away from us, in rows
const STRIPS = 300;

export default function flag({ stripes = 5 }: Partial<FlagOptions> = {}): Frame {
  const { cols, rows } = meta;
  const POLE = 4; // the pole's column
  const TOP = 2.5; // the top hem at the pole, in rows
  const bands = Math.max(2, Math.round(stripes));
  const xs = new Float64Array((STRIPS + 1) * rows); // where each strip crosses each row
  const lit = new Float32Array(cols * rows); // summed brightness of the strips landing in a cell
  const hits = new Uint16Array(cols * rows);
  const odd = new Uint16Array(cols * rows); // how many of them fell in a dark stripe
  const cover = new Float32Array(cols * rows); // how much of the cell's height the cloth covers
  const out: string[] = new Array(cols * rows);

  return (t, { paper = false } = {}) => {
    const gust = 0.85 + 0.15 * Math.sin((2 * Math.PI * t) / LOOP);
    const ph = (u: number, v: number) => 2 * Math.PI * (WAVES * u - RATE * t) - SKEW * v;
    // How far the cloth stands out of its plane, and its slope along the length.
    const z = (u: number, v: number) => gust * SWING * u * Math.sin(ph(u, v));
    const dz = (u: number, v: number) => (gust * SWING * (Math.sin(ph(u, v)) + u * 2 * Math.PI * WAVES * Math.cos(ph(u, v)))) / LONG;
    // The cloth can't stretch, so a strip turned away from us takes less room across the frame.
    for (let r = 0; r < rows; r++) {
      const v = Math.min(1, Math.max(0, (r + 0.5 - TOP) / HIGH));
      xs[r] = POLE + 1;
      for (let i = 1; i <= STRIPS; i++) {
        const w = (2 * LONG) / STRIPS / Math.hypot(1, dz((i - 0.5) / STRIPS, v));
        xs[i * rows + r] = xs[(i - 1) * rows + r] + w;
      }
    }
    lit.fill(0), hits.fill(0), odd.fill(0), cover.fill(0);
    for (let i = 0; i <= STRIPS; i++) {
      const u = i / STRIPS;
      // Nearer parts look taller, the hems rise and fall with each fold, and the free end droops.
      const zm = z(u, 0.5);
      const h = HIGH * (1 + 0.04 * zm) - 0.8 * u;
      const top = TOP + 1.2 * u * u + 0.8 * u * gust * Math.sin(ph(u, 0.5) + 0.8) - (h - HIGH) / 2;
      for (let r = Math.max(0, Math.floor(top)); r < Math.min(rows, top + h); r++) {
        const c = Math.floor(xs[i * rows + r]);
        if (c >= cols) continue;
        const k = r * cols + c;
        const v = Math.min(1, Math.max(0, (r + 0.5 - top) / h));
        // Light comes from the left and in front, so a face turned toward the pole is lit.
        const s = dz(u, v);
        lit[k] += Math.max(0, Math.min(1, (0.95 * s + 0.65) / Math.hypot(1, s) + 0.1));
        hits[k]++;
        odd[k] += Math.floor(v * bands * 0.9999) % 2;
        cover[k] = Math.max(cover[k], Math.min(r + 1, top + h) - Math.max(r, top));
      }
    }
    out.fill(" ");
    for (let k = 0; k < cols * rows; k++) {
      if (!hits[k]) continue;
      const f = cover[k];
      const below = k + cols < cols * rows && cover[k + cols] > 0.94;
      // A hem crossing the cell: a block filled to where the cloth reaches, from below or above.
      if (f < 0.8 && below) {
        out[k] = RISE[Math.min(6, Math.floor(f * 8))];
        continue;
      }
      if (f < 0.7 && !below) {
        out[k] = f < 0.3 ? "▔" : "▀";
        continue;
      }
      // The light stripes take the top of the ramp and the dark ones the bottom, so folds show in both.
      const l = lit[k] / hits[k];
      const b = odd[k] * 2 > hits[k] ? 0.1 + 0.34 * l : 0.52 + 0.48 * l;
      const g = Math.max(1, Math.min(9, Math.round(b * 9)));
      out[k] = RAMP[paper ? 10 - g : g];
    }
    // The pole, its cap, and the ground it stands in.
    out[Math.floor(TOP - 1) * cols + POLE] = "o";
    for (let r = Math.floor(TOP); r < rows - 1; r++) out[r * cols + POLE] = "┃";
    for (let c = POLE - 3; c <= POLE + 3; c++) out[(rows - 1) * cols + c] = c === POLE ? "┸" : "▁";
    const lines: string[] = [];
    for (let r = 0; r < rows; r++) lines.push(out.slice(r * cols, (r + 1) * cols).join(""));
    return lines.join("\n");
  };
}