Shaders & Backgroundsv0.3.0
ASCII Koi Pond
Shaded ASCII koi seen from above leave wakes and race for food you drop.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/ascii-koi-pond.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/color.tsexport type Vec3 = [r: number, g: number, b: number]; /** * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels. * Anything else falls back to black, so a typo never crashes a shader. */ export function hexToRgb(hex: string): Vec3 { let value = hex.trim().replace(/^#/, ""); if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join(""); if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0]; const n = Number.parseInt(value.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function hexToVec3(hex: string): Vec3 { const [r, g, b] = hexToRgb(hex); return [r / 255, g / 255, b / 255]; } /** * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result * instead of calling this every frame. Browser only; unreadable values fall back to black. */ export function cssColorToRgb(color: string, element: Element): Vec3 { const value = color.trim(); if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value); // Children of a canvas still get computed styles, but a regular parent is the safer host. const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element; const probe = document.createElement("span"); probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden"; probe.style.color = value; host.appendChild(probe); const computed = getComputedStyle(probe).color; probe.remove(); // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes. const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!ctx) return [0, 0, 0]; ctx.fillStyle = "#000"; ctx.fillStyle = computed; ctx.fillRect(0, 0, 1, 1); const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; return [r, g, b]; } /** * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme. * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe. */ export function observeTheme(onChange: () => void): () => void { const observer = new MutationObserver(onChange); const options = { attributes: true, attributeFilter: ["class", "data-theme"] }; observer.observe(document.documentElement, options); if (document.body) observer.observe(document.body, options); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); scheme.addEventListener("change", onChange); return () => { observer.disconnect(); scheme.removeEventListener("change", onChange); }; } /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function cssColorToVec3(color: string, element: Element): Vec3 { const [r, g, b] = cssColorToRgb(color, element); return [r / 255, g / 255, b / 255]; } const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */ export function rgbToOklch([r, g, b]: Vec3): Vec3 { const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255)); const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; return [L, Math.hypot(A, B), Math.atan2(B, A)]; } /** OKLCH back to 0–255 sRGB, clamped into gamut. */ export function oklchToRgb([L, C, h]: Vec3): Vec3 { const A = C * Math.cos(h); const B = C * Math.sin(h); const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3; const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3; const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3; const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]; return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3; } /** * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep * their chroma instead of turning muddy the way an RGB mix does. */ export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 { const [L1, C1, h1] = rgbToOklch(a); const [L2, C2, h2] = rgbToOklch(b); // A near-gray end has no meaningful hue: borrow the other end's. const ha = C1 < 0.02 ? h2 : h1; const hb = C2 < 0.02 ? h1 : h2; let dh = hb - ha; if (dh > Math.PI) dh -= Math.PI * 2; if (dh < -Math.PI) dh += Math.PI * 2; return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]); }lib/glyph/ascii-life.ts/** * Building blocks for ASCII creatures on a canvas: a grid of glyph cells (taller than wide, like * monospace glyphs), a diffusing wake field, follow-the-leader spines with a bend limit and a * travelling body wave, nearest-point queries in body coordinates, and a cell buffer that is drawn * in two batched passes (shaded blocks grouped by color, then glyphs blitted from a sprite atlas). * * The approach (shaded blocks under density-matched glyphs, supersampled silhouettes, a wake field * that lights the water, glyphs keyed to body coordinates) is learned from ascii-sharks by drewdi, * https://github.com/stackedlol/ascii-sharks (MIT). This file is an independent implementation. */ import type { GlyphAtlas } from "@/lib/glyph/glyph-atlas"; /* ------------------------------------------------------------------ grid */ export interface CellGrid { /** Cell width and height in CSS pixels. */ cw: number; ch: number; cols: number; rows: number; } /** A grid of cells `cell` wide and `cell × aspect` tall covering width × height. */ export function cellGrid(width: number, height: number, cell: number, aspect = 1.45): CellGrid { const cw = Math.max(4, cell); const ch = Math.max(4, Math.round(cw * aspect)); return { cw, ch, cols: Math.max(1, Math.ceil(width / cw)), rows: Math.max(1, Math.ceil(height / ch)) }; } export const cellAt = (g: CellGrid, x: number, y: number) => { const c = Math.floor(x / g.cw); const r = Math.floor(y / g.ch); return c < 0 || r < 0 || c >= g.cols || r >= g.rows ? -1 : r * g.cols + c; }; /* ------------------------------------------------------------------ hashing */ /** Integer hash → [0, 1). Stable, so textures keyed to it don't flicker. */ export function hash3(x: number, y: number, z: number) { let h = Math.imul(x | 0, 0x2c1b3c6d) ^ Math.imul(y | 0, 0x297a2d39) ^ Math.imul(z | 0, 0x7feb352d); h = Math.imul(h ^ (h >>> 15), 0x846ca68b); h ^= h >>> 16; return (h >>> 0) / 4294967296; } /* ------------------------------------------------------------------ wake field */ /** * Energy per cell that things deposit as they swim. Each step it blurs into its neighbors and * decays, so wakes spread like ripples and fade. */ export class WakeField { energy: Float32Array; private tmp: Float32Array; constructor( readonly cols: number, readonly rows: number, ) { this.energy = new Float32Array(cols * rows); this.tmp = new Float32Array(cols * rows); } add(index: number, amount: number, max = 1.6) { if (index >= 0) this.energy[index] = Math.min(max, this.energy[index] + amount); } /** A soft disc of energy around (x, y), `radius` cells wide (rows count wider, cells are tall). */ stamp(g: CellGrid, x: number, y: number, radius: number, amount: number) { const c0 = Math.floor(x / g.cw); const r0 = Math.floor(y / g.ch); const rr = Math.ceil(radius / 1.4); for (let r = r0 - rr; r <= r0 + rr; r++) { for (let c = c0 - radius; c <= c0 + radius; c++) { if (c < 0 || r < 0 || c >= this.cols || r >= this.rows) continue; const d = Math.hypot(c - c0, (r - r0) * 1.4); if (d <= radius + 0.5) this.add(r * this.cols + c, amount * (1 - d / (radius + 1))); } } } /** A thin ring of energy, for splashes and dropped food. */ ring(g: CellGrid, x: number, y: number, radiusPx: number, amount: number) { const steps = Math.max(8, Math.ceil((radiusPx * Math.PI * 2) / g.cw)); for (let i = 0; i < steps; i++) { const a = (i / steps) * Math.PI * 2; this.add(cellAt(g, x + Math.cos(a) * radiusPx, y + Math.sin(a) * radiusPx * 0.9), amount); } } step(dt: number, decayPerSecond = 1.5) { const { cols, rows, energy: e, tmp } = this; const decay = Math.exp(-dt * decayPerSecond); for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const i = r * cols + c; const l = c > 0 ? e[i - 1] : e[i]; const rt = c < cols - 1 ? e[i + 1] : e[i]; const u = r > 0 ? e[i - cols] : e[i]; const d = r < rows - 1 ? e[i + cols] : e[i]; tmp[i] = (e[i] * 4 + l + rt + u + d) * 0.125 * decay; } } this.tmp = e; this.energy = tmp; } } /* ------------------------------------------------------------------ spines */ const angleDiff = (a: number, b: number) => { let d = (b - a) % (Math.PI * 2); if (d > Math.PI) d -= Math.PI * 2; if (d < -Math.PI) d += Math.PI * 2; return d; }; /** * Pulls every node after the head to `segment` px behind the one before it, bending at most * `maxBend` radians per joint, so a body curls into a C but never folds back on itself. */ export function followSpine(nodes: Float32Array, heading: number, segment: number, maxBend: number) { let prev = heading + Math.PI; for (let k = 1; k < nodes.length / 2; k++) { const px = nodes[(k - 1) * 2]; const py = nodes[(k - 1) * 2 + 1]; let angle = Math.atan2(nodes[k * 2 + 1] - py, nodes[k * 2] - px); angle = prev + Math.max(-maxBend, Math.min(maxBend, angleDiff(prev, angle))); nodes[k * 2] = px + Math.cos(angle) * segment; nodes[k * 2 + 1] = py + Math.sin(angle) * segment; prev = angle; } } /** A straight spine from (x, y) trailing back from `heading`. */ export function straightSpine(count: number, x: number, y: number, heading: number, length: number) { const nodes = new Float32Array(count * 2); const seg = length / (count - 1); for (let k = 0; k < count; k++) { nodes[k * 2] = x - Math.cos(heading) * seg * k; nodes[k * 2 + 1] = y - Math.sin(heading) * seg * k; } return nodes; } /** * Copies `src` into `out` with a travelling sideways wave whose amplitude grows toward the tail, * fills `arc` with cumulative lengths and returns the total length. */ export function waveSpine(src: Float32Array, out: Float32Array, arc: Float32Array, phase: number, amplitude: number, waves = 5) { const n = src.length / 2; for (let k = 0; k < n; k++) { const s = k / (n - 1); const a = Math.max(0, k - 1); const b = Math.min(n - 1, k + 1); let tx = src[a * 2] - src[b * 2]; let ty = src[a * 2 + 1] - src[b * 2 + 1]; const tl = Math.hypot(tx, ty) || 1; tx /= tl; ty /= tl; const off = amplitude * (0.1 + 0.9 * s ** 1.8) * Math.sin(phase - s * waves); out[k * 2] = src[k * 2] - ty * off; out[k * 2 + 1] = src[k * 2 + 1] + tx * off; } arc[0] = 0; for (let k = 1; k < n; k++) arc[k] = arc[k - 1] + Math.hypot(out[k * 2] - out[(k - 1) * 2], out[k * 2 + 1] - out[(k - 1) * 2 + 1]); return arc[n - 1] || 1; } /** Result of `nearestOnSpine`: position along (s, 0 head → 1 tail), signed offset across (v), and the side's normal. */ export interface SpineHit { s: number; v: number; dist: number; nx: number; ny: number; } /** Nearest point on a spine polyline to (px, py), in body coordinates. */ export function nearestOnSpine(spine: Float32Array, arc: Float32Array, total: number, px: number, py: number, hit: SpineHit) { const n = spine.length / 2; let best = Infinity; for (let k = 0; k < n - 1; k++) { const ax = spine[k * 2]; const ay = spine[k * 2 + 1]; const sx = spine[(k + 1) * 2] - ax; const sy = spine[(k + 1) * 2 + 1] - ay; const len2 = sx * sx + sy * sy || 1; const u = Math.max(0, Math.min(1, ((px - ax) * sx + (py - ay) * sy) / len2)); const qx = ax + sx * u; const qy = ay + sy * u; const d2 = (px - qx) ** 2 + (py - qy) ** 2; if (d2 < best) { best = d2; const len = Math.sqrt(len2); const side = sx * (py - ay) - sy * (px - ax) >= 0 ? 1 : -1; hit.s = (arc[k] + u * len) / total; hit.nx = (-sy / len) * side; hit.ny = (sx / len) * side; hit.v = side; } } hit.dist = Math.sqrt(best); hit.v *= hit.dist; return hit; } /* ------------------------------------------------------------------ cell buffer */ /** Sub-cell sample points (x, y pairs in cell units) for anti-aliased silhouettes. */ export const SUBSAMPLES = [0.25, 0.28, 0.75, 0.3, 0.27, 0.74, 0.74, 0.72]; /** * What to draw in each cell this frame: an optional solid block (an index into a list of fill * styles, 0 = none) and an optional glyph (atlas glyph and color, 255 = none) at an opacity level. */ export class CellBuffer { block: Uint16Array; glyph: Uint8Array; color: Uint8Array; alpha: Uint8Array; /** Which layer last wrote the cell; nearer things overwrite farther ones. */ depth: Float32Array; private order: Int32Array; constructor(readonly size: number) { this.block = new Uint16Array(size); this.glyph = new Uint8Array(size); this.color = new Uint8Array(size); this.alpha = new Uint8Array(size); this.depth = new Float32Array(size); this.order = new Int32Array(size); this.clear(); } clear() { this.block.fill(0); this.glyph.fill(255); this.alpha.fill(255); this.depth.fill(-1); } /** Writes a cell if `depth` is at least the cell's current depth. Alpha is 0–255. */ set(i: number, depth: number, block: number, glyph: number, color: number, alpha = 255) { if (i < 0 || depth < this.depth[i]) return; this.depth[i] = depth; this.block[i] = block; this.glyph[i] = glyph; this.color[i] = color; this.alpha[i] = alpha; } /** * Draws blocks grouped by fill style (one fillStyle change per color), then glyphs from the * atlas (globalAlpha changes only when the level does). The context must be in CSS pixels. */ draw(ctx: CanvasRenderingContext2D, g: CellGrid, styles: readonly string[], atlas: GlyphAtlas | null) { const { block, glyph, color, alpha, order, size } = this; const counts = new Int32Array(styles.length + 1); for (let i = 0; i < size; i++) if (block[i]) counts[block[i]]++; const starts = new Int32Array(styles.length + 1); for (let b = 1; b <= styles.length; b++) starts[b] = starts[b - 1] + counts[b - 1]; const cursor = starts.slice(); for (let i = 0; i < size; i++) if (block[i]) order[cursor[block[i]]++] = i; for (let b = 1; b < styles.length; b++) { if (!counts[b]) continue; ctx.fillStyle = styles[b]; for (let k = starts[b]; k < starts[b] + counts[b]; k++) { const i = order[k]; ctx.fillRect((i % g.cols) * g.cw + 0.5, ((i / g.cols) | 0) * g.ch + 0.5, g.cw - 1, g.ch - 1); } } if (!atlas) return; // Atlas sprites are square (as tall as a cell), so center them in the narrower cell. const ox = (g.cw - atlas.cell) / 2; const oy = (g.ch - atlas.cell) / 2; let current = 255; for (let i = 0; i < size; i++) { if (glyph[i] === 255) continue; if (alpha[i] !== current) { current = alpha[i]; ctx.globalAlpha = current / 255; } atlas.draw(ctx, glyph[i], color[i], (i % g.cols) * g.cw + ox, ((i / g.cols) | 0) * g.ch + oy); } ctx.globalAlpha = 1; } } /* ------------------------------------------------------------------ colors */ /** Parses any CSS color a canvas understands into 0–255 RGB (browser only). */ export function parseColor(color: string): [number, number, number] { const c = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!c) return [0, 0, 0]; c.fillStyle = "#000"; c.fillStyle = color; c.fillRect(0, 0, 1, 1); const [r, g, b] = c.getImageData(0, 0, 1, 1).data; return [r, g, b]; } export const mix3 = (a: readonly number[], b: readonly number[], t: number) => `rgb(${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)})`;lib/glyph/canvas-loop.ts/** * The render loop every glyph and dither canvas shares: sizes the canvas to its host at a capped * pixel ratio, runs only while the host is on screen, the tab is visible, nothing is paused and * the user hasn't asked for reduced motion, and otherwise keeps one up-to-date still frame. * Framework-free: start it in an effect and dispose it in the cleanup. Browser only. */ export interface CanvasLoopFrame { /** Seconds since the previous frame (capped at 0.05), or 0 for a redraw without advancing. */ dt: number; /** Seconds the loop has been running. */ time: number; /** True under reduced motion: draw a calm, finished-looking frame. */ still: boolean; /** Host size in CSS pixels. */ width: number; height: number; /** Device pixel ratio of the canvas (capped). */ dpr: number; } export interface CanvasLoopOptions { /** Element observed for size and visibility. */ host: HTMLElement; /** Canvas sized to the host times the pixel ratio. */ canvas: HTMLCanvasElement; /** Advance by `frame.dt` and draw. Called every animation frame while running, and with dt 0 after resizes and redraws. */ render: (frame: CanvasLoopFrame) => void; /** Called after the canvas is resized, before it is redrawn. */ resize?: (frame: CanvasLoopFrame) => void; /** Read before every frame; while it returns true the loop stops and the last frame stays. */ isPaused?: () => boolean; /** Highest device pixel ratio used. @default 2 */ maxDpr?: number; /** Margin around the viewport that still counts as on screen. @default "120px" */ rootMargin?: string; } export interface CanvasLoop { /** Re-checks paused, visibility and reduced motion, and starts or stops the loop. Call it when props change. */ wake(): void; /** Draws one frame without advancing (no-op while the loop is running, since the next frame will). */ redraw(): void; /** Stops the loop and removes every observer and listener. */ dispose(): void; } export function runCanvasLoop(options: CanvasLoopOptions): CanvasLoop { const { host, canvas, rootMargin = "120px" } = options; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); let width = 0; let height = 0; let dpr = 1; let raf = 0; let last = 0; let time = 0; let inView = false; let disposed = false; const frame = (dt: number): CanvasLoopFrame => ({ dt, time, still: reducedMotion.matches, width, height, dpr }); const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !options.isPaused?.(); const tick = (now: number) => { raf = 0; if (!shouldRun()) return; const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; time += dt; options.render(frame(dt)); raf = requestAnimationFrame(tick); }; const wake = () => { if (disposed) return; if (shouldRun()) { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } return; } if (raf) cancelAnimationFrame(raf); raf = 0; }; const redraw = () => { if (!disposed && !raf && width > 0) options.render(frame(0)); }; const resize = () => { width = Math.max(1, host.clientWidth); height = Math.max(1, host.clientHeight); dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr ?? 2); const w = Math.max(1, Math.round(width * dpr)); const h = Math.max(1, Math.round(height * dpr)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } options.resize?.(frame(0)); // Resizing clears the canvas, so repaint right away even while running. options.render(frame(0)); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver( ([entry]) => { inView = entry?.isIntersecting ?? true; wake(); }, { rootMargin }, ); const onVisibility = () => wake(); const onMotionChange = () => { wake(); redraw(); }; resize(); resizeObserver.observe(host); intersection.observe(host); document.addEventListener("visibilitychange", onVisibility); reducedMotion.addEventListener("change", onMotionChange); return { wake, redraw, dispose() { disposed = true; if (raf) cancelAnimationFrame(raf); raf = 0; resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); reducedMotion.removeEventListener("change", onMotionChange); }, }; }lib/glyph/glyph-atlas.ts/** * Glyph sprite atlases: every glyph of a ramp is rendered once per color into one canvas, and * cells are blitted from it with `drawImage`. Drawing text per cell per frame (`fillText`) is far * slower, so glyph canvases never do it. Browser only. */ export interface GlyphAtlasOptions { /** Glyphs to pre-render, e.g. `rampGlyphs("classic")`. Spaces are kept as empty sprites. */ glyphs: readonly string[]; /** One tint per color, as any color a canvas accepts. Resolve CSS variables first with `resolveCanvasColors`. */ colors: readonly string[]; /** Size of one square cell in CSS pixels. */ cell: number; /** Device pixel ratio to render for, capped at 2. @default 1 */ dpr?: number; /** Font stack. @default Geist Mono (`--font-geist-mono`), then system monospace */ fontFamily?: string; /** Font weight. @default 500 */ fontWeight?: number | string; /** Glyph size as a share of the cell. @default 0.8 */ fontScale?: number; /** Largest scale a sprite will be drawn at, so enlarged glyphs stay sharp. @default 1 */ maxScale?: number; } export interface GlyphAtlas { readonly canvas: HTMLCanvasElement; readonly glyphs: readonly string[]; readonly colors: readonly string[]; /** Cell size in CSS pixels. */ readonly cell: number; /** Index of a glyph in the atlas, or -1. */ index(glyph: string): number; /** * Blits a glyph in a color with its cell's top-left corner at (x, y), in the context's current * units (CSS pixels when the context is scaled by the device pixel ratio). `scale` grows it * around the cell center. Set `globalAlpha` on the context for faded glyphs. */ draw(ctx: CanvasRenderingContext2D, glyph: number, color: number, x: number, y: number, scale?: number): void; } const FALLBACK_MONO = 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Mono", "Roboto Mono", monospace'; const SHADES: Record<string, number> = { "░": 0.25, "▒": 0.5, "▓": 0.75, "█": 1 }; const GUTTER = 1; const MAX_CACHED = 16; const cache = new Map<string, GlyphAtlas>(); /** Geist Mono as set up by the site (`--font-geist-mono`), followed by system monospace fonts. */ export function monoFontFamily(element: Element = document.documentElement): string { const geist = getComputedStyle(element).getPropertyValue("--font-geist-mono").trim(); return geist ? `${geist}, ${FALLBACK_MONO}` : FALLBACK_MONO; } /** * Turns CSS colors a canvas can't parse (`var(--primary)`, `color-mix(…)`, `currentColor`) into * computed `rgb()` strings by styling a hidden probe inside `element`. Plain colors pass through. */ export function resolveCanvasColors(colors: readonly string[], element: Element): string[] { let probe: HTMLSpanElement | null = null; const out = colors.map((color) => { if (!/var\(|color-mix\(|currentcolor/i.test(color)) return color; if (!probe) { probe = document.createElement("span"); probe.style.display = "none"; element.appendChild(probe); } probe.style.color = ""; probe.style.color = color; return getComputedStyle(probe).color || color; }); (probe as HTMLSpanElement | null)?.remove(); return out; } const fontSpec = (options: GlyphAtlasOptions, size: number) => `${options.fontWeight ?? 500} ${size}px ${options.fontFamily ?? FALLBACK_MONO}`; /** Resolves once the atlas font is loaded (or failed to load), so atlases built after it use the real face. */ export async function loadGlyphFont(options: Pick<GlyphAtlasOptions, "glyphs" | "fontFamily" | "fontWeight">): Promise<void> { try { await document.fonts?.load(fontSpec({ ...options, colors: [], cell: 16 }, 16), options.glyphs.join("").replace(/\s/g, "") || "0"); } catch { // A missing font falls back to the system monospace face. } } function createAtlas(options: GlyphAtlasOptions): GlyphAtlas { const { glyphs, colors, cell } = options; const dpr = Math.min(2, Math.max(1, options.dpr ?? 1)); const sprite = Math.max(2, Math.ceil(cell * dpr * Math.max(1, options.maxScale ?? 1))); const stride = sprite + GUTTER * 2; const canvas = document.createElement("canvas"); canvas.width = Math.max(1, glyphs.length * stride); canvas.height = Math.max(1, colors.length * stride); const ctx = canvas.getContext("2d"); if (ctx) { ctx.font = fontSpec(options, Math.round(sprite * (options.fontScale ?? 0.8))); ctx.textAlign = "center"; ctx.textBaseline = "middle"; colors.forEach((color, row) => { ctx.fillStyle = color; glyphs.forEach((glyph, col) => { const x = col * stride + GUTTER; const y = row * stride + GUTTER; const shade = SHADES[glyph]; if (shade === 1) ctx.fillRect(x, y, sprite, sprite); else if (shade) { // Shade blocks as crisp dot screens rather than whatever the font draws. const dots = 4; const size = sprite / dots; for (let dy = 0; dy < dots; dy++) { for (let dx = 0; dx < dots; dx++) { const on = shade === 0.5 ? (dx + dy) % 2 === 0 : shade === 0.25 ? dx % 2 === 0 && dy % 2 === 0 : !(dx % 2 === 1 && dy % 2 === 1); if (on) ctx.fillRect(x + dx * size, y + dy * size, Math.ceil(size), Math.ceil(size)); } } } else if (glyph.trim()) ctx.fillText(glyph, x + sprite / 2, y + sprite / 2 + sprite * 0.04); }); }); } const lookup = new Map(glyphs.map((g, i) => [g, i])); return { canvas, glyphs, colors, cell, index: (glyph) => lookup.get(glyph) ?? -1, draw(target, glyph, color, x, y, scale = 1) { if (glyph < 0 || color < 0 || glyph >= glyphs.length || color >= colors.length) return; const size = cell * scale; const offset = (size - cell) / 2; target.drawImage(canvas, glyph * stride + GUTTER, color * stride + GUTTER, sprite, sprite, x - offset, y - offset, size, size); }, }; } /** * Returns a glyph atlas for these options, building it on first use. Atlases are shared between * components with identical options. Once the font has loaded, the next call builds a fresh atlas * with the real face instead of reusing one drawn with the fallback. */ export function getGlyphAtlas(options: GlyphAtlasOptions): GlyphAtlas { const dpr = Math.min(2, Math.max(1, options.dpr ?? 1)); const fontReady = typeof document === "undefined" || !document.fonts ? true : document.fonts.check(fontSpec(options, 16)); const key = [options.glyphs.join(""), options.colors.join("|"), options.cell, dpr, options.fontFamily, options.fontWeight, options.fontScale, options.maxScale, fontReady].join("§"); const hit = cache.get(key); if (hit) { cache.delete(key); cache.set(key, hit); return hit; } const atlas = createAtlas({ ...options, dpr }); cache.set(key, atlas); while (cache.size > MAX_CACHED) cache.delete(cache.keys().next().value as string); return atlas; }Copy the source
components/ui/ascii-koi-pond/ascii-koi-pond.tsx"use client"; /* * The rendering here (top-down bodies, shaded blocks under density-matched glyphs, sub-sampled * edges, a wake field that lights the water) learns from ascii-sharks by drewdi, * https://github.com/stackedlol/ascii-sharks (MIT). This is an independent implementation. */ import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react"; import { observeTheme } from "@/lib/color"; import { cn } from "@/lib/utils"; import { runCanvasLoop, type CanvasLoop } from "@/lib/glyph/canvas-loop"; import { getGlyphAtlas, loadGlyphFont, monoFontFamily, resolveCanvasColors, type GlyphAtlas } from "@/lib/glyph/glyph-atlas"; import { CellBuffer, cellAt, cellGrid, followSpine, hash3, mix3, nearestOnSpine, parseColor, straightSpine, SUBSAMPLES, WakeField, waveSpine, type CellGrid, type SpineHit } from "@/lib/glyph/ascii-life"; export interface KoiPondColors { /** Water behind everything. */ background: string; /** Body (the pale scales). */ body: string; /** Glyphs on pale scales. */ bodyInk: string; /** Bright pattern patches (orange). */ patch: string; /** Deep pattern patches (red). */ patchDeep: string; /** Glyphs on patches. */ patchInk: string; /** Dark markings (sumi). */ dark: string; /** Eyes. */ eye: string; /** Wakes, ripples, lily pad outlines and the faint grid. */ line: string; } export type KoiPondPalette = "light" | "dark" | (Partial<KoiPondColors> & { base?: "light" | "dark" }); export interface AsciiKoiPondProps extends Omit<ComponentProps<"div">, "children"> { /** Content above the pond, e.g. a hero. */ children?: ReactNode; /** How many koi swim in the pond (1–12). @default 5 */ koi?: number; /** Cell width in px; cells are about 1.45 times as tall and glyphs scale with them. @default 8 */ cell?: number; /** Built-in light or dark colors, or your own (any CSS color). An object starts from `base`. @default "light" */ palette?: KoiPondPalette; /** Drifting lily pads drawn as outlines. @default 3 */ lilyPads?: number; /** Koi drift toward the cursor, scatter from fast moves, and a click drops food. @default true */ interactive?: boolean; /** Show how many pellets the koi have eaten in the top-right corner. @default true */ showCounter?: boolean; /** Called with the running total each time a koi eats a pellet. */ onFeed?: (total: number) => void; /** Freezes the pond on its current frame. @default false */ paused?: boolean; } const PALETTES: Record<"light" | "dark", KoiPondColors> = { light: { background: "#f7f6f2", body: "#ece9e1", bodyInk: "#6f6a5f", patch: "#ef8b3c", patchDeep: "#d2452a", patchInk: "#5e1f0c", dark: "#34322f", eye: "#f7f6f2", line: "#b4ae9f", }, dark: { background: "#11100f", body: "#b9b5ad", bodyInk: "#2a2825", patch: "#d97b2f", patchDeep: "#b33a20", patchInk: "#2a0e05", dark: "#23211f", eye: "#ece9e2", line: "#4a4743", }, }; /** Koi varieties: how much of the body carries pattern, and which patch colors. */ const VARIETIES = [ { patch: 0.4, deep: 0.5, sumi: 0 }, // kohaku-like: white with red-orange { patch: 0.34, deep: 0.25, sumi: 0.12 }, // showa-like: with black markings { patch: 0.6, deep: 0.15, sumi: 0 }, // mostly orange { patch: 0.28, deep: 0.8, sumi: 0.04 }, // pale with deep red ] as const; const SEGMENTS = 16; const SHADES = 10; const GLYPHS = [..." .'`,:;-=+*o%#@\\|/~"]; const G = Object.fromEntries(GLYPHS.map((g, i) => [g, i])) as Record<string, number>; /** Sparse glyphs on lit scales (none on the brightest), dense ones in shadow. */ const RAMP = [255, G["."], G[":"], G[";"], G["+"], G["*"], G["%"], G["#"], G["@"]]; const SLOPE = [G["-"], G["\\"], G["|"], G["/"]]; /** Atlas color slots. */ const C = { bodyInk: 0, patchInk: 1, line: 2, eye: 3, food: 4, sumiInk: 5, fin: 6 } as const; interface Koi { /** Spine points, head first: [x0, y0, x1, y1, …]. */ nodes: Float32Array; heading: number; speed: number; length: number; width: number; seed: number; variety: (typeof VARIETIES)[number]; phase: number; wander: number; } interface Pad { x: number; y: number; r: number; vx: number; vy: number; spin: number; notch: number; } const hash = (a: number, b: number, c = 0) => hash3(a, b, c); /** Smooth value noise on a grid, 0–1. */ function noise(x: number, y: number, seed: number) { const xi = Math.floor(x); const yi = Math.floor(y); const xf = x - xi; const yf = y - yi; const u = xf * xf * (3 - 2 * xf); const v = yf * yf * (3 - 2 * yf); const a = hash(xi, yi, seed); const b = hash(xi + 1, yi, seed); const c = hash(xi, yi + 1, seed); const d = hash(xi + 1, yi + 1, seed); return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; } /** Body half-width along the spine (0 head → 1 tail tip), as a share of the full width. */ function profile(t: number) { if (t < 0.1) return 0.5 + Math.sin((t / 0.1) * Math.PI * 0.5) * 0.4; if (t < 0.32) return 0.9 + Math.sin(((t - 0.1) / 0.22) * Math.PI * 0.5) * 0.1; if (t < 0.8) return 1 - ((t - 0.32) / 0.48) ** 1.3 * 0.84; return 0.16; } function resolvePalette(palette: KoiPondPalette): KoiPondColors { if (palette === "light" || palette === "dark") return PALETTES[palette]; return { ...PALETTES[palette.base ?? "light"], ...palette }; } /** A line glyph for an angle: -, \, | or /. */ const slope = (angle: number) => SLOPE[((Math.round(((angle % Math.PI) + Math.PI) / (Math.PI / 4)) % 4) + 4) % 4]; /** * An ASCII koi pond seen from above. Each koi is a follow-the-leader spine with a swaying tail, * drawn as shaded blocks (pale scales, orange and red patches, dark markings) under glyphs that * thicken into shadow, with soft supersampled edges, fanned fins and eyes. Koi drift toward the * cursor, scatter from fast moves and race for food dropped with a click; their wakes light the * water and fade, and lily pads drift past as outlines. Glyphs come from a shared atlas; the canvas * pauses offscreen and in hidden tabs and draws one still frame under reduced motion. */ export function AsciiKoiPond({ children, koi: koiCount = 5, cell = 8, palette = "light", lilyPads = 3, interactive = true, showCounter = true, onFeed, paused = false, className, style, ...props }: AsciiKoiPondProps) { const rootRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const [fed, setFed] = useState(0); const colors = resolvePalette(palette); const colorKey = JSON.stringify(colors); // A palette made of theme variables is re-resolved after a theme switch. const themed = /var\(|color-mix\(|currentcolor/i.test(colorKey); const [themeKey, setThemeKey] = useState(0); useEffect(() => (themed ? observeTheme(() => setThemeKey((k) => k + 1)) : undefined), [themed]); const live = useRef({ paused, interactive, onFeed, loop: null as CanvasLoop | null }); useEffect(() => { live.current.paused = paused; live.current.interactive = interactive; live.current.onFeed = onFeed; live.current.loop?.wake(); }); useEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!root || !canvas || !ctx) return; const state = live.current; const pal: KoiPondColors = JSON.parse(colorKey); const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const fontFamily = monoFontFamily(root); const rgb = (c: string) => parseColor(resolveCanvasColors([c], root)[0]); const [bgRgb, lineRgb] = [rgb(pal.background), rgb(pal.line)]; // Fins are drawn in whichever of body or body ink stands out more from the water. const [bodyRgb, bodyInkRgb] = [rgb(pal.body), rgb(pal.bodyInk)]; const contrast = (c: readonly number[]) => Math.abs(c[0] + c[1] + c[2] - bgRgb[0] - bgRgb[1] - bgRgb[2]); const finInk = mix3(contrast(bodyRgb) > contrast(bodyInkRgb) ? bodyRgb : bodyInkRgb, bgRgb, 0.15); const atlasColors = resolveCanvasColors([pal.bodyInk, pal.patchInk, pal.line, pal.eye, pal.patchDeep, pal.body, finInk], root); // Shaded block ramps per pattern layer: from a shadowed tone to a sunlit one. const blockStyles = [""]; for (const base of [pal.body, pal.patch, pal.patchDeep, pal.dark]) { const c = rgb(base); const shadow = c.map((v) => v * 0.62); const lit = c.map((v) => v + (255 - v) * 0.22); for (let l = 0; l < SHADES; l++) blockStyles.push(mix3(shadow, lit, l / (SHADES - 1))); } const padStyle = blockStyles.length; blockStyles.push(mix3(lineRgb, bgRgb, 0.84)); const blockIndex = (layer: number, shade: number) => 1 + layer * SHADES + Math.min(SHADES - 1, Math.max(0, Math.round(shade * (SHADES - 1)))); const wakeStyles = [0.35, 0.5, 0.65, 0.8, 0.95].map((a) => `rgba(${lineRgb.join(",")},${a})`); let grid: CellGrid = cellGrid(1, 1, cell); let width = 1; let height = 1; let dpr = 1; let atlas: GlyphAtlas | null = null; let wake = new WakeField(1, 1); let cells = new CellBuffer(1); let background: HTMLCanvasElement | null = null; let disposed = false; let time = 0; let total = 0; const fish: Koi[] = []; const pads: Pad[] = []; const food: { x: number; y: number; t: number }[] = []; const pointer = { x: -1e4, y: -1e4, vx: 0, vy: 0, inside: false, t: 0, stamp: 0 }; const count = Math.max(1, Math.min(12, Math.round(koiCount))); const hit: SpineHit = { s: 0, v: 0, dist: 0, nx: 0, ny: 0 }; const spine = new Float32Array((SEGMENTS + 1) * 2); const arc = new Float32Array(SEGMENTS + 1); const buildAtlas = () => { atlas = getGlyphAtlas({ glyphs: GLYPHS, colors: atlasColors, cell: grid.ch, dpr, fontFamily, fontWeight: 600, fontScale: 0.74 }); }; const paintBackground = () => { background ??= document.createElement("canvas"); background.width = Math.round(width * dpr); background.height = Math.round(height * dpr); const g = background.getContext("2d"); if (!g) return; g.fillStyle = `rgb(${bgRgb.join(",")})`; g.fillRect(0, 0, background.width, background.height); g.strokeStyle = `rgba(${lineRgb.join(",")},0.16)`; g.lineWidth = 1; g.beginPath(); for (let c = 0; c <= grid.cols; c++) { const x = Math.round(c * grid.cw * dpr) + 0.5; g.moveTo(x, 0); g.lineTo(x, background.height); } for (let r = 0; r <= grid.rows; r++) { const y = Math.round(r * grid.ch * dpr) + 0.5; g.moveTo(0, y); g.lineTo(background.width, y); } g.stroke(); }; const spawn = (i: number): Koi => { // At least ~24 cells long so the shading has room, whatever the cell size. const length = Math.max(grid.cw * 24, Math.min(Math.max(300, grid.cw * 32), Math.min(width, height) * 0.44)) * (0.8 + hash(i, 1) * 0.35); const x = width * (0.15 + hash(i, 2) * 0.7); const y = height * (0.15 + hash(i, 3) * 0.7); const heading = hash(i, 4) * Math.PI * 2; return { nodes: straightSpine(SEGMENTS + 1, x, y, heading, length), heading, speed: 24 + hash(i, 5) * 16, length, width: length * (0.22 + hash(i, 6) * 0.04), seed: 1000 + i * 97, variety: VARIETIES[Math.floor(hash(i, 7) * VARIETIES.length)], phase: hash(i, 8) * 10, wander: hash(i, 9) * Math.PI * 2, }; }; /* ---------------------------------------------------------- simulation */ const steer = (k: Koi, dt: number) => { const hx = k.nodes[0]; const hy = k.nodes[1]; let urgency = 1.2; let speed = k.speed; k.wander += (noise(time * 0.15, k.seed * 0.01, k.seed) - 0.5) * dt * 3; let target = k.wander; // Food first, then a curious drift toward the cursor. let pellet: (typeof food)[number] | null = null; let best = Infinity; for (const f of food) { const d = Math.hypot(f.x - hx, f.y - hy); if (d < best) { best = d; pellet = f; } } if (pellet && best < Math.max(width, height)) { target = Math.atan2(pellet.y - hy, pellet.x - hx); urgency = 3.2; speed = k.speed * 2.1; if (best < k.width * 0.6) { food.splice(food.indexOf(pellet), 1); total += 1; wake.ring(grid, pellet.x, pellet.y, grid.cw * 2, 1.1); setFed(total); state.onFeed?.(total); } } else if (pointer.inside && state.interactive) { const d = Math.hypot(pointer.x - hx, pointer.y - hy); const fast = Math.hypot(pointer.vx, pointer.vy) > 900 && time - pointer.t < 0.15; if (fast && d < k.length * 1.6) { // Startled: dart away. target = Math.atan2(hy - pointer.y, hx - pointer.x); urgency = 5; speed = k.speed * 3; } else if (d < Math.max(width, height) * 0.45 && d > k.length * 0.5) { target = Math.atan2(pointer.y - hy, pointer.x - hx) + Math.sin(time * 0.7 + k.phase) * 0.6; urgency = 1.6; } } // Keep a little apart from the others. for (const o of fish) { if (o === k) continue; const dx = hx - o.nodes[0]; const dy = hy - o.nodes[1]; const d = Math.hypot(dx, dy); if (d > 0 && d < k.width * 2.2) target = Math.atan2(Math.sin(target) + (dy / d) * 0.8, Math.cos(target) + (dx / d) * 0.8); } // Turn back from the edges. const margin = k.length * 0.6; if (hx < -margin * 0.4 || hx > width + margin * 0.4 || hy < -margin * 0.4 || hy > height + margin * 0.4) { target = Math.atan2(height / 2 - hy, width / 2 - hx); urgency = 2.4; } let delta = target - k.heading; delta = Math.atan2(Math.sin(delta), Math.cos(delta)); const v = speed * (0.85 + Math.sin(time * 2 + k.phase) * 0.15); // Turn no tighter than a circle about a third of the body long, so koi glide in arcs. const maxTurn = v / (k.length * (urgency > 3 ? 0.22 : 0.34)); k.heading += Math.max(-maxTurn, Math.min(maxTurn, delta * urgency)) * dt; k.wander = k.heading + Math.atan2(Math.sin(k.wander - k.heading), Math.cos(k.wander - k.heading)) * 0.98; k.nodes[0] += Math.cos(k.heading) * v * dt; k.nodes[1] += Math.sin(k.heading) * v * dt; followSpine(k.nodes, k.heading, k.length / SEGMENTS, 0.13); // The tail sweeps the water. for (let s = SEGMENTS - 5; s <= SEGMENTS; s++) wake.add(cellAt(grid, k.nodes[s * 2], k.nodes[s * 2 + 1]), 0.05 + v * 0.0016); }; const step = (dt: number) => { time += dt; for (const k of fish) steer(k, dt); for (const p of pads) { p.x += p.vx * dt; p.y += p.vy * dt; p.spin += dt * 0.05; if (p.x < -p.r) p.x = width + p.r; if (p.x > width + p.r) p.x = -p.r; if (p.y < -p.r) p.y = height + p.r; if (p.y > height + p.r) p.y = -p.r; } for (let i = food.length - 1; i >= 0; i--) if (time - food[i].t > 12) food.splice(i, 1); if (pointer.inside && state.interactive) wake.stamp(grid, pointer.x, pointer.y, 1, 0.05); wake.step(dt, 1.2); }; /* ---------------------------------------------------------- drawing */ const shadeOut = { shade: 0 }; /** Classifies a point against the loaded spine: 0 miss, 1 body, 2 fin or tail, 3 dorsal ridge. */ const sample = (k: Koi, totalLen: number, px: number, py: number) => { nearestOnSpine(spine, arc, totalLen, px, py, hit); const t = hit.s; const half = (profile(t) * k.width) / 2; if (hit.dist <= half && t < 0.8) { const across = hit.dist / (half || 1); // A rounded back lit from the top left. const facing = (hit.nx * -0.6 + hit.ny * -0.8) * across; shadeOut.shade = 0.68 - across * across * 0.44 + facing * 0.26; // The dorsal fin runs down the middle of the back as a thin bright ridge. return t > 0.3 && t < 0.62 && hit.dist < k.width * 0.07 ? 3 : 1; } // Pectoral fins sweep back from behind the head, small pelvic fins further down. const pec = (t - 0.17) / 0.17; const pel = (t - 0.5) / 0.08; const fin = (pec > 0 && pec < 1 && hit.dist < half + k.width * 0.36 * Math.sin(pec * Math.PI) * (1 - pec * 0.4)) || (pel > 0 && pel < 1 && hit.dist < half + k.width * 0.14 * Math.sin(pel * Math.PI)); // The tail fans out into a shallow fork; nothing past its tip. const fan = (t - 0.8) / 0.2; const tail = fan > 0 && t < 0.998 && hit.dist < k.width * (0.08 + 0.42 * fan) && !(fan > 0.72 && hit.dist < k.width * 0.5 * (fan - 0.72)); if (fin || tail) { shadeOut.shade = 0.5; return 2; } return 0; }; const drawKoi = (k: Koi, order: number) => { const totalLen = waveSpine(k.nodes, spine, arc, time * 6 + k.phase, k.width * 0.2, 3.2); let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (let s = 0; s <= SEGMENTS; s++) { minX = Math.min(minX, spine[s * 2]); maxX = Math.max(maxX, spine[s * 2]); minY = Math.min(minY, spine[s * 2 + 1]); maxY = Math.max(maxY, spine[s * 2 + 1]); } const pad = k.width; const c0 = Math.max(0, Math.floor((minX - pad) / grid.cw)); const c1 = Math.min(grid.cols - 1, Math.floor((maxX + pad) / grid.cw)); const r0 = Math.max(0, Math.floor((minY - pad) / grid.ch)); const r1 = Math.min(grid.rows - 1, Math.floor((maxY + pad) / grid.ch)); const depth = 1 + order * 0.01; // Eyes: the cells on either side of the head. const ex = spine[2]; const ey = spine[3]; const dirX = spine[0] - spine[4]; const dirY = spine[1] - spine[5]; const dl = Math.hypot(dirX, dirY) || 1; const eyes = [-1, 1].map((side) => cellAt(grid, ex - (dirY / dl) * side * k.width * 0.24, ey + (dirX / dl) * side * k.width * 0.24)); for (let r = r0; r <= r1; r++) { for (let c = c0; c <= c1; c++) { const x = c * grid.cw; const y = r * grid.ch; const flag = sample(k, totalLen, x + grid.cw / 2, y + grid.ch / 2); if (!flag && hit.dist > pad) continue; const shade = shadeOut.shade; const t = hit.s; const side = Math.max(-1, Math.min(1, hit.v / Math.max(1, (profile(t) * k.width) / 2))); // Fin rays fan out from the body and sweep back toward the tail. const normal = Math.atan2(hit.ny, hit.nx); const rayAngle = normal + (t >= 0.8 ? -Math.sign(hit.v) * 1.2 : Math.sign(hit.v) * 0.7); let hits = flag ? 1 : 0; for (let s = 0; s < SUBSAMPLES.length; s += 2) if (sample(k, totalLen, x + SUBSAMPLES[s] * grid.cw, y + SUBSAMPLES[s + 1] * grid.ch)) hits++; if (!hits) continue; const i = r * grid.cols + c; const key = (hash(Math.round(t * 40), Math.round(side * 4), k.seed) * 4294967295) >>> 0; if (eyes.includes(i)) { cells.set(i, depth + 0.5, blockIndex(3, 0.1), G.o, C.eye); continue; } if (flag === 2) { // Fins and tail: translucent, just their rays over the water. cells.set(i, depth - 0.2, 0, slope(rayAngle), C.fin, hits >= 4 ? 230 : 130); continue; } if (flag === 3 && hits >= 3) { cells.set(i, depth, blockIndex(0, 0.95), slope(normal + Math.PI / 2), C.bodyInk, 170); continue; } if (!flag || hits < 3) { // Soft edge: a light glyph, no block. cells.set(i, depth - 0.5, 0, [G["."], G["'"], G[","], G["`"], G[":"]][(key >>> 8) % 5], C.bodyInk, Math.round((0.35 + hits * 0.12) * 255)); continue; } // Pattern in body coordinates, so markings travel with the fish. const n = noise(t * 7, side * 1.8 + 10, k.seed); const m = noise(t * 9 + 40, side * 2.2, k.seed + 7); let layer = 0; let ink: number = C.bodyInk; if (k.variety.sumi > 0 && m < k.variety.sumi + 0.15 && t > 0.1) { layer = 3; ink = C.sumiInk; } else if (n < k.variety.patch && t > 0.04 && t < 0.84) { layer = noise(t * 4, side + 3, k.seed + 3) < k.variety.deep ? 2 : 1; ink = C.patchInk; } const glyph = RAMP[Math.max(0, Math.min(RAMP.length - 1, Math.floor((1 - shade) ** 1.25 * (RAMP.length - 0.01)) + (((key >>> 4) % 3) - 1)))]; cells.set(i, depth, blockIndex(layer, shade), glyph, ink, 200); } } }; const drawPads = () => { for (const p of pads) { const c0 = Math.max(0, Math.floor((p.x - p.r) / grid.cw)); const c1 = Math.min(grid.cols - 1, Math.floor((p.x + p.r) / grid.cw)); const r0 = Math.max(0, Math.floor((p.y - p.r) / grid.ch)); const r1 = Math.min(grid.rows - 1, Math.floor((p.y + p.r) / grid.ch)); for (let r = r0; r <= r1; r++) { for (let c = c0; c <= c1; c++) { const dx = (c + 0.5) * grid.cw - p.x; const dy = ((r + 0.5) * grid.ch - p.y) / 0.9; const d = Math.hypot(dx, dy) / p.r; if (d > 1) continue; // A wedge-shaped notch cut toward the center. const a = Math.atan2(dy, dx); const rel = Math.atan2(Math.sin(a - p.notch - p.spin), Math.cos(a - p.notch - p.spin)); if (Math.abs(rel) < 0.3 && d > 0.12) continue; const i = r * grid.cols + c; if (d > 1 - grid.cw / p.r) cells.set(i, 0.3, padStyle, slope(a + Math.PI / 2), C.line, 230); else cells.set(i, 0.3, padStyle, hash(c, r, 5) < 0.12 ? slope(a) : 255, C.line, 120); } } } }; const render = () => { ctx.setTransform(1, 0, 0, 1, 0, 0); if (background) ctx.drawImage(background, 0, 0); if (!fish.length) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); cells.clear(); drawPads(); fish.forEach((k, order) => drawKoi(k, order)); for (const f of food) cells.set(cellAt(grid, f.x, f.y), 0.4, 0, G["*"], C.food); // Wakes and ripples light the water. const e = wake.energy; const size = Math.max(2, Math.round(grid.cw * 0.3)); for (let level = 0; level < wakeStyles.length; level++) { ctx.fillStyle = wakeStyles[level]; for (let i = 0; i < e.length; i++) { if (e[i] < 0.16 || cells.depth[i] >= 0) continue; if (Math.min(wakeStyles.length - 1, Math.floor((e[i] - 0.16) * 5)) !== level) continue; ctx.fillRect(Math.round((i % grid.cols) * grid.cw + grid.cw * 0.6), Math.round(((i / grid.cols) | 0) * grid.ch + grid.ch * 0.6), size, size); } } cells.draw(ctx, grid, blockStyles, atlas); }; const loop = runCanvasLoop({ host: root, canvas, isPaused: () => state.paused, resize: (frame) => { width = frame.width; height = frame.height; dpr = frame.dpr; const next = cellGrid(width, height, Math.max(6, Math.round(cell))); if (next.cols !== grid.cols || next.rows !== grid.rows) { wake = new WakeField(next.cols, next.rows); cells = new CellBuffer(next.cols * next.rows); } grid = next; buildAtlas(); paintBackground(); if (!fish.length) for (let i = 0; i < count; i++) fish.push(spawn(i)); if (!pads.length) { for (let i = 0; i < lilyPads; i++) { pads.push({ x: width * (0.1 + hash(i, 21) * 0.8), y: height * (0.1 + hash(i, 22) * 0.8), r: grid.cw * (3.5 + hash(i, 23) * 2.5), vx: (hash(i, 24) - 0.5) * 4, vy: (hash(i, 25) - 0.5) * 3, spin: hash(i, 26) * 6, notch: hash(i, 27) * Math.PI * 2, }); } } // Still frames show the koi mid-swim rather than in a straight line. if (frame.still && time === 0) for (let s = 0; s < 120; s++) step(1 / 60); }, render: ({ dt }) => { if (dt > 0) step(dt); render(); }, }); state.loop = loop; const local = (e: PointerEvent) => { const rect = root.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; const onMove = (e: PointerEvent) => { if (e.pointerType === "touch") return; const p = local(e); const dt = Math.max(0.008, (e.timeStamp - pointer.stamp) / 1000); pointer.vx = (p.x - pointer.x) / dt; pointer.vy = (p.y - pointer.y) / dt; pointer.x = p.x; pointer.y = p.y; pointer.stamp = e.timeStamp; pointer.t = time; pointer.inside = true; }; const onLeave = () => { pointer.inside = false; }; const onDown = (e: PointerEvent) => { if (!state.interactive || reduceMotion.matches || state.paused) return; // Clicks on content (links, buttons, fields) are theirs, not food. if ((e.target as HTMLElement).closest("a, button, input, textarea, select, label, [role=button]")) return; const p = local(e); for (let i = 0; i < 3; i++) food.push({ x: p.x + (hash(food.length + i, total, 3) - 0.5) * grid.cw * 2, y: p.y + (hash(food.length + i, total, 4) - 0.5) * grid.ch * 2, t: time }); wake.ring(grid, p.x, p.y, grid.cw * 2.5, 1.2); if (food.length > 24) food.splice(0, food.length - 24); loop.wake(); }; root.addEventListener("pointermove", onMove); root.addEventListener("pointerleave", onLeave); root.addEventListener("pointerdown", onDown); loadGlyphFont({ glyphs: GLYPHS, fontFamily, fontWeight: 600 }).then(() => { if (disposed) return; buildAtlas(); loop.redraw(); }); return () => { disposed = true; state.loop = null; loop.dispose(); root.removeEventListener("pointermove", onMove); root.removeEventListener("pointerleave", onLeave); root.removeEventListener("pointerdown", onDown); }; }, [koiCount, cell, lilyPads, colorKey, themeKey]); return ( <div ref={rootRef} className={cn("relative isolate overflow-hidden font-mono", className)} style={{ background: colors.background, ...style }} {...props}> <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 size-full" /> {showCounter ? ( <span aria-hidden="true" className="pointer-events-none absolute top-3 right-4 font-mono text-[11px] tabular-nums" style={{ color: colors.bodyInk }}> fed {fed} </span> ) : null} {children} </div> ); }
Usage
import { AsciiKoiPond } from "@/components/ui/ascii-koi-pond/ascii-koi-pond";
<AsciiKoiPond koi={5} palette="light" className="h-[480px]">
<Hero />
</AsciiKoiPond>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | – | Content above the pond, e.g. a hero. |
koi | number | 5 | How many koi swim in the pond (1–12). |
cell | number | 8 | Cell width in px; cells are about 1.45 times as tall and glyphs scale with them. |
palette | KoiPondPalette | "light" | Built-in light or dark colors, or your own (any CSS color). An object starts from `base`. |
lilyPads | number | 3 | Drifting lily pads drawn as outlines. |
interactive | boolean | true | Koi drift toward the cursor, scatter from fast moves, and a click drops food. |
showCounter | boolean | true | Show how many pellets the koi have eaten in the top-right corner. |
onFeed | (total: number) => void | – | Called with the running total each time a koi eats a pellet. |
paused | boolean | false | Freezes the pond on its current frame. |
Also accepts every native <div> attribute, including ref and className.
- Seen from above, each koi is a follow-the-leader spine of 16 segments with a body profile (head, shoulders, tapering body) and a travelling wave that grows toward the tail. Cells are about 1.45 times as tall as they are wide, like a terminal.
- Every covered cell gets a shaded block lit from the top left and a glyph whose density follows the shade: sparse . : on lit scales, dense % # @ in shadow. Five sub-samples per cell decide coverage, so partly covered edge cells get a light glyph instead of a hard block. Fins and the tail are translucent blocks with fin rays (- \\ | /).
- Patterns (pale scales, orange and red patches, dark markings) come from value noise in each fish's own body coordinates, so they travel with the fish. Four varieties are mixed in.
- Wakes and food ripples go into a diffusing energy field that lights small dots in the water and fades. Lily pads drift past as notched outlines. Pass `palette` as an object to recolor any part; it starts from `base`.
- The rendering approach (top-down bodies, shaded blocks with density-matched glyphs, sub-sampled edges, a wake field) learns from ascii-sharks by drewdi (MIT); this is an independent implementation.
Accessibility
- The pond is a decorative canvas (aria-hidden) behind your content, which keeps its own semantics and tab order. The fed counter is decorative too; use onFeed to announce it if it matters.
- Clicks on links, buttons and form fields belong to them, never become food. Following and scattering react to a mouse or pen only, so touch scrolling is untouched.
- Under reduced motion the pond draws a single still frame and clicks don't drop food. It pauses offscreen and in hidden tabs.