Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287'use client';
import { useEffect, useRef } from 'react';import { useReducedMotion } from '@/lib/reduced-motion';
export function VaporwaveGrid() { const canvasRef = useRef<HTMLCanvasElement>(null); const logoRef = useRef<HTMLImageElement | null>(null); const { reducedMotion } = useReducedMotion();
useEffect(() => { const canvas = canvasRef.current; if (!canvas) return;
const ctx = canvas.getContext('2d'); if (!ctx) return;
if (!logoRef.current) { logoRef.current = new Image(); logoRef.current.src = '/img/face.webp'; } const logo = logoRef.current;
let animationId: number; let w = 0; let h = 0;
function resize() { const dpr = window.devicePixelRatio || 1; const vw = window.innerWidth; canvas!.style.width = ''; canvas!.style.marginLeft = ''; const canvasLeft = canvas!.getBoundingClientRect().left; canvas!.style.width = `${vw}px`; canvas!.style.marginLeft = `${-canvasLeft}px`; w = vw; h = canvas!.getBoundingClientRect().height; canvas!.width = w * dpr; canvas!.height = h * dpr; ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); }
resize();
function render(opts: { elapsed: number; faceReveal: number; scrollOffset: number; glitch: boolean; }) { const { elapsed, faceReveal, scrollOffset, glitch } = opts;
canvas!.style.opacity = String(0.4 + 0.6 * faceReveal);
ctx!.clearRect(0, 0, w, h);
const gridRegionHeight = h * 0.4; const horizonY = h - gridRegionHeight * 0.65; const centerX = w / 2; const gridHeight = h - horizonY;
const styles = getComputedStyle(canvas!); const aquaRaw = styles.getPropertyValue('--color-aqua').trim() || '0 212 255'; const bgRaw = styles.getPropertyValue('--color-bg').trim() || '8 8 24'; const [ar, ag, ab] = aquaRaw.split(' ').map(Number); const [br, bg, bb] = bgRaw.split(' ').map(Number);
const aquaRgba = (a: number) => `rgba(${ar}, ${ag}, ${ab}, ${a})`; const bgRgb = `rgb(${br}, ${bg}, ${bb})`; const bgRgba = (a: number) => `rgba(${br}, ${bg}, ${bb}, ${a})`;
// --- 1. Sky fade --- const skyGrad = ctx!.createLinearGradient(0, 0, 0, horizonY); skyGrad.addColorStop(0, bgRgb); skyGrad.addColorStop(0.4, bgRgb); skyGrad.addColorStop(1, bgRgba(0)); ctx!.fillStyle = skyGrad; ctx!.fillRect(0, 0, w, horizonY);
const sunSize = Math.min(w * 0.15, 150);
// --- 2. Logo glow + face --- if (logo.complete && logo.naturalWidth > 0 && faceReveal > 0) { const faceY = horizonY - sunSize * 0.8 * faceReveal;
ctx!.save(); ctx!.beginPath(); ctx!.rect(0, 0, w, horizonY); ctx!.clip();
const glowAlpha = faceReveal * 0.35; const glowGrad = ctx!.createRadialGradient( centerX, horizonY, sunSize * 0.2, centerX, horizonY, sunSize * 3, ); glowGrad.addColorStop(0, `rgba(255, 51, 144, ${glowAlpha})`); glowGrad.addColorStop(0.3, `rgba(255, 51, 144, ${glowAlpha * 0.35})`); glowGrad.addColorStop(1, 'rgba(255, 51, 144, 0)'); ctx!.fillStyle = glowGrad; ctx!.fillRect(0, 0, w, h);
ctx!.restore();
const groundGlow = ctx!.createRadialGradient( centerX, horizonY, 0, centerX, horizonY + gridHeight * 0.4, w * 0.5, ); groundGlow.addColorStop(0, `rgba(255, 51, 144, ${faceReveal * 0.12})`); groundGlow.addColorStop(0.5, `rgba(255, 51, 144, ${faceReveal * 0.04})`); groundGlow.addColorStop(1, 'rgba(255, 51, 144, 0)'); ctx!.fillStyle = groundGlow; ctx!.fillRect(0, horizonY, w, gridHeight);
ctx!.save(); ctx!.beginPath(); ctx!.rect(0, 0, w, horizonY); ctx!.clip();
const glitchInterval = 8; const glitchPhase = elapsed % glitchInterval; const isGlitching = glitch && (glitchPhase < 0.5 || (glitchPhase > 3.8 && glitchPhase < 4.15));
if (isGlitching) { const numSlices = 12; const sliceH = sunSize / numSlices; const srcSliceH = logo.naturalWidth / numSlices; const frame = Math.floor(elapsed * 4); const frameFrac = (elapsed * 4) % 1;
for (let s = 0; s < numSlices; s++) { const hash = Math.sin(s * 127.1 + frame * 311.7) * 43758.5453; const nextHash = Math.sin(s * 127.1 + (frame + 1) * 311.7) * 43758.5453; const shouldOffset = (hash - Math.floor(hash)) > 0.55; const nextShouldOffset = (nextHash - Math.floor(nextHash)) > 0.55;
const targetX = shouldOffset ? ((Math.sin(s * 43.3 + frame * 17.1) * 9999) % 1 - 0.5) * sunSize * 0.3 : 0; const nextTargetX = nextShouldOffset ? ((Math.sin(s * 43.3 + (frame + 1) * 17.1) * 9999) % 1 - 0.5) * sunSize * 0.3 : 0;
const ease = frameFrac < 0.3 ? frameFrac / 0.3 : 1; const drift = Math.sin(elapsed * 5 + s * 2.3) * sunSize * 0.04; const offsetX = targetX + (nextTargetX - targetX) * ease * 0.25 + drift;
ctx!.drawImage( logo, 0, s * srcSliceH, logo.naturalWidth, srcSliceH, centerX - sunSize / 2 + offsetX, faceY + s * sliceH, sunSize, sliceH, ); }
const rgbShift = 3 + Math.sin(elapsed * 6) * 2; ctx!.globalCompositeOperation = 'lighter'; ctx!.globalAlpha = 0.12 + Math.sin(elapsed * 9) * 0.05; ctx!.drawImage(logo, centerX - sunSize / 2 - rgbShift, faceY, sunSize, sunSize); ctx!.drawImage(logo, centerX - sunSize / 2 + rgbShift, faceY, sunSize, sunSize); ctx!.globalCompositeOperation = 'source-over'; ctx!.globalAlpha = 1; } else { ctx!.drawImage(logo, centerX - sunSize / 2, faceY, sunSize, sunSize); }
ctx!.restore(); }
// --- 3. Horizontal grid lines --- const lineSpacing = 0.07; const maxDepth = 60; const minDepth = 0.1; const focalLen = gridHeight * minDepth; const offset = (scrollOffset * 0.02) % lineSpacing;
ctx!.strokeStyle = aquaRgba(1); ctx!.lineWidth = 1;
for (let depth = minDepth; depth < maxDepth; depth += lineSpacing) { let d = depth - offset; if (d < minDepth) d += lineSpacing;
const screenY = horizonY + focalLen / d; if (screenY > h + 2) continue;
const t = (screenY - horizonY) / gridHeight; ctx!.globalAlpha = Math.min(t * 0.7, 0.5); ctx!.beginPath(); ctx!.moveTo(0, screenY); ctx!.lineTo(w, screenY); ctx!.stroke(); }
// --- 4. Vertical grid lines --- const numVerticals = 16; const extendBeyond = w * 0.5;
for (let i = -numVerticals; i <= numVerticals; i++) { const spread = i / numVerticals; const bottomX = centerX + spread * (w + extendBeyond); const horizonX = centerX + spread * w * 0.05;
const t = Math.abs(spread); ctx!.globalAlpha = (1 - t * 0.5) * 0.35; ctx!.beginPath(); ctx!.moveTo(horizonX, horizonY); ctx!.lineTo(bottomX, h); ctx!.stroke(); }
ctx!.globalAlpha = 1;
// --- 5. Horizon glow --- const horizonGlowGrad = ctx!.createLinearGradient(0, horizonY - 4, 0, horizonY + 12); horizonGlowGrad.addColorStop(0, aquaRgba(0)); horizonGlowGrad.addColorStop(0.4, aquaRgba(0.3)); horizonGlowGrad.addColorStop(1, aquaRgba(0)); ctx!.fillStyle = horizonGlowGrad; ctx!.fillRect(0, horizonY - 4, w, 16); }
if (reducedMotion) { const drawStatic = () => render({ elapsed: 0, faceReveal: 1, scrollOffset: 0, glitch: false }); const onResize = () => { resize(); drawStatic(); }; const observer = new ResizeObserver(onResize); observer.observe(canvas); window.addEventListener('resize', onResize); drawStatic(); if (!logo.complete || logo.naturalWidth === 0) { logo.addEventListener('load', drawStatic, { once: true }); return () => { logo.removeEventListener('load', drawStatic); window.removeEventListener('resize', onResize); observer.disconnect(); }; } return () => { window.removeEventListener('resize', onResize); observer.disconnect(); }; }
const observer = new ResizeObserver(resize); observer.observe(canvas); window.addEventListener('resize', resize);
function draw(time: number) { const elapsed = time / 1000; const scrollY = window.scrollY; const maxScroll = document.documentElement.scrollHeight - window.innerHeight; const scrollProgress = maxScroll > 0 ? scrollY / maxScroll : 0; const scrollOffset = scrollY * 0.01;
const faceThreshold = 0.7; const faceProgress = Math.max(0, (scrollProgress - faceThreshold) / (1 - faceThreshold)); const faceReveal = Math.min(faceProgress, 1);
render({ elapsed, faceReveal, scrollOffset, glitch: true }); animationId = requestAnimationFrame(draw); }
animationId = requestAnimationFrame(draw);
return () => { cancelAnimationFrame(animationId); window.removeEventListener('resize', resize); observer.disconnect(); }; }, [reducedMotion]);
return ( <canvas ref={canvasRef} aria-hidden="true" className="not-prose" style={{ display: 'block', height: '100vh', marginTop: '-60vh', position: reducedMotion ? 'relative' : 'sticky', bottom: reducedMotion ? undefined : 0, pointerEvents: 'none', zIndex: -1, }} /> );}