// ui_kits/site/Landing.jsx — experimental landing. minimal title + 3 doors. // Particle field switches PHYSICS per hovered door: // about → constellation: particles pull toward 5 anchor stars + draw connecting lines // experiments → grid lock: particles snap to a flowing dot grid // fun → gravity flip: particles repel from cursor + drift upward like fireworks // idle → ambient brownian drift const DOORS = [ { id: 'about', label: 'about', hint: 'who · resume · the longer version', mood: 'constellation' }, { id: 'experiments', label: 'experiments', hint: 'prototypes · shaders · half-built', mood: 'grid' }, { id: 'fun', label: 'fun', hint: 'games · toys · things to click', mood: 'fireworks' }, ]; const Landing = ({ setRoute, mode }) => { const canvasRef = React.useRef(null); const stateRef = React.useRef({ mouse: { x: -9999, y: -9999 }, mood: 'idle' }); const [hover, setHover] = React.useState(null); const [count, setCount] = React.useState(0); React.useEffect(() => { stateRef.current.mood = hover?.mood || 'idle'; }, [hover]); React.useEffect(() => { const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); const dpr = Math.min(devicePixelRatio || 1, 2); let w = 0, h = 0; let particles = []; let stars = []; // constellation anchors let raf, t0 = performance.now(); const resize = () => { w = canvas.clientWidth; h = canvas.clientHeight; canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; const seed = () => { const target = Math.min(160, Math.floor((w * h) / 8000)); particles = Array.from({ length: target }, () => ({ x: Math.random() * w, y: Math.random() * h, vx: (Math.random() - 0.5) * 0.4, vy: (Math.random() - 0.5) * 0.4, r: 1 + Math.random() * 1.6, // each particle has a home position for grid-lock physics gx: 0, gy: 0, })); stars = []; for (let i = 0; i < 6; i++) { stars.push({ x: w * (0.2 + 0.6 * Math.random()), y: h * (0.2 + 0.6 * Math.random()) }); } }; resize(); seed(); const onResize = () => { resize(); seed(); }; addEventListener('resize', onResize); const onMove = (e) => { const r = canvas.getBoundingClientRect(); stateRef.current.mouse.x = e.clientX - r.left; stateRef.current.mouse.y = e.clientY - r.top; }; addEventListener('pointermove', onMove); const css = (name, fb) => (getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fb); const tick = () => { const { mouse, mood } = stateRef.current; const fg = css('--fg', '#fff'); const accent = css('--accent', '#f60'); const t = (performance.now() - t0) / 1000; ctx.clearRect(0, 0, w, h); // ---- per-mood physics ---- const cols = Math.round(w / 48); const rows = Math.round(h / 48); const gx0 = (w - cols * 48) / 2 + 24; const gy0 = (h - rows * 48) / 2 + 24; for (let i = 0; i < particles.length; i++) { const p = particles[i]; if (mood === 'constellation') { // pull toward nearest star, slow let nearest = stars[0], nd = Infinity; for (const s of stars) { const d = (s.x - p.x) ** 2 + (s.y - p.y) ** 2; if (d < nd) { nd = d; nearest = s; } } p.vx += (nearest.x - p.x) * 0.0008; p.vy += (nearest.y - p.y) * 0.0008; p.vx *= 0.93; p.vy *= 0.93; } else if (mood === 'grid') { // snap to grid cell home const ci = i % cols; const ri = Math.floor(i / cols) % rows; p.gx = gx0 + ci * 48 + Math.sin(t * 1.2 + i * 0.6) * 4; p.gy = gy0 + ri * 48 + Math.cos(t * 1.0 + i * 0.4) * 4; p.vx += (p.gx - p.x) * 0.08; p.vy += (p.gy - p.y) * 0.08; p.vx *= 0.78; p.vy *= 0.78; } else if (mood === 'fireworks') { // strong repulsion from cursor + upward buoyancy const dx = p.x - mouse.x, dy = p.y - mouse.y; const d2 = dx * dx + dy * dy; const R = 220; if (d2 < R * R) { const d = Math.max(8, Math.sqrt(d2)); const f = (R - d) / R; p.vx += (dx / d) * f * 1.6; p.vy += (dy / d) * f * 1.6; } p.vy -= 0.04; // gravity flipped p.vx += (Math.random() - 0.5) * 0.4; p.vx *= 0.985; p.vy *= 0.985; } else { // idle: gentle brownian drift, slight cursor repulsion p.vx += (Math.random() - 0.5) * 0.08; p.vy += (Math.random() - 0.5) * 0.08; const dx = p.x - mouse.x, dy = p.y - mouse.y; const d2 = dx * dx + dy * dy; if (d2 < 140 * 140) { const d = Math.max(8, Math.sqrt(d2)); const f = (140 - d) / 140; p.vx += (dx / d) * f * 0.6; p.vy += (dy / d) * f * 0.6; } p.vx *= 0.99; p.vy *= 0.99; } p.x += p.vx; p.y += p.vy; // wrap (fireworks lets things drift off the top intentionally — wrap from below) if (p.x < -10) p.x = w + 10; if (p.x > w + 10) p.x = -10; if (p.y < -10) p.y = h + 10; if (p.y > h + 10) p.y = -10; } // ---- per-mood rendering ---- if (mood === 'constellation') { // lines from each particle to its nearest star ctx.strokeStyle = `color-mix(in oklch, ${accent} 60%, transparent)`; ctx.lineWidth = 0.6; for (const p of particles) { let nearest = stars[0], nd = Infinity; for (const s of stars) { const d = (s.x - p.x) ** 2 + (s.y - p.y) ** 2; if (d < nd) { nd = d; nearest = s; } } if (nd < 220 * 220) { ctx.globalAlpha = 1 - Math.sqrt(nd) / 220; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(nearest.x, nearest.y); ctx.stroke(); } } ctx.globalAlpha = 1; // stars themselves for (const s of stars) { ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(s.x, s.y, 3, 0, Math.PI * 2); ctx.fill(); } } else if (mood === 'grid') { // faint grid backdrop ctx.strokeStyle = `color-mix(in oklch, ${fg} 12%, transparent)`; ctx.lineWidth = 1; ctx.beginPath(); for (let i = 0; i <= cols; i++) { ctx.moveTo(gx0 - 24 + i * 48, gy0 - 24); ctx.lineTo(gx0 - 24 + i * 48, gy0 - 24 + rows * 48); } for (let j = 0; j <= rows; j++) { ctx.moveTo(gx0 - 24, gy0 - 24 + j * 48); ctx.lineTo(gx0 - 24 + cols * 48, gy0 - 24 + j * 48); } ctx.stroke(); } else { // idle: link near particles ctx.strokeStyle = `color-mix(in oklch, ${fg} 30%, transparent)`; ctx.lineWidth = 0.6; const L = 90; for (let i = 0; i < particles.length; i++) { for (let j = i + 1; j < particles.length; j++) { const a = particles[i], b = particles[j]; const dx = a.x - b.x, dy = a.y - b.y; const d2 = dx * dx + dy * dy; if (d2 < L * L) { ctx.globalAlpha = (1 - Math.sqrt(d2) / L) * 0.5; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } } } ctx.globalAlpha = 1; } // particles for (const p of particles) { ctx.fillStyle = (mood === 'fireworks' || mood === 'constellation') ? accent : fg; ctx.globalAlpha = (mood === 'fireworks' || mood === 'constellation') ? 0.9 : 0.55; const r = mood === 'fireworks' ? p.r * 1.4 : p.r; ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; raf = requestAnimationFrame(tick); }; tick(); return () => { cancelAnimationFrame(raf); removeEventListener('pointermove', onMove); removeEventListener('resize', onResize); }; }, [mode]); const openDoor = (id) => { setCount(c => c + 1); setRoute('/' + id); }; return (
); }; window.Landing = Landing;