302 lines (289 loc) · 13.96 KB
  1// Home hero: two fBm-noise cloud layers drift at different speeds (parallax)
  2// behind a pixel-font wordmark; a letter cell warms to the accent color while
  3// the near cloud passes behind it. Canvas 2D only, one rAF loop.
  4// The wordmark comes from data-cloud-hero="..." on the hero element; empty = clouds only.
  5import { $, prefersReducedMotion, storage } from "./dom";
  6
  7const RAMP = " .·:•oO@"; // cloud density → glyph
  8const CELLS_PER_SEC = (144 + 24) / 75; // speed, not duration — keeps px/s constant as cols grows on wide screens
  9// Baseline zoom, captured once per page load (module scope, so SPA re-inits
 10// don't rebase it and change the speed mid-session).
 11const DPR_BASE = window.devicePixelRatio || 1;
 12const FIRST_DELAY = 5; // s before the first rise — soon, so visitors see one
 13const gap = () => 15 + Math.random() * 25; // s of empty sky between transits
 14// Artwork colors, not UI theme tokens: the sun is warm and the moon is pale
 15// regardless of theme (theme only decides which body is up).
 16type Rgb = [number, number, number];
 17const SUN: Rgb = [244, 180, 96];
 18const MOON: Rgb = [216, 214, 226];
 19
 20// Hand-drawn 7-row pixel font ("#" = filled cell). Only the glyphs the
 21// wordmark needs — extend here if the text changes.
 22const FONT: Record<string, string[]> = {
 23  " ": ["...", "...", "...", "...", "...", "...", "..."],
 24  I: ["###", ".#.", ".#.", ".#.", ".#.", ".#.", "###"],
 25  L: ["#....", "#....", "#....", "#....", "#....", "#....", "#####"],
 26  O: [".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."],
 27  V: ["#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."],
 28  E: ["#####", "#....", "#....", "####.", "#....", "#....", "#####"],
 29  C: [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."],
 30  U: ["#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."],
 31  D: ["####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####."],
 32  S: [".####", "#....", "#....", ".###.", "....#", "....#", "####."],
 33  T: ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."],
 34  N: ["#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#"],
 35  Y: ["#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.."],
 36  R: ["####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"],
 37  B: ["####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."],
 38  G: [".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."],
 39  P: ["####.", "#...#", "#...#", "####.", "#....", "#....", "#...."],
 40  H: ["#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"],
 41  A: [".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"],
 42};
 43
 44// Classic shadertoy hash + value noise, 3-octave fbm. Returns ~0..1.
 45const hash = (x: number, y: number): number => {
 46  const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
 47  return s - Math.floor(s);
 48};
 49const noise = (x: number, y: number): number => {
 50  const xi = Math.floor(x);
 51  const yi = Math.floor(y);
 52  const xf = (x - xi) ** 2 * (3 - 2 * (x - xi));
 53  const yf = (y - yi) ** 2 * (3 - 2 * (y - yi));
 54  const a = hash(xi, yi);
 55  const b = hash(xi + 1, yi);
 56  const c = hash(xi, yi + 1);
 57  const d = hash(xi + 1, yi + 1);
 58  return a + (b - a) * xf + (c - a) * yf + (a - b - c + d) * xf * yf;
 59};
 60const fbm = (x: number, y: number): number =>
 61  0.5 * noise(x, y) + 0.3 * noise(x * 2.1, y * 2.1) + 0.2 * noise(x * 4.3, y * 4.3);
 62
 63const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
 64const lerpRgb = (a: Rgb, b: Rgb, t: number): string =>
 65  `${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)}`;
 66
 67// Stamp the wordmark into a per-cell bitmask, integer-scaled and centered.
 68const buildMask = (word: string, cols: number, rows: number): Uint8Array => {
 69  const mask = new Uint8Array(cols * rows);
 70  const glyphs = [...word].map((ch) => FONT[ch]).filter((g): g is string[] => !!g);
 71  if (!glyphs.length) return mask;
 72  const wordW = glyphs.reduce((w, g) => w + g[0].length + 1, -1);
 73  // word height capped at half the band, so the text scales with the cell size
 74  // instead of jumping to a bigger integer scale on mid-width screens
 75  const k = Math.max(1, Math.min(Math.floor((rows * 0.5) / 7), Math.floor((cols * 0.9) / wordW)));
 76  if (wordW * k > cols) return mask; // narrow screens: clouds only
 77  let cx = Math.floor((cols - wordW * k) / 2);
 78  const cy = Math.floor((rows - 7 * k) / 2);
 79  for (const g of glyphs) {
 80    for (let r = 0; r < 7; r++)
 81      for (let c = 0; c < g[r].length; c++) {
 82        if (g[r][c] !== "#") continue;
 83        for (let dy = 0; dy < k; dy++)
 84          for (let dx = 0; dx < k; dx++) mask[(cy + r * k + dy) * cols + cx + c * k + dx] = 1;
 85      }
 86    cx += (g[0].length + 1) * k;
 87  }
 88  return mask;
 89};
 90
 91export const initClouds = (signal: AbortSignal): void => {
 92  const hero = $<HTMLElement>("[data-cloud-hero]");
 93  if (!hero) return;
 94  const canvas = $<HTMLCanvasElement>(".home-hero__canvas", hero);
 95  const ctx = canvas?.getContext("2d");
 96  if (!canvas || !ctx) return;
 97
 98  const word = (hero.dataset.cloudHero ?? "").toUpperCase();
 99  hero.classList.add("home-hero--on"); // shows the canvas, so size it after this
100
101  let cols = 0;
102  let rows = 0;
103  let w = 0;
104  let h = 0;
105  let cell = 10; // px per glyph cell; shrinks on narrow screens so the whole
106  // composition scales down instead of showing a small slice of it
107  let mask: Uint8Array = new Uint8Array(0);
108  let TRANSIT = 75; // seconds for the sun/moon to cross the band; recomputed in resize()
109
110  const resize = () => {
111    const rect = canvas.getBoundingClientRect();
112    const dpr = Math.min(window.devicePixelRatio || 1, 2);
113    w = rect.width;
114    h = rect.height;
115    cell = Math.max(6, Math.min(10, Math.round(w / 144)));
116    canvas.width = Math.max(1, Math.floor(w * dpr));
117    canvas.height = Math.max(1, Math.floor(h * dpr));
118    cols = Math.ceil(w / cell);
119    rows = Math.ceil(h / cell);
120    TRANSIT = (cols + 24) / CELLS_PER_SEC;
121    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
122    ctx.textAlign = "center";
123    ctx.textBaseline = "middle";
124    ctx.font = `${cell}px ui-monospace, monospace`;
125    mask = buildMask(word, cols, rows);
126  };
127
128  // assumes theme color tokens are 6-digit hex (they are, see _colors.scss)
129  let themeKey: string | null = null;
130  let colors: { heading: Rgb; muted: Rgb; accent: Rgb } = {
131    heading: [0, 0, 0],
132    muted: [0, 0, 0],
133    accent: [0, 0, 0],
134  };
135  const readColors = () => {
136    const key = document.documentElement.dataset.theme ?? "";
137    if (key === themeKey) return;
138    themeKey = key;
139    const css = getComputedStyle(document.documentElement);
140    const parse = (name: string): Rgb => {
141      const n = parseInt(css.getPropertyValue(name).trim().slice(1), 16);
142      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
143    };
144    colors = { heading: parse("--heading"), muted: parse("--muted"), accent: parse("--accent") };
145  };
146
147  const draw = (t: number) => {
148    readColors();
149    ctx.clearRect(0, 0, w, h);
150    const { heading, accent } = colors;
151    // Terraria-style transit: rise on the left, apex mid-band, set on the right.
152    // Transits are episodes, not a loop — after one ends the sky stays empty
153    // for a while, so the body never teleports back to the left edge.
154    if (t >= riseAt + TRANSIT) riseAt = t + gap();
155    const p = (t - riseAt) / TRANSIT;
156    // light ramps in at rise and out at set (and is 0 between transits)
157    const fade = clamp01(Math.min(p, 1 - p) / 0.1);
158    const bx = p * (cols + 24) - 12; // start/end just off-band
159    const by = rows * (0.9 - 0.72 * Math.sin(p * Math.PI));
160    const body = themeKey === "light" ? SUN : MOON;
161    const isSun = body === SUN;
162    const bodyStr = body.join(",");
163    const R = Math.max(2.5, rows * 0.17); // body radius, in cells
164    // narrow screens see a thin slice of the sky and can look empty — lower the
165    // condensation threshold as the viewport shrinks (zero on desktop widths)
166    const bias = cols < 110 ? ((110 - cols) / 110) * 0.12 : 0;
167    for (let r = 0; r < rows; r++) {
168      const y = (r + 0.5) * cell;
169      // fade near the band's top/bottom so shapes don't get sliced by the edge
170      const edge = clamp01(Math.min(r, rows - 1 - r) / (rows * 0.18));
171      for (let c = 0; c < cols; c++) {
172        const x = (c + 0.5) * cell;
173        // Halftone sky: a glyph in (almost) every cell — faint dots for open
174        // sky, denser marks inside clouds. Two fbm fields at different drift
175        // speeds give parallax; clouds wear the theme accent and warm toward
176        // the body color where its light hits.
177        const far = fbm((c + t * 0.5) * 0.014, r * 0.045 + 19);
178        const near = fbm((c + t * 1.4) * 0.02, r * 0.055 + 57);
179        const dens = Math.max(
180          clamp01((far - 0.42 + bias) / 0.35) * 0.55,
181          clamp01((near - 0.45 + bias) / 0.3),
182        );
183        let halo = 0;
184        let light = 0;
185        if (fade > 0) {
186          const dx = c - bx;
187          const dy = r - by;
188          const dist = Math.hypot(dx, dy);
189          // Sun: long wide rays (30papers-style angular lobes) reaching across the
190          // band. Moon: calm circular glow, no rays.
191          // both bodies get a tight glowing circle; the sun adds wide rays that
192          // reach across most of the band
193          halo = fade * clamp01(1 - dist / (R * (isSun ? 2.4 : 3.2))) ** 1.3;
194          light =
195            fade *
196            (isSun
197              ? clamp01(
198                  halo * 0.9 +
199                    clamp01(1 - dist / (R * 20)) ** 1.1 *
200                      (0.1 + 1.4 * (0.5 + 0.5 * Math.sin(Math.atan2(dy, dx) * 9 + t * 0.5)) ** 2),
201                )
202              : clamp01(halo * 1.1 + clamp01(1 - dist / (R * 8)) ** 1.5 * 0.7));
203          if (dist <= R) {
204            // The body, in the sky's halftone language: a dense glyph core that
205            // loosens toward the rim. Both bodies hide behind clouds (and the
206            // wordmark, drawn later) — their light doesn't. The moon's crescent
207            // comes from subtracting an offset disc.
208            let v = clamp01(((R - dist) / R) * 2.2) * (1 - clamp01(dens * 1.7));
209            if (!isSun) {
210              const biteDist = Math.hypot(c - (bx + R * 0.55), r - (by - R * 0.25));
211              v *= clamp01((biteDist - R * 0.55) / (R * 0.45));
212            }
213            if (v > 0.05) {
214              ctx.fillStyle = `rgba(${bodyStr},${0.5 + 0.5 * v})`;
215              ctx.fillText(RAMP[Math.round((0.6 + 0.4 * v) * (RAMP.length - 1))], x, y);
216            }
217          }
218        }
219        // clouds are drawn after (over) the body, so it sits behind them.
220        // Light multiplies cloud density only — rays are invisible on open sky
221        // and show up as lit cloud matter. The halo is the exception: a small
222        // visible glowing circle hugging the body (stronger for the moon).
223        const glow = halo * (isSun ? 0.25 : 0.6);
224        const alpha = Math.min(0.9, (0.045 + dens * 0.85 * (0.6 + light * 1.2) + glow) * edge);
225        if (alpha > 0.02) {
226          const tint = clamp01(Math.max(light * (0.5 + dens), halo));
227          ctx.fillStyle = `rgba(${lerpRgb(accent, body, tint)},${alpha})`;
228          ctx.fillText(RAMP[Math.round(Math.max(dens, glow) * (RAMP.length - 1))], x, y);
229        }
230        if (mask[r * cols + c]) {
231          // opaque rect per cell: fillText("█") leaves stripes, translucent
232          // rects show seams where the bleed overlaps
233          ctx.fillStyle = `rgb(${lerpRgb(heading, body, Math.max(dens * 0.3, light))})`;
234          ctx.fillRect(x - cell / 2, y - cell / 2, cell + 0.5, cell + 0.5);
235        }
236      }
237    }
238  };
239
240  const reduced = prefersReducedMotion();
241  // Page zoom scales devicePixelRatio, and with it the physical size of the
242  // band — a fixed-duration transit then looks faster. Dividing frame time by
243  // the zoom factor keeps all motion (transit, drift, rays) at a constant
244  // on-screen speed: exactly 2x slower wall-clock at 200%, never compounding,
245  // because nothing else in the timing depends on zoom. Accumulating world
246  // time frame by frame also means zooming mid-transit never teleports the body.
247  // Resume world time from where it was last saved instead of restarting at 0
248  // on every SPA nav / refresh, so the sky doesn't visibly reset. The gap is
249  // capped so a long absence (tab closed for hours) advances the scene by a
250  // bounded amount instead of jumping to a wildly different pattern/transit.
251  const WT_KEY = "cloud-wt";
252  const WT_SEEN_KEY = "cloud-wt-seen";
253  const RISE_KEY = "cloud-rise";
254  const WT_MAX_GAP = 90; // seconds
255  const wtNow = Date.now();
256  const savedWt = Number(storage?.getItem(WT_KEY)) || 0;
257  const lastSeen = Number(storage?.getItem(WT_SEEN_KEY)) || wtNow;
258  let raf = 0;
259  let wt = savedWt + Math.min((wtNow - lastSeen) / 1000, WT_MAX_GAP); // world time, in seconds slowed by zoom
260  let last = 0;
261  let riseAt = Number(storage?.getItem(RISE_KEY)) || FIRST_DELAY; // t of next transit's start; persisted like wt
262  const saveWt = () => {
263    storage?.setItem(WT_KEY, String(wt));
264    storage?.setItem(RISE_KEY, String(riseAt));
265    storage?.setItem(WT_SEEN_KEY, String(Date.now()));
266  };
267  const loop = (now: number) => {
268    const t = now / 1000;
269    // clamp dt: first frame's `last` is 0, and t counts from page load, not init
270    wt += (Math.min(t - last, 0.1) * DPR_BASE) / (window.devicePixelRatio || 1);
271    last = t;
272    draw(wt);
273    raf = requestAnimationFrame(loop);
274  };
275
276  resize();
277  const staticT = TRANSIT * 0.35; // reduced motion: body frozen mid-morning
278  if (reduced) {
279    riseAt = 0;
280    draw(staticT);
281    // static frame won't repaint on its own, so follow theme switches
282    const mo = new MutationObserver(() => draw(staticT));
283    mo.observe(document.documentElement, { attributeFilter: ["data-theme"] });
284    signal.addEventListener("abort", () => mo.disconnect());
285  } else {
286    raf = requestAnimationFrame(loop);
287    signal.addEventListener("abort", () => {
288      cancelAnimationFrame(raf);
289      saveWt();
290    });
291    // pagehide covers reload/tab close/backgrounding, which abort doesn't see
292    window.addEventListener("pagehide", saveWt, { signal });
293  }
294  window.addEventListener(
295    "resize",
296    () => {
297      resize();
298      if (reduced) draw(staticT);
299    },
300    { signal },
301  );
302};