"use client"; import { useEffect, useRef } from "react"; import { useReducedMotion } from "motion/react"; type ParticleGalaxyProps = { /** Number of stars. Keep it under 120000 on phones. */ count?: number; /** Six-digit hex colour of the young stars in the arms. */ arms?: string; /** Six-digit hex colour of the old stars at the centre. */ core?: string; /** Six-digit hex colour of the small gas clouds along the arms. */ gas?: string; /** Six-digit hex colour of the sky behind. */ background?: string; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; /** Name read out by screen readers. */ label?: string; /** Set the size here. */ className?: string; }; const MAX_PIXELS = 1600000; /* The arms are not drawn. Each star runs its own ellipse, each ellipse is turned a little more than the one inside it, and stars linger at the ends of their ellipse. The crowding reads as two arms that never wind up. */ const VERTEX = `attribute vec4 s; uniform vec2 turn,lens; uniform vec3 arm,core,gas; uniform float time,aspect,height,bend; varying vec4 tone; void main(){ float kind=floor(s.w),mag=fract(s.w); vec2 ndc; float w=1.; if(kind>3.5&&kind<4.5){ ndc=s.xy; tone=vec4(vec3(.32+mag*.5)*vec3(.9,.95,1.),1.); gl_PointSize=(1.2+mag*1.5)*height/640.; }else{ float a=s.x,th=s.y+time*.11/(.12+a); float pull=kind<.5?.3:kind<1.5?.9:.96; float phi=th-pull*.5*sin(2.*th); float e=.32*smoothstep(0.,.18,a)*(1.-.4*a); vec2 q=vec2(a*cos(phi),a*(1.-e)*sin(phi)); float tw=1.8*log(a+.05)+time*.012-(kind>2.5?.17:0.); float c=cos(tw),n=sin(tw); vec3 p=vec3(q.x*c-q.y*n,s.z*(.016+a*.72*exp(-a*a*12.)),q.x*n+q.y*c); c=cos(turn.x);n=sin(turn.x); p=vec3(c*p.x+n*p.z,p.y,-n*p.x+c*p.z); c=cos(turn.y);n=sin(turn.y); p=vec3(p.x,c*p.y-n*p.z,n*p.y+c*p.z); p=vec3(.91*p.x-.41*p.y,.41*p.x+.91*p.y,p.z); w=1.-p.z/2.9; ndc=p.xy/w*min(aspect*.92,1.22)*vec2(1./aspect,1.); float inner=exp(-a*a*18.); float edge=smoothstep(1.02,.55,a); if(kind>4.5){ tone=vec4(mix(arm,core,inner)*.012*edge,1.); gl_PointSize=(26.+mag*30.)*height/640./w; }else if(kind<.5){ tone=vec4(mix(mix(arm,core,.72),core*1.08,inner)*.24*(.5+mag)*edge,1.); gl_PointSize=(1.7+mag*1.5)*height/640./w; }else if(kind<1.5){ tone=vec4(arm*.5*(.45+mag)*edge,1.); gl_PointSize=(1.6+mag*1.8)*height/640./w; }else if(kind<2.5){ tone=vec4(gas*.13*edge,1.); gl_PointSize=(4.+mag*7.)*height/640./w; }else{ tone=vec4(0.,0.,0.,.055*(1.-inner*.85)*edge); gl_PointSize=(15.+mag*18.)*height/640./w; } } vec2 v=ndc-lens; v.x*=aspect; vec2 shift=v/(dot(v,v)+.0016)*bend; shift.x/=aspect; gl_Position=vec4(ndc+shift,0.,1.); }`; const FRAGMENT = `precision mediump float; varying vec4 tone; void main(){ vec2 c=gl_PointCoord-.5; gl_FragColor=tone*exp(-dot(c,c)*14.); }`; const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); function seeded(start: number) { let n = start; return () => { n = (n + 0x6d2b79f5) | 0; let t = Math.imul(n ^ (n >>> 15), 1 | n); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /* radius, phase, height, kind + brightness. Stars come first, the dark dust last, so it can be drawn over them. */ function buildSky(count: number) { const random = seeded(7); const gauss = () => (random() + random() + random() - 1.5) / 0.75; const stars = Math.round(count); const dust = Math.round(count * 0.16); const data = new Float32Array((stars + dust) * 4); for (let i = 0; i < stars + dust; i++) { const roll = random(); const kind = i >= stars ? 3 : roll < 0.02 ? 4 : roll < 0.4 ? 1 : roll < 0.415 ? 2 : roll < 0.5 ? 5 : 0; const mag = random() * 0.98; const round = kind === 0 ? Math.pow(random(), 1.7) : Math.min(1, 0.07 - 0.36 * Math.log(1 - random() * 0.96)); const o = i * 4; if (kind === 4) { data[o] = random() * 2 - 1; data[o + 1] = random() * 2 - 1; } else { data[o] = round; data[o + 1] = random() * Math.PI * 2; data[o + 2] = gauss(); } data[o + 3] = kind + mag; } return { data, stars, dust }; } function program(gl: WebGLRenderingContext) { const made = gl.createProgram(); for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) { const shader = gl.createShader(type); if (!made || !shader) return null; gl.shaderSource(shader, text); gl.compileShader(shader); gl.attachShader(made, shader); } if (!made) return null; gl.linkProgram(made); return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null; } /* ParticleGalaxy: a tilted spiral galaxy of single stars with dark dust lanes. The mouse bends its light like a heavy unseen mass; drag or arrow keys tilt it. Runs only while on screen. */ export function ParticleGalaxy({ count = 56000, arms = "#a9c7ff", core = "#ffd9a6", gas = "#ff6f91", background = "#04050b", speed = 1, label = "A spiral galaxy of stars seen at an angle. Drag or use arrow keys to tilt it.", className = "aspect-square w-80", }: ParticleGalaxyProps) { const reduce = useReducedMotion(); const boxRef = useRef(null); const plainRef = useRef(null); const hues = [arms, core, gas, background].join(); useEffect(() => { const box = boxRef.current; const plain = plainRef.current; if (!box || !plain) return; const canvas = document.createElement("canvas"); canvas.setAttribute("aria-hidden", "true"); canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%"; const gl = canvas.getContext("webgl", { alpha: false, antialias: false }); const made = gl && program(gl); if (!gl || !made) return; gl.useProgram(made); const { data, stars, dust } = buildSky(count); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); const slot = gl.getAttribLocation(made, "s"); gl.enableVertexAttribArray(slot); gl.vertexAttribPointer(slot, 4, gl.FLOAT, false, 0, 0); const at = (name: string) => gl.getUniformLocation(made, name); const [one, two, three, sky] = hues.split(",").map(rgb); gl.uniform3fv(at("arm"), one); gl.uniform3fv(at("core"), two); gl.uniform3fv(at("gas"), three); gl.clearColor(sky[0], sky[1], sky[2], 1); gl.enable(gl.BLEND); let raf = 0; let last = 0; let inView = false; let time = reduce ? 40 : 0; let yaw = 0; let pitch = -1.14; let drift = 0; let lensX = 0; let lensY = 0; let bend = 0; let goalBend = 0; let held = false; let lastX = 0; let lastY = 0; const draw = () => { gl.uniform2f(at("turn"), yaw, pitch); gl.uniform2f(at("lens"), lensX, lensY); gl.uniform1f(at("bend"), bend); gl.uniform1f(at("time"), time % 4000); gl.clear(gl.COLOR_BUFFER_BIT); gl.blendFunc(gl.ONE, gl.ONE); gl.drawArrays(gl.POINTS, 0, stars); gl.blendFunc(gl.ZERO, gl.ONE_MINUS_SRC_ALPHA); gl.drawArrays(gl.POINTS, stars, dust); }; const frame = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000 || 0); last = now; time += dt * speed; if (!held) { yaw += drift * dt; drift *= 1 - Math.min(1, dt * 2); } bend += (goalBend - bend) * Math.min(1, dt * 5); draw(); raf = inView ? requestAnimationFrame(frame) : 0; }; const start = () => { if (inView && !raf && !reduce) raf = requestAnimationFrame(frame); }; const fit = () => { const { width, height } = box.getBoundingClientRect(); if (!width || !height) return; const scale = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (width * height))); canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform1f(at("aspect"), width / height); gl.uniform1f(at("height"), canvas.height); draw(); }; const press = (e: PointerEvent) => { held = true; goalBend = 0; lastX = e.clientX; lastY = e.clientY; box.setPointerCapture(e.pointerId); }; const move = (e: PointerEvent) => { if (held) { const dx = e.clientX - lastX; const dy = e.clientY - lastY; lastX = e.clientX; lastY = e.clientY; yaw += dx * 0.007; pitch = Math.min(-0.35, Math.max(-1.42, pitch + dy * 0.005)); drift = dx * 0.3; } else if (e.pointerType === "mouse") { const r = box.getBoundingClientRect(); lensX = ((e.clientX - r.left) / r.width - 0.5) * 2; lensY = (0.5 - (e.clientY - r.top) / r.height) * 2; goalBend = 0.007; if (reduce) bend = goalBend; } if (reduce) draw(); }; const release = () => { held = false; }; const leave = () => { goalBend = 0; if (reduce) { bend = 0; draw(); } }; const key = (e: KeyboardEvent) => { const turn = { ArrowLeft: -1, ArrowRight: 1 }[e.key] ?? 0; const tilt = { ArrowUp: -1, ArrowDown: 1 }[e.key] ?? 0; if (!turn && !tilt) return; e.preventDefault(); yaw += turn * 0.14; pitch = Math.min(-0.35, Math.max(-1.42, pitch + tilt * 0.08)); if (reduce) draw(); }; fit(); box.append(canvas); plain.hidden = true; const sized = new ResizeObserver(fit); sized.observe(box); const watch = new IntersectionObserver(([entry]) => { inView = entry.isIntersecting; start(); }); watch.observe(box); const lost = () => { canvas.remove(); plain.hidden = false; }; canvas.addEventListener("webglcontextlost", lost); box.addEventListener("pointerdown", press); box.addEventListener("pointermove", move); box.addEventListener("pointerup", release); box.addEventListener("pointercancel", release); box.addEventListener("pointerleave", leave); box.addEventListener("keydown", key); return () => { cancelAnimationFrame(raf); sized.disconnect(); watch.disconnect(); canvas.removeEventListener("webglcontextlost", lost); box.removeEventListener("pointerdown", press); box.removeEventListener("pointermove", move); box.removeEventListener("pointerup", release); box.removeEventListener("pointercancel", release); box.removeEventListener("pointerleave", leave); box.removeEventListener("keydown", key); gl.getExtension("WEBGL_lose_context")?.loseContext(); canvas.remove(); plain.hidden = false; }; }, [count, hues, speed, reduce]); return (
); }