sunrise

[github]

the sun lifting off a sea horizon, its path glittering

nature 60×20 15 fps 5.9 kB

use it

html

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

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

<Ascii piece={sunrise} />

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

astro

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

<Ascii piece="sunrise" />

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

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

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.

sunrise.ts

[js] [github]
/*
 * sunrise: the sun coming up out of the sea at dawn. The glow along the
 * horizon gathers into bands, the sun climbs out of it and sinks back as dusk,
 * and its path glitters on the water.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "sunrise",
  category: "nature",
  note: "the sun lifting off a sea horizon, its path glittering",
  cols: 60,
  rows: 20,
  fps: 15,
} satisfies Meta;

// The sun, its glow and its glints are light, not shade, so they keep their
// sense on paper and print as a negative, like eclipse.
const SKY = " .·:-="; // the glow bands, faint to strong
const LOOP = 48; // seconds, from one dawn to the next
const DAY = 0.85; // the part of the loop the sun is up
const HZ = 12; // the horizon row
const R = 2.6; // the sun's radius, in rows
const TOP = 6.6; // the sun's highest, in rows above the horizon
const CX = 30, WX = 8; // the middle of its arc and how far it travels each way
const START = 2.2; // seconds into the loop at t = 0, the sun a third up
const GAP = 0.32; // the dark rim between the disc and its corona, in rows

const hash = (a: number, b: number, c: number) => {
  let h = Math.imul(a, 0x27d4eb2d) ^ Math.imul(b, 0x165667b1) ^ Math.imul(c, 0x2545f491);
  h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
  h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};
const clamp = (v: number) => Math.max(0, Math.min(1, v));
const smooth = (a: number, b: number, v: number) => {
  const x = clamp((v - a) / (b - a));
  return x * x * (3 - 2 * x);
};
// Value noise along x for row r, sliding with time.
const noise = (x: number, r: number, z: number) => {
  const i = Math.floor(x), j = Math.floor(z);
  const u = smooth(0, 1, x - i), v = smooth(0, 1, z - j);
  const n = (a: number, b: number) => hash(i + a, r, j + b);
  return (n(0, 0) * (1 - u) + n(1, 0) * u) * (1 - v) + (n(0, 1) * (1 - u) + n(1, 1) * u) * v;
};

// Where the sun is a fraction s through the loop: up in an arc for the day,
// then a short dip under the sea, its speed matched where the two meet.
const sun = (s: number): [number, number] => {
  if (s < DAY) {
    const p = Math.PI * (s / DAY);
    return [CX - WX * Math.cos(p), -R + (TOP + R) * Math.sin(p)];
  }
  const q = Math.PI * ((s - DAY) / (1 - DAY));
  return [CX + WX * Math.cos(q), -R - (TOP + R) * ((1 - DAY) / DAY) * Math.sin(q)];
};

export default function sunrise(): Frame {
  const { cols, rows } = meta;
  const g = Array.from({ length: rows }, () => new Array<string>(cols));
  const stars: [number, number, number][] = [];
  for (let i = 0; i < 22; i++) stars.push([Math.floor(hash(i, 1, 7) * cols), Math.floor(hash(i, 2, 7) * 7), hash(i, 3, 7)]);

  return (t) => {
    const u = (((t + START) % LOOP) + LOOP) % LOOP;
    const [sx, h] = sun(u / LOOP);
    const sy = HZ - h;
    // The glow is broad and dim before dawn, strongest as the sun breaks the
    // sea, and draws down into tighter bands as it climbs.
    const glow = 0.72 + 0.28 * smooth(-4, 0, h) - 0.25 * smooth(1, TOP, h);
    const height = 3.8 - 1.9 * smooth(0, TOP, h);
    const night = 1 - smooth(-3, 3, h);
    const lit = clamp((h + R) / (2 * R)); // how much of the disc is above the sea

    for (let r = 0; r < rows; r++) g[r].fill(" ");
    for (const [c, r, p] of stars) {
      const tw = 0.5 + 0.5 * Math.sin(t * (1.1 + p) + p * 40);
      if ((0.25 + night) * tw > 0.6 + r * 0.04) g[r][c] = p < 0.15 ? "+" : p < 0.55 ? "·" : ".";
    }

    for (let r = 0; r < HZ; r++) {
      const y = r + 0.5;
      for (let c = 0; c < cols; c++) {
        const x = c + 0.5;
        // The disc, sampled 3 by 4 in the cell so the horizon cuts it flat; a
        // cell at least half covered is sun, and one that comes near it is
        // left dark, so the disc keeps a clean edge.
        let cov = 0, near = 0;
        for (let i = 0; i < 4; i++)
          for (let j = 0; j < 3; j++) {
            const yy = r + (i + 0.5) / 4;
            const e = Math.hypot((c + (j + 0.5) / 3 - sx) / 2, yy - sy) - R;
            if (yy < HZ && e < 0) cov++;
            if (e < GAP) near++;
          }
        if (cov >= 6) g[r][c] = "@";
        if (cov >= 6 || (near && lit > 0)) continue;
        // Outside it, bands that bow up toward the sun, and a soft corona.
        const d = Math.hypot((x - sx) / 2, y - sy) - R;
        const bow = 0.62 + 0.38 * Math.exp(-(((x - sx) / 17) ** 2));
        const v = Math.max(glow * Math.exp(-(HZ - y) / height) * bow, 0.42 * lit * Math.exp(-(d - GAP) / 1.5));
        const k = Math.min(SKY.length - 1, Math.floor(v * SKY.length));
        if (k > 0) g[r][c] = SKY[k];
      }
    }

    // The sea, in perspective: short close-set dashes out by the horizon,
    // longer and sparser swells nearer, each run slowly lengthening and
    // shrinking. Under the sun its path breaks into glints that come and go.
    g[HZ].fill("▔");
    for (let r = HZ + 1; r < rows; r++) {
      const k = r - HZ;
      const scale = 0.6 * k ** 1.2, cut = 0.43 + 0.028 * k;
      const drift = t * (0.15 + 0.06 * k);
      const wide = 2.4 + k * 1.2;
      const bright = (0.25 + 0.75 * lit) * (1.05 - k * 0.05) * (0.4 + 0.6 * glow);
      let run = 0;
      for (let c = 0; c < cols; c++) {
        const x = c + 0.5;
        // A crest, unless it has already run longer than a wave this far out.
        let wave = noise((x + drift) / scale, r, t * 0.22 + k * 3.1) > cut;
        run = wave ? run + 1 : 0;
        if (run > (1.4 + 1.2 * k) * (0.7 + 0.6 * hash(Math.floor(x + drift), r, 9))) wave = false, run = 0;
        const path = Math.exp(-(((x - sx) / wide) ** 2)) * bright;
        const glint = noise(x * (1.3 / Math.sqrt(k)), r + 40, t * 2.2 + k) * path;
        if (glint > 0.45) g[r][c] = "=";
        else if (glint > 0.3 || (wave && path > 0.3)) g[r][c] = "-";
        else if (glint > 0.2) g[r][c] = "·";
        else if (wave) g[r][c] = k > 3 ? "~" : "-";
      }
    }
    return g.map((row) => row.join("")).join("\n");
  };
}