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