dissolve

[github]

words that crumble to dust and condense into the next

type 64×13 20 fps 9.2 kB

use it

html

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

<ascii-art piece="dissolve" options='{"words":["hello","hola","ciao","salut"]}'></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 { dissolve } from "ascii.rest/pieces";

<Ascii piece={dissolve} options={{"words":["hello","hola","ciao","salut"]}} />

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

astro

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

<Ascii piece="dissolve" options={{"words":["hello","hola","ciao","salut"]}} />

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

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, dissolve, {"words":["hello","hola","ciao","salut"]});

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.

dissolve.ts

[js] [github]
/*
 * dissolve: a word in smooth stroked capitals crumbles letter by letter, its dust
 * sinking out of each letter, while the next word's dust gathers onto its strokes.
 */
import type { Frame, Meta } from "../types.ts";

export interface DissolveOptions {
  [key: string]: unknown;
  /** A list of words, or one string of them split at commas and spaces. */
  words: string[] | string;
}

export const meta = {
  name: "dissolve",
  category: "type",
  note: "words that crumble to dust and condense into the next",
  cols: 64,
  rows: 13,
  fps: 20,
  options: { words: ["hello", "hola", "ciao", "salut"] },
} satisfies Meta<DissolveOptions>;

// Capitals as polylines in a 4 by 6 box, y down.
const STROKES: Record<string, number[][]> = {
  A: [[0, 6, 1.6, 0, 2.4, 0, 4, 6], [0.7, 4, 3.3, 4]],
  B: [[0, 0, 0, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3], [0, 0, 2.5, 0, 3.5, 1, 3.5, 2, 2.5, 3]],
  C: [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5]],
  D: [[0, 0, 0, 6, 2.5, 6, 4, 4.5, 4, 1.5, 2.5, 0, 0, 0]],
  E: [[4, 0, 0, 0, 0, 6, 4, 6], [0, 3, 3, 3]],
  F: [[4, 0, 0, 0, 0, 6], [0, 3, 3, 3]],
  G: [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 3, 2, 3]],
  H: [[0, 0, 0, 6], [4, 0, 4, 6], [0, 3, 4, 3]],
  I: [[1, 0, 3, 0], [2, 0, 2, 6], [1, 6, 3, 6]],
  J: [[1.5, 0, 4, 0], [3, 0, 3, 5, 2, 6, 1, 6, 0, 5]],
  K: [[0, 0, 0, 6], [4, 0, 0, 3.6], [1.5, 2.25, 4, 6]],
  L: [[0, 0, 0, 6, 4, 6]],
  M: [[0, 6, 0, 0, 2, 3.5, 4, 0, 4, 6]],
  N: [[0, 6, 0, 0, 4, 6, 4, 0]],
  O: [[1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5, 0, 1, 1, 0]],
  P: [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3]],
  Q: [[1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5, 0, 1, 1, 0], [2.4, 4.4, 4, 6.2]],
  R: [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3], [2.2, 3, 4, 6]],
  S: [[4, 1, 3, 0, 1, 0, 0, 1, 0, 2, 1, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
  T: [[0, 0, 4, 0], [2, 0, 2, 6]],
  U: [[0, 0, 0, 5, 1, 6, 3, 6, 4, 5, 4, 0]],
  V: [[0, 0, 2, 6, 4, 0]],
  W: [[0, 0, 1, 6, 2, 2, 3, 6, 4, 0]],
  X: [[0, 0, 4, 6], [4, 0, 0, 6]],
  Y: [[0, 0, 2, 3], [4, 0, 2, 3, 2, 6]],
  Z: [[0, 0, 4, 0, 0, 6, 4, 6]],
  0: [[1, 0, 2, 0, 3, 1, 3, 5, 2, 6, 1, 6, 0, 5, 0, 1, 1, 0]],
  1: [[0.5, 1, 1.5, 0, 1.5, 6], [0.5, 6, 2.5, 6]],
  2: [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 0, 6, 4, 6]],
  3: [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1.5, 3], [3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
  4: [[3, 6, 3, 0, 0, 4, 4, 4]],
  5: [[4, 0, 0, 0, 0, 3, 3, 3, 4, 4, 4, 5, 3, 6, 0, 6]],
  6: [[3.5, 0, 1.5, 0, 0, 1.5, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3, 1, 3, 0, 4]],
  7: [[0, 0, 4, 0, 1.5, 6]],
  8: [[1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1, 3, 0, 4, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3]],
  9: [[0.5, 6, 2.5, 6, 4, 4.5, 4, 1, 3, 0, 1, 0, 0, 1, 0, 2, 1, 3, 4, 3]],
  ".": [[2, 6, 2, 6]],
  ",": [[2, 5.6, 1.5, 7]],
  "!": [[2, 0, 2, 4], [2, 6, 2, 6]],
  "?": [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 2, 3.5, 2, 4.2], [2, 6, 2, 6]],
  "'": [[2, 0, 2, 1.5]],
  "-": [[1, 3, 3, 3]],
};
const RAMP = " .·░▒▓█";
const HOLD = 1.6, TRANS = 2.6; // seconds each word rests, and the change to the next
const STAGGER = 0.2, JITTER = 0.22; // letter by letter, and cell by cell within one
const ERODE = 0.3, FADE = 0.5, LATER = 1; // a cell wearing away, a cell filling in, the new word's lag
const R = 0.5, RD = 0.6; // stroke radius, a little more on slanted strokes so they hold their weight
const GAP = 4; // columns from one letter's last stroke to the next one's first

