297 lines (284 loc) · 13.45 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.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};