'use client'; import { useEffect, useRef } from 'react'; import * as THREE from 'three'; export type StackNode = { label: string; color: string }; /** * An interactive 3D constellation of the tech stack. Every tool is a glowing * dot positioned on a Fibonacci sphere and tinted by its category color. The * globe auto-rotates, can be dragged to spin, and reveals a tooltip with the * tool name when a dot is hovered (raycast). Everything is torn down on unmount * — RAF, GL context, geometries, materials, textures, and listeners. */ export function StackCanvas({ nodes }: { nodes: StackNode[] }) { const mountRef = useRef(null); const tooltipRef = useRef(null); useEffect(() => { const mount = mountRef.current; const tooltip = tooltipRef.current; if (!mount || !tooltip || nodes.length === 0) return; const prefersReduced = window.matchMedia( '(prefers-reduced-motion: reduce)', ).matches; // --- Sizing ------------------------------------------------------------- let width = mount.clientWidth || 600; let height = mount.clientHeight || 460; // --- Renderer ----------------------------------------------------------- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(width, height); renderer.setClearColor(0x000000, 0); mount.appendChild(renderer.domElement); renderer.domElement.style.touchAction = 'pan-y'; renderer.domElement.style.cursor = 'grab'; // --- Scene / camera ----------------------------------------------------- const scene = new THREE.Scene(); const R = 2.6; const dist = 6.6; const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100); camera.position.set(0, 0, dist); const group = new THREE.Group(); scene.add(group); // --- Wireframe backdrop globe ------------------------------------------ const wireGeo = new THREE.IcosahedronGeometry(R, 2); const wire = new THREE.LineSegments( new THREE.WireframeGeometry(wireGeo), new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.08, }), ); wireGeo.dispose(); group.add(wire); // --- Glow sprite texture (shared) -------------------------------------- const glowCanvas = document.createElement('canvas'); glowCanvas.width = glowCanvas.height = 64; const gctx = glowCanvas.getContext('2d')!; const grad = gctx.createRadialGradient(32, 32, 0, 32, 32, 32); grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(0.25, 'rgba(255,255,255,0.85)'); grad.addColorStop(1, 'rgba(255,255,255,0)'); gctx.fillStyle = grad; gctx.fillRect(0, 0, 64, 64); const glowTex = new THREE.CanvasTexture(glowCanvas); // --- Nodes as sprites on a Fibonacci sphere ---------------------------- const golden = Math.PI * (3 - Math.sqrt(5)); const sprites: THREE.Sprite[] = []; const materials: THREE.SpriteMaterial[] = []; const n = nodes.length; nodes.forEach((node, i) => { const y = 1 - (i / Math.max(1, n - 1)) * 2; const r = Math.sqrt(Math.max(0, 1 - y * y)); const theta = i * golden; const pos = new THREE.Vector3( Math.cos(theta) * r, y, Math.sin(theta) * r, ).multiplyScalar(R); const mat = new THREE.SpriteMaterial({ map: glowTex, color: new THREE.Color(node.color), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, }); const sprite = new THREE.Sprite(mat); sprite.position.copy(pos); sprite.scale.setScalar(0.5); sprite.userData = { label: node.label, color: node.color, base: 0.5 }; group.add(sprite); sprites.push(sprite); materials.push(mat); }); // --- Interaction state -------------------------------------------------- let dragging = false; let lastX = 0; let lastY = 0; let velX = 0; let velY = 0; const auto = prefersReduced ? 0 : 0.0018; let hovered: THREE.Sprite | null = null; const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); let pointerInside = false; const onPointerDown = (e: PointerEvent) => { dragging = true; lastX = e.clientX; lastY = e.clientY; renderer.domElement.setPointerCapture(e.pointerId); renderer.domElement.style.cursor = 'grabbing'; }; const onPointerMove = (e: PointerEvent) => { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; pointerInside = true; if (dragging) { const dx = e.clientX - lastX; const dy = e.clientY - lastY; lastX = e.clientX; lastY = e.clientY; velY = dx * 0.005; velX = dy * 0.005; } }; const onPointerUp = (e: PointerEvent) => { dragging = false; try { renderer.domElement.releasePointerCapture(e.pointerId); } catch { /* noop */ } renderer.domElement.style.cursor = 'grab'; }; const onPointerLeave = () => { pointerInside = false; }; renderer.domElement.addEventListener('pointerdown', onPointerDown); renderer.domElement.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); renderer.domElement.addEventListener('pointerleave', onPointerLeave); // --- Resize ------------------------------------------------------------- const ro = new ResizeObserver(() => { width = mount.clientWidth || width; height = mount.clientHeight || height; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); }); ro.observe(mount); // --- Render loop -------------------------------------------------------- let raf = 0; const tmp = new THREE.Vector3(); const tick = () => { raf = requestAnimationFrame(tick); // Rotation: apply velocity + gentle auto-spin, with decay when idle. if (!dragging) { velY *= 0.94; velX *= 0.94; } group.rotation.y += velY + auto; group.rotation.x += velX; group.rotation.x = Math.max(-0.6, Math.min(0.6, group.rotation.x)); group.updateMatrixWorld(); // Hover raycast (only when not dragging and pointer is inside). if (pointerInside && !dragging) { raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(sprites, false); const next = (hits[0]?.object as THREE.Sprite) ?? null; if (next !== hovered) { hovered = next; } } else if (!pointerInside) { hovered = null; } // Scale + tooltip for the hovered sprite. for (const s of sprites) { const target = s === hovered ? 0.85 : 0.5; const cur = s.scale.x; s.scale.setScalar(cur + (target - cur) * 0.2); } if (hovered) { hovered.getWorldPosition(tmp); tmp.project(camera); const sx = (tmp.x * 0.5 + 0.5) * width; const sy = (-tmp.y * 0.5 + 0.5) * height; const data = hovered.userData as { label: string; color: string }; tooltip.textContent = data.label; tooltip.style.transform = `translate(${sx}px, ${sy}px) translate(-50%, -160%)`; tooltip.style.borderColor = data.color; tooltip.style.color = data.color; tooltip.style.opacity = '1'; renderer.domElement.style.cursor = 'pointer'; } else { tooltip.style.opacity = '0'; if (!dragging) renderer.domElement.style.cursor = 'grab'; } renderer.render(scene, camera); }; tick(); // --- Teardown ----------------------------------------------------------- return () => { cancelAnimationFrame(raf); ro.disconnect(); renderer.domElement.removeEventListener('pointerdown', onPointerDown); renderer.domElement.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); renderer.domElement.removeEventListener('pointerleave', onPointerLeave); materials.forEach((m) => m.dispose()); glowTex.dispose(); wire.geometry.dispose(); (wire.material as THREE.Material).dispose(); renderer.dispose(); if (renderer.domElement.parentNode === mount) { mount.removeChild(renderer.domElement); } }; }, [nodes]); return (
); }