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