Skip to content

Commit ba6ddce

Browse files
alexisbohnsclaude
andauthored
Add animated ASCII ridged-mountains background to the home page (#282)
* Add animated ASCII ridged-mountains background to the home page Ports ComputerK's "ridged mountains" CodePen (jENaeKp) from p5.js to a hand-rolled Canvas2D loop: a grid of ASCII characters shaded by ridged Perlin noise, panning slowly for a living terrain texture. Theme-aware (paints the app's --foreground color), respects prefers-reduced-motion, and follows the existing wobble/boil system's no-animation-library convention. Fixes #280 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZWExYDE1QhSsz5Z5ofybC * Ease the background in softly on mount instead of full contrast The texture now starts at ~6% opacity and eases up to full contrast over 6s (smoothstep), so the logo reads first and the terrain grows in behind it rather than appearing at full intensity immediately. Skipped under prefers-reduced-motion, which still renders straight at the resting frame. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZWExYDE1QhSsz5Z5ofybC --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f4ce39 commit ba6ddce

4 files changed

Lines changed: 420 additions & 0 deletions

File tree

app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Gochi_Hand } from "next/font/google";
33
import { Button } from "@/components/ui/button";
44
import { ThemeToggle } from "@/components/theme-toggle";
55
import { ArkaikLogoBoil } from "@/components/branding/ArkaikLogoBoil";
6+
import { AsciiTerrainBackground } from "@/components/background/AsciiTerrainBackground";
67

