260 lines
8.9 KiB
TypeScript
260 lines
8.9 KiB
TypeScript
'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<HTMLDivElement>(null);
|
|
const tooltipRef = useRef<HTMLDivElement>(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 (
|
|
<div
|
|
ref={mountRef}
|
|
className="relative h-[400px] w-full select-none sm:h-[460px] lg:h-[520px]"
|
|
>
|
|
<div
|
|
ref={tooltipRef}
|
|
className="pointer-events-none absolute left-0 top-0 z-10 whitespace-nowrap rounded-full border bg-base-900/80 px-3 py-1 font-mono text-[0.72rem] tracking-wide backdrop-blur transition-opacity duration-150"
|
|
style={{ opacity: 0 }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|