eclipse

[github]

the moon slides over the sun; diamond ring, then corona

space 64×27 20 fps 6.9 kB

use it

html

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

<ascii-art piece="eclipse"></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 { eclipse } from "ascii.rest/pieces";

<Ascii piece={eclipse} />

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

astro

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

<Ascii piece="eclipse" />

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

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, eclipse);

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.

eclipse.ts

[js] [github]
/*
 * eclipse: a total solar eclipse. The new moon slides across the sun; the
 * last bead of sunlight flares into a diamond ring, the corona opens round
 * the black disk for totality, and the ring flashes again on the far side.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "eclipse",
  category: "space",
  note: "the moon slides over the sun; diamond ring, then corona",
  cols: 64,
  rows: 27,
  fps: 20,
} satisfies Meta;

// The sun and corona are light, not shade, so the ramp keeps its sense on
// paper: there the sun is a disk of ink and the corona a halo of it.
const RAMP = " .:-=+*#%@";
const R = 6; // the sun's radius in rows; it spans twice as many columns
const MOON = 1.06; // the moon's radius, in suns
const LOOP = 18; // seconds from one crossing to the next
const MID = 5.3; // the middle of totality, in seconds from t = 0
const TILT = 0.2; // the moon's path, radians below level
const MISS = 0.005; // how far off centre it passes, in suns
const SS = 3; // samples per cell each way for the sun's edge

// Streamers: angle, width at the limb in radians, how fast they fade outward
// in suns, and strength. They narrow as they go, so each ends in a point.
const STREAMERS = [[0.1, 0.42, 0.44, 0.85], [3.28, 0.45, 0.44, 0.8],[0.85, 0.15, 0.5, 0.6], [2.35, 0.16, 0.48, 0.6], [-0.6, 0.15, 0.45, 0.55], [3.9, 0.14, 0.42, 0.5]];
// Prominences: angle and size, small loops of gas just off the limb that the
// moon covers and uncovers as it passes.
const PROMS = [[2.9, 0.06], [-0.9, 0.05], [0.5, 0.04]];

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;
  };
}

const smooth = (a: number, b: number, x: number) => {
  const u = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return u * u * (3 - 2 * u);
};
const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a));

export default function eclipse(): Frame {
  const { cols, rows } = meta;
  const n = RAMP.length - 1;
  const rand = mulberry32(11);
  const X = (c: number) => (c - cols / 2) / (2 * R), Y = (r: number) => (rows / 2 - r) / R;
  // The moon's limb is ragged with valleys and peaks: Baily's beads.
  const limb = Array.from({ length: 5 }, (_, k) => [3 + 4 * k + Math.floor(rand() * 3), rand() * 6.283, 0.006 / (1 + k * 0.4)]);
  const moonR = (a: number) => MOON + limb.reduce((s, [k, p, h]) => s + h * Math.sin(k * a + p), 0);

  // The corona's shape never changes, only how much of it shows: a bright
  // inner ring, a soft halo, the streamers and fine plumes at the poles.
  const corona = new Float32Array(cols * rows);
  const ring = new Float32Array(cols * rows); // the inner ring, which shimmers
  const angle = new Float32Array(cols * rows);
  const stars = new Uint8Array(cols * rows);
  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++) {
      const k = r * cols + c, x = X(c + 0.5), y = Y(r + 0.5), d = Math.hypot(x, y), a = Math.atan2(y, x);
      angle[k] = a;
      if (d < 1) continue;
      let s = 0;
      for (const [at, w, fall, amp] of STREAMERS) {
        const off = wrap(a - at) / (w / d ** 1.7);
        s += amp * Math.exp(-off * off) * Math.exp(-(d - 1) / fall);
      }
      const pole = Math.exp(-(((Math.abs(a) - Math.PI / 2) / 0.4) ** 2)) * (0.5 + 0.5 * Math.cos(26 * a));
      ring[k] = Math.exp(-(d - 1) / 0.07);
      for (const [at, size] of PROMS) {
        const px = Math.cos(at) * (1 + size), py = Math.sin(at) * (1 + size);
        ring[k] += 1.4 * Math.exp(-(((x - px) / size) ** 2 + ((y - py) / (size * 0.8)) ** 2));
      }
      corona[k] = 0.25 * Math.exp(-(d - 1) / 0.16) + s + 0.3 * pole * Math.exp(-(d - 1) / 0.2);
      if (d > 1.7 && corona[k] < 0.02 && rand() < 0.014) stars[k] = 1;
    }
  stars[2 * cols + Math.round(cols / 2 - 2.4 * 2 * R)] = 2; // a planet

  return (t) => {
    const tau = ((((t - MID + LOOP / 2) % LOOP) + LOOP) % LOOP) - LOOP / 2;
    const d = 0.035 * tau + 0.00515 * tau ** 3; // slow near the middle, fast at the ends
    const mx = d * Math.cos(TILT) + MISS * Math.sin(TILT), my = -d * Math.sin(TILT) + MISS * Math.cos(TILT);
    const off = Math.hypot(mx, my);
    const gap = off + 1 - MOON; // the widest sliver of sun left, in suns; below 0 is totality
    const show = 1 - smooth(0, 0.07, gap); // the corona shows as the sunlight goes
    const flare = smooth(0, -0.03, gap);
    const bead = smooth(-0.004, 0.01, gap) * (1 - smooth(0.04, 0.09, gap));
    // The corona brightens as the eyes adapt through totality.
    const glow = 0.75 + 0.45 * smooth(0, -0.055, gap);
    const out: string[] = new Array(cols * rows);
    for (let r = 0; r < rows; r++)
      for (let c = 0; c < cols; c++) {
        const k = r * cols + c;
        let lit = 0, moon = 0;
        for (let j = 0; j < SS; j++)
          for (let i = 0; i < SS; i++) {
            const x = X(c + (i + 0.5) / SS), y = Y(r + (j + 0.5) / SS);
            const dx = x - mx, dy = y - my, dm = Math.hypot(dx, dy);
            const inMoon = dm < MOON + 0.02 && (dm < MOON - 0.02 || dm < moonR(Math.atan2(dy, dx)));
            if (inMoon) moon++;
            else {
              const q = 1 - x * x - y * y;
              if (q > 0) lit += 0.72 + 0.28 * Math.sqrt(q);
            }
          }
        lit /= SS * SS;
        let i = lit > 0 ? Math.round(Math.max(lit ** 0.7, 0.2) * n) : 0;
        if (moon < SS * SS && corona[k] + ring[k] > 0) {
          const a = angle[k];
          const shimmer = 1 + 0.25 * Math.sin(9 * a + 1.9 * t) * Math.sin(5 * a - 1.2 * t);
          const cv = (corona[k] + 1.3 * ring[k] * shimmer) * glow;
          i = Math.max(i, Math.min(n, Math.floor(Math.min(1, cv * show) ** 0.8 * (1 - moon / (SS * SS)) * n + 0.1)));
        }
        if (i === 0 && stars[k] && flare > 0.4) out[k] = stars[k] === 2 ? "*" : ".";
        // Before totality the moon's rim shows faintly against the sky.
        else if (i === 0 && moon > 0 && moon < SS * SS && show < 0.5) out[k] = ".";
        else out[k] = RAMP[i];
      }
    // The diamond: one bead of sun on the limb, too bright for its cell, with
    // a short flare either side and above and below.
    if (bead > 0.05) {
      const bx = cols / 2 - (mx / off) * 2 * R, by = rows / 2 + (my / off) * R;
      const put = (dc: number, dr: number, ch: string) => {
        const c = Math.floor(bx + dc), r = Math.floor(by + dr);
        if (c >= 0 && c < cols && r >= 0 && r < rows) out[r * cols + c] = ch;
      };
      const arm = bead > 0.5 ? 2 : 1;
      for (let s = 1; s <= arm; s++) {
        const ch = s === arm ? "-" : "=";
        put(s, 0, ch);
        put(-s, 0, ch);
      }
      if (bead > 0.4) {
        put(0, -1, bead > 0.75 ? "|" : ":");
        put(0, 1, bead > 0.75 ? "|" : ":");
      }
      put(0, 0, "@");
    }
    const lines: string[] = [];
    for (let r = 0; r < rows; r++) lines.push(out.slice(r * cols, (r + 1) * cols).join(""));
    return lines.join("\n");
  };
}