const hash = (a: number, b: number) => {
  let h = Math.imul(a, 374761393) + Math.imul(b, 668265263);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};
const ease = (x: number) => (x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x));

// Distance from (x, y) to the segment a-b, less the stroke's radius.
function seg(x: number, y: number, ax: number, ay: number, bx: number, by: number) {
  const dx = bx - ax, dy = by - ay;
  const l = dx * dx + dy * dy;
  const k = l ? Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / l)) : 0;
  return Math.hypot(x - ax - k * dx, y - ay - k * dy) - (dx && dy ? RD : R);
}

export default function dissolve({ words = meta.options.words }: Partial<DissolveOptions> = {}): Frame {
  const { cols, rows } = meta;
  const given = (Array.isArray(words) ? words : String(words).split(/[,\s]+/)).filter(Boolean);
  const list = (given.length ? given : ["hello"]).map((w) => [...String(w).toUpperCase()]);
  const glyph = (ch: string) => STROKES[ch] || (ch === " " ? [] : STROKES["?"]);
  const span = (g: number[][]): [number, number] => {
    const xs = g.flatMap((p) => p.filter((_, i) => i % 2 === 0));
    return xs.length ? [Math.min(...xs), Math.max(...xs)] : [0, 2];
  };
  // One unit is a row tall and two columns wide, smaller if the longest word needs it.
  const wide = (w: string[], u: number) => w.reduce((a, ch) => a + Math.round(2 * u * (span(glyph(ch))[1] - span(glyph(ch))[0])) + Math.max(2, Math.round(GAP * u)), 0) - Math.max(2, Math.round(GAP * u)) + 4 * u * R;
  let unit = Math.min(1, (rows - 2) / (6 + 2 * R));
  while (unit > 0.3 && Math.max(...list.map((w) => wide(w, unit))) > cols - 4) unit -= 0.02;
  const gap = Math.max(2, Math.round(GAP * unit));

  // Bake each word's ink coverage and which letter every column belongs to. The
  // pen advances in whole columns, so every gap between letters is the same.
  const baked = list.map((w) => {
    const cover = new Float32Array(cols * rows);
    const slot = new Int8Array(cols);
    const y0 = rows / 2 - 3 * unit;
    let pen = Math.round((cols - wide(w, unit)) / 2 + 2 * unit * R);
    const centres: number[] = [];
    for (const ch of w) {
      const g = glyph(ch);
      const [lo, hi] = span(g);
      const width = Math.round(2 * unit * (hi - lo));
      centres.push(pen + width / 2);
      for (let r = 0; r < rows; r++) {
        for (let c = Math.max(0, pen - 3); c < Math.min(cols, pen + width + 3); c++) {
          let hit = 0;
          for (let j = 0; j < 3; j++) {
            for (let i = 0; i < 3; i++) {
              const gx = lo + (c + (i + 0.5) / 3 - pen) / (2 * unit), gy = (r + (j + 0.5) / 3 - y0) / unit;
              let d = 9;
              for (const p of g) for (let k = 0; k + 3 < p.length; k += 2) d = Math.min(d, seg(gx, gy, p[k], p[k + 1], p[k + 2], p[k + 3]));
              if (d <= 0) hit++;
            }
          }
          cover[r * cols + c] = Math.max(cover[r * cols + c], hit / 9);
        }
      }
      pen += width + gap;
    }
    for (let c = 0; c < cols; c++) {
      let best = 0;
      centres.forEach((x, i) => Math.abs(x - c) < Math.abs(centres[best] - c) && (best = i));
      slot[c] = best;
    }
    return { cover, slot };
  });

  const period = list.length * (HOLD + TRANS);
  const val = new Float32Array(cols * rows);
  const dust = new Float32Array(cols * rows);
  const splat = (x: number, y: number, d: number) => {
    const c = Math.round(x), r = Math.round(y);
    if (c >= 0 && c < cols && r >= 0 && r < rows) dust[r * cols + c] = Math.max(dust[r * cols + c], d);
  };

  return (t) => {
    const u = (((t + 1) % period) + period) % period;
    const n = Math.floor(u / (HOLD + TRANS));
    const tau = u - n * (HOLD + TRANS) - HOLD;
    const A = baked[n], B = baked[(n + 1) % list.length];
    dust.fill(0);
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        const k = r * cols + c;
        const a = A.cover[k], b = B.cover[k];
        let v = a;
        if (tau > 0) {
          v = 0;
          // The old cell wears thin, then about half of them let go as a grain
          // that falls out of the letter, drifting a little right, and fades.
          if (a > 0) {
            const go = A.slot[c] * STAGGER + hash(k, n) * JITTER;
            const e = (tau - go) / ERODE;
            if (a <= 0.3 || hash(k, n + 7) > 0.5) v = a * (1 - ease(e));
            else if (e < 1) v = a * (1 - 0.5 * Math.max(0, e));
            else {
              const age = tau - go - ERODE, life = 0.5 + 0.35 * hash(k, n + 57);
              if (age < life) {
                const vx = 0.6 + 1.4 * hash(k, n + 31), vy = 0.3 + 0.6 * hash(k, n + 83);
                splat(c + 0.5 + vx * age, r + vy * age + 4 * age * age, 0.5 * (1 - 0.7 * age / life));
              }
            }
          }
          // The new cell's grain, for about half of them, gathers in toward its
          // stroke from a cell or two away, and the cell fills in around it.
          if (b > 0) {
            const land = LATER + B.slot[c] * STAGGER + hash(k, n + 977) * JITTER;
            if (tau >= land) v = Math.max(v, b * (b > 0.3 ? 0.4 + 0.6 * ease((tau - land) / FADE) : ease((tau - land) / FADE)));
            else if (b > 0.3 && hash(k, n + 9) < 0.55) {
              const life = 0.45 + 0.3 * hash(k, n + 211), f = (land - tau) / life;
              if (f < 1) {
                const q = f * f, th = 6.283 * hash(k, n + 401), d = 0.8 + 1.2 * hash(k, n + 613);
                splat(c + 1.8 * d * Math.cos(th) * q, r + d * Math.sin(th) * q, 0.17 + 0.3 * (1 - f));
              }
            }
          }
        }
        val[k] = v;
      }
    }
    const lines: string[] = [];
    for (let r = 0; r < rows; r++) {
      let line = "";
      for (let c = 0; c < cols; c++) line += RAMP[Math.round(Math.max(val[r * cols + c], dust[r * cols + c]) * (RAMP.length - 1))];
      lines.push(line);
    }
    return lines.join("\n");
  };
}