first commit
ci / build (push) Failing after 23s
deploy / deploy (push) Failing after 10m12s

This commit is contained in:
soroush.asadi
2026-05-31 12:47:02 +03:30
commit add78d8460
100 changed files with 15221 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
'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>
);
}