78
const gochiHand = Gochi_Hand({
89
subsets: ["latin"],
@@ -13,6 +14,7 @@ export default function Home() {
1314

1415
return (
1516
<div className="relative flex min-h-screen flex-col overflow-hidden bg-background font-sans">
17+
<AsciiTerrainBackground />
1618
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(127,127,127,0.12),transparent_62%)]" />
1719

1820
<header className="relative flex items-center justify-end px-6 py-4">
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
"use client";
2+
3+
/**
4+
* Home page ASCII "ridged mountains" background (issue #280) — a port of
5+
* ComputerK's CodePen (https://codepen.io/ComputerK/pen/jENaeKp) from p5.js
6+
* to a hand-rolled Canvas2D loop, matching how the rest of the app's
7+
* continuous animation (`ArkaikLogoBoil`, the wobble system) avoids
8+
* creative-coding/animation library dependencies.
9+
*
10+
* A grid of monospace ASCII characters is shaded by 4-octave ridged Perlin
11+
* noise (see `lib/background/perlin.ts`), which folds/cubes the noise field
12+
* into vein-like ridges that read as flowing terrain. The noise field pans
13+
* a little every rendered frame for a slow, living drift. Color is the
14+
* app's theme `--foreground` at a low, brightness-modulated alpha, so it
15+
* reads as a subtle backdrop in both light and dark mode rather than the
16+
* pen's stark white-on-black terminal look.
17+
*/
18+
19+
import { useEffect, useRef } from "react";
20+
21+
import { perlin2 } from "@/lib/background/perlin";
22+
import {
23+
AMP_MULTIPLIER,
24+
ASCII_CHARS,
25+
BRIGHTNESS_THRESHOLD,
26+
CHAR_SIZE,
27+
EDGE_DISTANCE,
28+
FRAME_RATE,
29+
FREQ_MULTIPLIER,
30+
INTRO_DURATION_MS,
31+
INTRO_START_FACTOR,
32+
MAX_OPACITY,
33+
NOISE_PAN_SPEED,
34+
NOISE_SCALE,
35+
OCTAVE_NUM,
36+
} from "@/lib/background/constants";
37+
38+
const FREQUENCIES = Array.from({ length: OCTAVE_NUM }, (_, i) => FREQ_MULTIPLIER ** i);
39+
const AMPLITUDES = Array.from({ length: OCTAVE_NUM }, (_, i) => AMP_MULTIPLIER ** i);
40+
const MAX_NOISE_VAL = AMPLITUDES.reduce((sum, amp) => sum + amp, 0);
41+
const FRAME_INTERVAL_MS = 1000 / FRAME_RATE;
42+
43+
function clamp(value: number, lo: number, hi: number): number {
44+
return Math.min(hi, Math.max(lo, value));
45+
}
46+
47+
function mapRange(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number {
48+
return outMin + ((value - inMin) * (outMax - outMin)) / (inMax - inMin);
49+
}
50+
51+
/** Slow start, slow finish, quicker through the middle — a natural-feeling reveal. */
52+
function smoothstep(t: number): number {
53+
const c = clamp(t, 0, 1);
54+
return c * c * (3 - 2 * c);
55+
}
56+
57+
/** Opacity multiplier for the mount-time intro: soft → full contrast. */
58+
function introOpacityScale(elapsedMs: number): number {
59+
if (elapsedMs >= INTRO_DURATION_MS) return 1;
60+
const t = smoothstep(elapsedMs / INTRO_DURATION_MS);
61+
return INTRO_START_FACTOR + (1 - INTRO_START_FACTOR) * t;
62+
}
63+
64+
/** Ridged noise: folds/cubes a smooth field into vein-like ridges. */
65+
function getRidgedNoise(x: number, y: number, offsetX: number, offsetY: number): number {
66+
let noiseVal = 0;
67+
for (let i = 0; i < OCTAVE_NUM; i++) {
68+
const frequency = FREQUENCIES[i];
69+
const amplitude = AMPLITUDES[i];
70+
const raw = perlin2(
71+
x * frequency * NOISE_SCALE + offsetX,
72+
y * frequency * NOISE_SCALE + offsetY,
73+
);
74+
const p01 = clamp((raw + 1) / 2, 0, 1);
75+
let n = 1 - Math.abs(p01);
76+
n = 1 - Math.abs(n * 2 - 1);
77+
n = n * n * n;
78+
noiseVal += n * amplitude;
79+
}
80+
return noiseVal / MAX_NOISE_VAL;
81+
}
82+
83+
/** Parses this app's `H S% L%` custom-property triples into `[r, g, b]`. */
84+
function hslTripleToRgb(triple: string): [number, number, number] {
85+
const [h, s, l] = triple
86+
.trim()
87+
.split(/\s+/)
88+
.map((part) => parseFloat(part));
89+
const sFrac = (s || 0) / 100;
90+
const lFrac = (l || 0) / 100;
91+
92+
if (sFrac === 0) {
93+
const gray = Math.round(lFrac * 255);
94+
return [gray, gray, gray];
95+
}
96+
97+
const q = lFrac < 0.5 ? lFrac * (1 + sFrac) : lFrac + sFrac - lFrac * sFrac;
98+
const p = 2 * lFrac - q;
99+
const hueToRgb = (t: number) => {
100+
let tt = t;
101+
if (tt < 0) tt += 1;
102+
if (tt > 1) tt -= 1;
103+
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
104+
if (tt < 1 / 2) return q;
105+
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
106+
return p;
107+
};
108+
const hFrac = (h || 0) / 360;
109+
return [
110+
Math.round(hueToRgb(hFrac + 1 / 3) * 255),
111+
Math.round(hueToRgb(hFrac) * 255),
112+
Math.round(hueToRgb(hFrac - 1 / 3) * 255),
113+
];
114+
}
115+
116+
export function AsciiTerrainBackground({ className }: { className?: string }) {
117+
const canvasRef = useRef<HTMLCanvasElement>(null);
118+
119+
useEffect(() => {
120+
const canvas = canvasRef.current;
121+
const ctx = canvas?.getContext("2d");
122+
if (!canvas || !ctx) return;
123+
124+
const fontFamily = `${getComputedStyle(document.documentElement).getPropertyValue("--font-geist-mono").trim() || "ui-monospace"}, monospace`;
125+
126+
let cols = 0;
127+
let rows = 0;
128+
let noiseCache = new Float32Array(0);
129+
let fadeCache = new Float32Array(0);
130+
let offsetX = 0;
131+
let offsetY = 0;
132+
let rgb: [number, number, number] = [0, 0, 0];
133+
let rafId = 0;
134+
let lastFrameTime = 0;
135+
let resizePending = false;
136+
let reduced = false;
137+
let lastCssWidth = -1;
138+
let lastCssHeight = -1;
139+
140+
const readColor = () => {
141+
rgb = hslTripleToRgb(getComputedStyle(document.documentElement).getPropertyValue("--foreground"));
142+
};
143+
144+
const setupContext = () => {
145+
ctx.font = `${CHAR_SIZE}px ${fontFamily}`;
146+
ctx.textAlign = "center";
147+
ctx.textBaseline = "middle";
148+
};
149+
150+
const updateNoiseCache = () => {
151+
for (let col = 0; col < cols; col++) {
152+
for (let row = 0; row < rows; row++) {
153+
noiseCache[col * rows + row] = getRidgedNoise(col, row, offsetX, offsetY);
154+
}
155+
}
156+
};
157+
158+
const render = (opacityScale = 1) => {
159+
const cssWidth = canvas.clientWidth;
160+
const cssHeight = canvas.clientHeight;
161+
ctx.clearRect(0, 0, cssWidth, cssHeight);
162+
163+
const half = CHAR_SIZE / 2;
164+
let currentAlpha = -1;
165+
const [r, g, b] = rgb;
166+
167+
for (let col = 0; col < cols; col++) {
168+
const x = col * CHAR_SIZE + half;
169+
for (let row = 0; row < rows; row++) {
170+
const index = col * rows + row;
171+
const fadeFactor = fadeCache[index];
172+
if (fadeFactor < BRIGHTNESS_THRESHOLD) continue;
173+
174+
let noiseVal = clamp(mapRange(noiseCache[index], 0, 1, -0.2, 1.2), 0, 1);
175+
noiseVal = Math.pow(noiseVal, 1.5);
176+
const alpha = Math.pow(noiseVal, 0.8) * MAX_OPACITY * opacityScale * fadeFactor;
177+
178+
if (Math.abs(alpha - currentAlpha) > 1 / 255) {
179+
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`;
180+
currentAlpha = alpha;
181+
}
182+
183+
const charIndex = Math.floor(mapRange(noiseVal, 0, 1, 0, ASCII_CHARS.length - 0.01));
184+
const y = row * CHAR_SIZE + half;
185+
ctx.fillText(ASCII_CHARS[charIndex], x, y);
186+
}
187+
}
188+
};
189+
190+
const mountTime = performance.now();
191+
192+
const frame = (time: number) => {
193+
rafId = requestAnimationFrame(frame);
194+
if (time - lastFrameTime < FRAME_INTERVAL_MS) return;
195+
lastFrameTime = time;
196+
197+
updateNoiseCache();
198+
render(introOpacityScale(time - mountTime));
199+
offsetX += NOISE_PAN_SPEED;
200+
offsetY += NOISE_PAN_SPEED;
201+
};
202+
203+
const start = () => {
204+
cancelAnimationFrame(rafId);
205+
if (reduced) {
206+
updateNoiseCache();
207+
render();
208+
} else {
209+
lastFrameTime = 0;
210+
rafId = requestAnimationFrame(frame);
211+
}
212+
};
213+
214+
// Setting canvas.width/height always clears the bitmap, even to the same
215+
// value — guard on an actual size change so the animation loop's own
216+
// repaint (or, under reduced motion, an explicit re-render below) is the
217+
// only thing that ever touches pixels once sized.
218+
const resize = () => {
219+
const dpr = window.devicePixelRatio || 1;
220+
const cssWidth = Math.max(1, Math.round(canvas.clientWidth));
221+
const cssHeight = Math.max(1, Math.round(canvas.clientHeight));
222+
if (cssWidth === lastCssWidth && cssHeight === lastCssHeight) return;
223+
lastCssWidth = cssWidth;
224+
lastCssHeight = cssHeight;
225+
226+
canvas.width = Math.round(cssWidth * dpr);
227+
canvas.height = Math.round(cssHeight * dpr);
228+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
229+
setupContext();
230+
231+
cols = Math.floor(cssWidth / CHAR_SIZE);
232+
rows = Math.floor(cssHeight / CHAR_SIZE);
233+
noiseCache = new Float32Array(cols * rows);
234+
fadeCache = new Float32Array(cols * rows);
235+
236+
const half = CHAR_SIZE / 2;
237+
for (let col = 0; col < cols; col++) {
238+
const x = col * CHAR_SIZE + half;
239+
const fadeX = clamp(Math.min(x, cssWidth - x) / EDGE_DISTANCE, 0, 1);
240+
for (let row = 0; row < rows; row++) {
241+
const y = row * CHAR_SIZE + half;
242+
const fadeY = clamp(Math.min(y, cssHeight - y) / EDGE_DISTANCE, 0, 1);
243+
fadeCache[col * rows + row] = fadeX * fadeY;
244+
}
245+
}
246+
247+
// The animation loop repaints on its own next tick; under reduced
248+
// motion nothing else will, so re-render the static frame now.
249+
if (reduced) {
250+
updateNoiseCache();
251+
render();
252+
}
253+
};
254+
255+
const scheduleResize = () => {
256+
if (resizePending) return;
257+
resizePending = true;
258+
requestAnimationFrame(() => {
259+
resizePending = false;
260+
resize();
261+
});
262+
};
263+
264+
readColor();
265+
resize();
266+
267+
const resizeObserver = new ResizeObserver(scheduleResize);
268+
resizeObserver.observe(canvas);
269+
270+
const themeObserver = new MutationObserver(readColor);
271+
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
272+
273+
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
274+
const applyReducedMotion = () => {
275+
reduced = reducedMotionQuery.matches;
276+
start();
277+
};
278+
applyReducedMotion();
279+
reducedMotionQuery.addEventListener("change", applyReducedMotion);
280+
281+
return () => {
282+
cancelAnimationFrame(rafId);
283+
resizeObserver.disconnect();
284+
themeObserver.disconnect();
285+
reducedMotionQuery.removeEventListener("change", applyReducedMotion);
286+
};
287+
}, []);
288+
289+
return (
290+
<canvas
291+
ref={canvasRef}
292+
aria-hidden="true"
293+
className={className ?? "pointer-events-none absolute inset-0 h-full w-full"}
294+
/>
295+
);
296+
}

lib/background/constants.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Tuning for the home page's ASCII "ridged mountains" background
3+
* (issue #280), ported from ComputerK's CodePen
4+
* (https://codepen.io/ComputerK/pen/jENaeKp). Values are a direct port of
5+
* the pen's `CONFIG` object except `maxOpacity`, which is new here since
6+
* the app paints the theme's foreground color over the page background
7+
* instead of the pen's plain white-on-black.
8+
*/
9+
10+
/** Noise-space scale per pixel. Lower = larger, slower-rolling ridges. */
11+
export const NOISE_SCALE = 0.004;
12+
13+
/** Grid cell size in CSS px — one ASCII character per cell. */
14+
export const CHAR_SIZE = 10;
15+
16+
/** Distance in px over which the texture fades in from each canvas edge. */
17+
export const EDGE_DISTANCE = 100;
18+
19+
/** Cells whose edge-fade factor falls below this are skipped entirely. */
20+
export const BRIGHTNESS_THRESHOLD = 0.05;
21+
22+
/** Octaves of ridged noise layered together. */
23+
export const OCTAVE_NUM = 4;
24+
25+
/** Frequency multiplier applied per successive octave. */
26+
export const FREQ_MULTIPLIER = 2.2;
27+
28+
/** Amplitude multiplier applied per successive octave. */
29+
export const AMP_MULTIPLIER = 0.45;
30+
31+
/** Animation frame rate cap, in fps. */
32+
export const FRAME_RATE = 30;
33+
34+
/** Speed the noise field pans per rendered frame, in noise-space units. */
35+
export const NOISE_PAN_SPEED = 0.001;
36+
37+
/** Brightness ramp, darkest to brightest. */
38+
export const ASCII_CHARS = [" ", ".", ":", "-", "~", "+", "=", "^", "*", "#", "@", "█"];
39+
40+
/**
41+
* Ceiling on the foreground color's alpha, applied on top of each cell's
42+
* brightness/edge-fade factor. Keeps the texture a subtle backdrop rather
43+
* than the pen's stark full-contrast terminal look.
44+
*/
45+
export const MAX_OPACITY = 0.35;
46+
47+
/**
48+
* On mount, the texture eases in from `INTRO_START_FACTOR × MAX_OPACITY`
49+
* (soft, logo-first) up to the full `MAX_OPACITY` (ridges, contrast) over
50+
* `INTRO_DURATION_MS`, instead of appearing at full contrast immediately.
51+
* Skipped under `prefers-reduced-motion: reduce`, which renders straight at
52+
* full contrast.
53+
*/
54+
export const INTRO_DURATION_MS = 6000;
55+
56+
/** Opacity fraction the texture starts at when the intro begins. */
57+
export const INTRO_START_FACTOR = 0.06;

0 commit comments

Comments
 (0)