moon phases

[github]

the moon waxing and waning, its maria darker in the light

space 46×23 15 fps 6.1 kB

use it

html

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

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

<Ascii piece={moonPhases} />

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

astro

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

<Ascii piece="moon-phases" />

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

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

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.

moon-phases.ts

[js] [github]
/*
 * moon-phases: the moon through one lunar month. The sun swings round
 * behind it, so the terminator sweeps across the sphere and back again.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "moon phases",
  category: "space",
  note: "the moon waxing and waning, its maria darker in the light",
  cols: 46,
  rows: 23,
  fps: 15,
} satisfies Meta;

const RAMP = " .:-=+*#%@";
const MONTH = 24; // seconds for new moon to new moon
const START = 0.95; // phase at t = 0, in radians past new moon: a young crescent
const SS = 3; // samples per cell each way

// The near side's seas as ellipses on the disk, x east and y north, radius 1:
// centre, radii, how much darker, and a tilt in radians.
const MARIA: [number, number, number, number, number, number][] = [
  [-0.62, 0.08, 0.28, 0.5, 0.62, 0], // Oceanus Procellarum
  [-0.3, 0.45, 0.27, 0.21, 0.7, 0], // Imbrium
  [-0.3, 0.71, 0.25, 0.1, 0.55, 0.25], // Frigoris, west
  [0.14, 0.79, 0.22, 0.085, 0.5, -0.2], // Frigoris, east
  [0.19, 0.42, 0.15, 0.14, 0.7, 0], // Serenitatis
  [0.38, 0.12, 0.21, 0.16, 0.7, 0], // Tranquillitatis
  [0.05, 0.25, 0.09, 0.06, 0.5, 0], // Vaporum
  [-0.28, 0.08, 0.12, 0.09, 0.4, 0], // Insularum
  [0.75, 0.3, 0.1, 0.14, 0.72, 0], // Crisium
  [0.62, -0.12, 0.12, 0.2, 0.62, 0], // Fecunditatis
  [0.42, -0.28, 0.09, 0.09, 0.62, 0], // Nectaris
  [-0.2, -0.34, 0.19, 0.14, 0.6, 0], // Nubium
  [-0.52, -0.43, 0.09, 0.1, 0.65, 0], // Humorum
  [-0.88, -0.06, 0.05, 0.08, 0.5, 0], // Grimaldi
];
// Young bright craters: Tycho, Copernicus, Kepler, Aristarchus.
const CRATERS: [number, number, number, number][] = [[-0.15, -0.72, 0.09, 0.4],[-0.33, 0.17, 0.05, 0.2], [-0.6, 0.14, 0.035, 0.15], [-0.75, 0.38, 0.03, 0.3]];
// Tycho's rays: direction and length.
const RAYS: [number, number][] = [[1.75, 0.55], [1.3, 0.4], [2.3, 0.35], [0.4, 0.3], [3.1, 0.3], [-0.6, 0.22], [4.1, 0.2]];
const TYCHO = CRATERS[0];

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

// How bright the ground is at a point of the disk, before any light falls on it.
function albedo(x: number, y: number) {
  let alb = 1;
  for (const [mx, my, rx, ry, k, rot] of MARIA) {
    const c = Math.cos(rot), s = Math.sin(rot), dx = x - mx, dy = y - my;
    alb -= k * (1 - smooth(0.55, 1, Math.hypot((dx * c + dy * s) / rx, (dy * c - dx * s) / ry)));
  }
  alb = Math.max(0.3, alb);
  for (const [mx, my, rr, k] of CRATERS) alb += k * (1 - smooth(0.3, 1, Math.hypot(x - mx, y - my) / rr));
  const dx = x - TYCHO[0], dy = y - TYCHO[1], d = Math.hypot(dx, dy), a = Math.atan2(dy, dx);
  for (const [at, len] of RAYS) {
    const off = Math.abs(Math.atan2(Math.sin(a - at), Math.cos(a - at)));
    if (d > TYCHO[2] && d < len && off < 1) alb += 0.3 * (1 - d / len) * Math.exp(-(((off * d) / 0.03) ** 2));
  }
  return Math.min(1.35, alb);
}

export default function moonPhases(): Frame {
  const { cols, rows } = meta;
  const cx = cols / 2, cy = rows / 2;
  const R = rows / 2 - 1; // radius in rows; it spans twice as many columns
  const rand = mulberry32(7);
  const disk = (c: number, r: number) => Math.hypot((c + 0.5 - cx) / (2 * R), (cy - r - 0.5) / R);

  // Every sample inside the disk keeps its normal and its albedo, and every
  // cell its mean albedo, which is all that shows in earthshine.
  const samples = Array.from({ length: cols * rows }, (): number[] => []);
  const ground = new Float32Array(cols * rows);
  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++) {
      const s = samples[r * cols + c];
      for (let j = 0; j < SS; j++)
        for (let i = 0; i < SS; i++) {
          const x = (c + (i + 0.5) / SS - cx) / (2 * R), y = (cy - r - (j + 0.5) / SS) / R;
          const d = x * x + y * y;
          if (d < 1) s.push(x, y, Math.sqrt(1 - d), albedo(x, y));
        }
      for (let i = 3; i < s.length; i += 4) ground[r * cols + c] += s[i] / (s.length / 4);
    }
  const full = 4 * SS * SS;
  const inside = (c: number, r: number) => c >= 0 && c < cols && r >= 0 && r < rows && samples[r * cols + c].length * 2 >= full;
  // A cell on the rim, where the night side is drawn as a faint outline.
  const rim = new Uint8Array(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;
      if (inside(c, r)) rim[k] = inside(c - 1, r) && inside(c + 1, r) && inside(c, r - 1) && inside(c, r + 1) ? 0 : 1;
      else if (disk(c, r) > 1.12 && rand() < 0.022) stars[k] = rand() < 0.25 ? 2 : 1;
    }
  const n = RAMP.length - 1;

  return (t, { paper = false } = {}) => {
    const ph = START + (2 * Math.PI * (t % MONTH)) / MONTH;
    const m = Math.hypot(Math.sin(ph), 0.12, Math.cos(ph));
    const lx = Math.sin(ph) / m, ly = 0.12 / m, lz = -Math.cos(ph) / m;
    // Near new moon the earth is nearly full in the moon's sky, and its light
    // shows the night side's highlands faintly.
    const earthshine = smooth(0, 0.5, Math.cos(ph));
    let out = "";
    for (let r = 0; r < rows; r++) {
      if (r) out += "\n";
      for (let c = 0; c < cols; c++) {
        const k = r * cols + c, s = samples[k];
        if (!inside(c, r)) {
          out += stars[k] ? (stars[k] === 2 ? "+" : ".") : " ";
          continue;
        }
        // Mostly Lommel-Seeliger, which keeps the full moon nearly flat, with
        // a little Lambert so the ball still reads as round.
        let sum = 0;
        for (let i = 0; i < s.length; i += 4) {
          const mu0 = s[i] * lx + s[i + 1] * ly + s[i + 2] * lz;
          if (mu0 <= 0) continue;
          sum += s[i + 3] * (0.3 * mu0 + (0.7 * 2 * mu0) / (mu0 + s[i + 2]));
        }
        const v = Math.min(1, sum / (s.length / 4));
        let i = v < 0.015 ? (rim[k] || earthshine * ground[k] > 0.72 ? 1 : 0) : 1 + Math.round(v * (n - 1));
        if (paper) i = n - i;
        out += RAMP[i];
      }
    }
    return out;
  };
}