diff --git a/src/components/WebGLBackground.tsx b/src/components/WebGLBackground.tsx index d233032..b8560d0 100644 --- a/src/components/WebGLBackground.tsx +++ b/src/components/WebGLBackground.tsx @@ -1,242 +1,139 @@ -import { useRef, useMemo, useEffect, useState, useCallback } from 'react'; +import { useRef, useMemo, useEffect, useState } from 'react'; import { Canvas, useFrame, useThree } from '@react-three/fiber'; -import { Text } from '@react-three/drei'; -import { EffectComposer, Bloom, ChromaticAberration, Scanline, Glitch as GlitchEffect } from '@react-three/postprocessing'; -import { GlitchMode, BlendFunction } from 'postprocessing'; +import { EffectComposer, Bloom, ChromaticAberration, Scanline } from '@react-three/postprocessing'; +import { BlendFunction } from 'postprocessing'; import * as THREE from 'three'; /* =================================================================== - WEBGL BACKGROUND — Full-page immersive dark tech layer + PCB JOURNEY — Scroll follows copper traces on a circuit board - Sections (scroll-driven): - - HERO (0-100vh): Full atom + particles + text rings - - ABOUT (100-200vh): Circuit traces + node grid - - CASES (200-300vh): Floating card planes - - SPRINTS (300-400vh): Orbital rings - - CONTACT (400vh+): Atom returns, particles intensify + The camera travels along PCB traces as the user scrolls. + Current flows through the traces as glowing particles. + Site sections are "components" soldered onto the board. =================================================================== */ const COLORS = { - cyan: new THREE.Color('#5bd1d8'), - amber: new THREE.Color('#f1c27a'), - green: new THREE.Color('#b6d18f'), - electric: new THREE.Color('#0071e3'), - nucleus: new THREE.Color('#ff6b35'), - white: new THREE.Color('#ffffff'), + copper: new THREE.Color('#c87533'), + copperBright: new THREE.Color('#e8a040'), + substrate: new THREE.Color('#1a3a1a'), + substrateDark: new THREE.Color('#0d1f0d'), + solder: new THREE.Color('#c0c0c0'), + current: new THREE.Color('#5bd1d8'), + currentWarm: new THREE.Color('#f1c27a'), + silkscreen: new THREE.Color('#e8e8d0'), + via: new THREE.Color('#888888'), }; -const FONT_URL = '/assets/fonts/manrope-regular.ttf'; +/* ---------- Scroll state ---------- */ +let scrollProgress = 0; -/* ---------- Shared scroll state ---------- */ -let globalScroll = 0; -let globalMaxScroll = 1; +/* ---------- PCB Trace path (the main route the camera follows) ---------- */ +const TRACE_POINTS = [ + // Start — top of board + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(2, 0, -4), + new THREE.Vector3(5, 0, -5), + // First bend (Hero section) + new THREE.Vector3(8, 0, -5), + new THREE.Vector3(8, 0, -8), + // Horizontal run (About) + new THREE.Vector3(5, 0, -12), + new THREE.Vector3(0, 0, -14), + // Via + layer change (Cases) + new THREE.Vector3(-3, -0.1, -16), + new THREE.Vector3(-6, -0.1, -18), + // Right angle bend (Photos) + new THREE.Vector3(-6, -0.1, -22), + new THREE.Vector3(-3, 0, -24), + // Long straight (Sprints) + new THREE.Vector3(2, 0, -26), + new THREE.Vector3(6, 0, -28), + // Final destination (Contact) + new THREE.Vector3(6, 0, -32), + new THREE.Vector3(3, 0, -35), + new THREE.Vector3(0, 0, -38), +]; -/* ---------- Particle system (persistent across sections) ---------- */ -function ParticleField({ count = 800 }) { - const ref = useRef(null); - - const { positions, velocities, colors, sizes } = useMemo(() => { - const pos = new Float32Array(count * 3); - const vel = new Float32Array(count * 3); - const col = new Float32Array(count * 3); - const sz = new Float32Array(count); - const palette = [COLORS.cyan, COLORS.amber, COLORS.green, COLORS.electric]; - - for (let i = 0; i < count; i++) { - // Spread across a tall vertical volume - pos[i * 3] = (Math.random() - 0.5) * 20; - pos[i * 3 + 1] = (Math.random() - 0.5) * 60; - pos[i * 3 + 2] = (Math.random() - 0.5) * 15; - - vel[i * 3] = (Math.random() - 0.5) * 0.003; - vel[i * 3 + 1] = (Math.random() - 0.5) * 0.002; - vel[i * 3 + 2] = (Math.random() - 0.5) * 0.003; - - const c = palette[Math.floor(Math.random() * palette.length)]; - col[i * 3] = c.r; - col[i * 3 + 1] = c.g; - col[i * 3 + 2] = c.b; - - sz[i] = 0.02 + Math.random() * 0.04; - } - return { positions: pos, velocities: vel, colors: col, sizes: sz }; - }, [count]); - - useFrame(({ clock }) => { - if (!ref.current) return; - const posAttr = ref.current.geometry.attributes.position as THREE.BufferAttribute; - const arr = posAttr.array as Float32Array; - const t = clock.getElapsedTime(); - - for (let i = 0; i < count; i++) { - arr[i * 3] += velocities[i * 3] + Math.sin(t * 0.3 + i * 0.1) * 0.001; - arr[i * 3 + 1] += velocities[i * 3 + 1]; - arr[i * 3 + 2] += velocities[i * 3 + 2]; - - // Wrap around - if (Math.abs(arr[i * 3]) > 12) arr[i * 3] *= -0.5; - if (Math.abs(arr[i * 3 + 1]) > 35) arr[i * 3 + 1] *= -0.5; - if (Math.abs(arr[i * 3 + 2]) > 8) arr[i * 3 + 2] *= -0.5; - } - posAttr.needsUpdate = true; - }); - - return ( - - - - - - - - ); +function createTraceCurve() { + return new THREE.CatmullRomCurve3(TRACE_POINTS, false, 'catmullrom', 0.3); } -/* ---------- Nucleus (Hero center) ---------- */ -function Nucleus() { - const coreRef = useRef(null); - const glowRef = useRef(null); - const shellRef = useRef(null); - - useFrame(({ clock, pointer }) => { - const t = clock.getElapsedTime(); - const scrollFade = 1; // atome fixe, toujours visible - const mouseProx = Math.max(0, 1 - Math.sqrt(pointer.x ** 2 + pointer.y ** 2)); - - if (coreRef.current) { - const s = (1 + Math.sin(t * 3) * 0.08 + mouseProx * 0.15) * scrollFade; - coreRef.current.scale.setScalar(Math.max(0.01, s)); - (coreRef.current.material as THREE.MeshStandardMaterial).emissiveIntensity = 0.8 + mouseProx * 1; - } - if (glowRef.current) { - const s = (1 + Math.sin(t * 2) * 0.15 + mouseProx * 0.3) * scrollFade; - glowRef.current.scale.setScalar(Math.max(0.01, s)); - (glowRef.current.material as THREE.MeshStandardMaterial).opacity = (0.15 + mouseProx * 0.2) * scrollFade; - } - if (shellRef.current) { - shellRef.current.rotation.y = t * (0.5 + mouseProx * 2); - shellRef.current.rotation.x = t * 0.3; - shellRef.current.scale.setScalar(Math.max(0.01, scrollFade)); - } - }); +/* ---------- Main copper trace ---------- */ +function CopperTrace() { + const curve = useMemo(() => createTraceCurve(), []); + const tubeGeo = useMemo(() => { + return new THREE.TubeGeometry(curve, 200, 0.08, 8, false); + }, [curve]); return ( - - - + {/* Main trace — copper tube */} + + - - - - - - - + {/* Glow around trace */} + + ); } -/* ---------- Orbit ring + electron trail ---------- */ -function Orbit({ tilt, speed, color, trailCount = 25 }: { - tilt: number[]; speed: number; color: THREE.Color; trailCount?: number; -}) { - const meshRef = useRef(null); - const ringRef = useRef(null); - const phase = useMemo(() => Math.random() * Math.PI * 2, []); - const dummy = useMemo(() => new THREE.Object3D(), []); - const R = 3.2; - const E = 0.38; - - const ringGeo = useMemo(() => { - const pts: THREE.Vector3[] = []; - for (let i = 0; i <= 128; i++) { - const a = (i / 128) * Math.PI * 2; - pts.push(new THREE.Vector3(Math.cos(a) * R, Math.sin(a) * R * E, 0)); - } - return new THREE.BufferGeometry().setFromPoints(pts); - }, []); - - useFrame(({ clock }) => { - if (!meshRef.current || !ringRef.current) return; - const t = clock.getElapsedTime() * speed + phase; - const scrollFade = 1; // orbites fixes - ringRef.current.scale.setScalar(Math.max(0.01, scrollFade)); - - for (let i = 0; i < trailCount; i++) { - const age = i / trailCount; - const angle = t - age * 0.8; - dummy.position.set(Math.cos(angle) * R, Math.sin(angle) * R * E, 0); - dummy.scale.setScalar(Math.max(0.001, ((1 - age) * 0.14 + 0.02))); - dummy.updateMatrix(); - meshRef.current.setMatrixAt(i, dummy.matrix); - } - meshRef.current.instanceMatrix.needsUpdate = true; - }); - - return ( - - - - - - - - - - ); -} - -/* ---------- Circuit traces (About section) ---------- */ -function CircuitTraces() { - const groupRef = useRef(null); +/* ---------- Secondary traces (decorative, branch off main) ---------- */ +function SecondaryTraces() { const traces = useMemo(() => { - const lines: { points: THREE.Vector3[]; color: THREE.Color }[] = []; - for (let i = 0; i < 30; i++) { - const pts: THREE.Vector3[] = []; - let x = (Math.random() - 0.5) * 16; - let y = -15 + (Math.random() - 0.5) * 10; - pts.push(new THREE.Vector3(x, y, (Math.random() - 0.5) * 4)); - for (let j = 0; j < 4 + Math.floor(Math.random() * 4); j++) { - // PCB-style: horizontal or vertical segments - if (Math.random() > 0.5) x += (Math.random() - 0.5) * 3; - else y += (Math.random() - 0.5) * 2; - pts.push(new THREE.Vector3(x, y, pts[0].z)); - } - lines.push({ - points: pts, - color: [COLORS.cyan, COLORS.green, COLORS.electric][Math.floor(Math.random() * 3)], - }); - } - return lines; + const paths: THREE.Vector3[][] = []; + // Branch traces at various points along the main route + const branches = [ + // Hero area branches + [new THREE.Vector3(3, 0, -4), new THREE.Vector3(3, 0, -2), new THREE.Vector3(5, 0, -1)], + [new THREE.Vector3(5, 0, -5), new THREE.Vector3(7, 0, -3), new THREE.Vector3(10, 0, -3)], + // About area + [new THREE.Vector3(2, 0, -12), new THREE.Vector3(2, 0, -10), new THREE.Vector3(4, 0, -9)], + [new THREE.Vector3(-1, 0, -13), new THREE.Vector3(-3, 0, -11), new THREE.Vector3(-5, 0, -11)], + // Cases area + [new THREE.Vector3(-4, -0.1, -17), new THREE.Vector3(-2, -0.1, -15), new THREE.Vector3(0, -0.1, -15)], + [new THREE.Vector3(-6, -0.1, -20), new THREE.Vector3(-8, -0.1, -19), new THREE.Vector3(-10, -0.1, -18)], + // Sprints area + [new THREE.Vector3(4, 0, -27), new THREE.Vector3(4, 0, -25), new THREE.Vector3(6, 0, -24)], + [new THREE.Vector3(1, 0, -27), new THREE.Vector3(-1, 0, -25), new THREE.Vector3(-3, 0, -25)], + // Contact area + [new THREE.Vector3(4, 0, -33), new THREE.Vector3(6, 0, -31), new THREE.Vector3(8, 0, -31)], + [new THREE.Vector3(1, 0, -36), new THREE.Vector3(-1, 0, -34), new THREE.Vector3(-3, 0, -34)], + ]; + + branches.forEach(pts => { + const curve = new THREE.CatmullRomCurve3(pts, false, 'catmullrom', 0.5); + const tubePts = curve.getPoints(30); + paths.push(tubePts); + }); + return paths; }, []); - useFrame(({ clock }) => { - if (!groupRef.current) return; - const scrollPos = globalScroll; - // Visible between 20-50% scroll - const fade = Math.max(0, Math.min(1, (scrollPos - 0.15) * 5)) * Math.max(0, Math.min(1, (0.45 - scrollPos) * 5)); - groupRef.current.children.forEach((child, i) => { - (child as THREE.Line).material.opacity = fade * 0.3; - }); - }); - return ( - - {traces.map((trace, i) => { - const geo = new THREE.BufferGeometry().setFromPoints(trace.points); + + {traces.map((pts, i) => { + const geo = new THREE.BufferGeometry().setFromPoints(pts); return ( - + ); })} @@ -244,35 +141,34 @@ function CircuitTraces() { ); } -/* ---------- Circuit nodes (solder points) ---------- */ -function CircuitNodes() { +/* ---------- Current flow particles along the trace ---------- */ +function CurrentFlow({ count = 60 }) { const ref = useRef(null); const dummy = useMemo(() => new THREE.Object3D(), []); - const count = 40; - - const nodePositions = useMemo(() => { - const pos: [number, number, number][] = []; - for (let i = 0; i < count; i++) { - pos.push([ - (Math.random() - 0.5) * 16, - -15 + (Math.random() - 0.5) * 10, - (Math.random() - 0.5) * 4, - ]); - } - return pos; - }, []); + const curve = useMemo(() => createTraceCurve(), []); + const phases = useMemo(() => Array.from({ length: count }, () => Math.random()), [count]); useFrame(({ clock }) => { if (!ref.current) return; const t = clock.getElapsedTime(); - const scrollPos = globalScroll; - const fade = Math.max(0, Math.min(1, (scrollPos - 0.15) * 5)) * Math.max(0, Math.min(1, (0.45 - scrollPos) * 5)); for (let i = 0; i < count; i++) { - const [x, y, z] = nodePositions[i]; - dummy.position.set(x, y, z); - const pulse = 1 + Math.sin(t * 2 + i) * 0.3; - dummy.scale.setScalar(0.06 * pulse * fade); + // Each particle flows along the curve + const progress = ((phases[i] + t * 0.06) % 1); + const pos = curve.getPointAt(progress); + + // Add slight jitter + dummy.position.set( + pos.x + (Math.random() - 0.5) * 0.05, + pos.y + 0.02 + Math.sin(t * 3 + i) * 0.02, + pos.z + (Math.random() - 0.5) * 0.05, + ); + + // Size based on proximity to camera/scroll position + const distToScroll = Math.abs(progress - scrollProgress); + const brightness = Math.max(0.2, 1 - distToScroll * 3); + dummy.scale.setScalar(0.03 * brightness + 0.01); + dummy.updateMatrix(); ref.current.setMatrixAt(i, dummy.matrix); } @@ -281,160 +177,419 @@ function CircuitNodes() { return ( - - + + ); } -/* ---------- Sprint orbital rings ---------- */ -function SprintRings() { - const groupRef = useRef(null); - const rings = [ - { radius: 2.5, color: COLORS.cyan, label: '28%', speed: 0.3 }, - { radius: 3.5, color: COLORS.amber, label: '55%', speed: 0.2 }, - { radius: 4.5, color: COLORS.green, label: '100%', speed: 0.15 }, +/* ---------- Warm current (second layer) ---------- */ +function WarmCurrentFlow({ count = 30 }) { + const ref = useRef(null); + const dummy = useMemo(() => new THREE.Object3D(), []); + const curve = useMemo(() => createTraceCurve(), []); + const phases = useMemo(() => Array.from({ length: count }, () => Math.random()), [count]); + + useFrame(({ clock }) => { + if (!ref.current) return; + const t = clock.getElapsedTime(); + + for (let i = 0; i < count; i++) { + const progress = ((phases[i] + t * 0.04 + 0.5) % 1); + const pos = curve.getPointAt(progress); + dummy.position.set(pos.x, pos.y + 0.03, pos.z); + const distToScroll = Math.abs(progress - scrollProgress); + const brightness = Math.max(0.1, 1 - distToScroll * 4); + dummy.scale.setScalar(0.02 * brightness + 0.005); + dummy.updateMatrix(); + ref.current.setMatrixAt(i, dummy.matrix); + } + ref.current.instanceMatrix.needsUpdate = true; + }); + + return ( + + + + + ); +} + +/* ---------- PCB Substrate (green board) ---------- */ +function Substrate() { + return ( + + {/* Main board */} + + + + + {/* Solder mask grid pattern */} + {Array.from({ length: 20 }).map((_, i) => ( + + + + + ))} + {Array.from({ length: 14 }).map((_, i) => ( + + + + + ))} + + ); +} + +/* ---------- Vias (through-hole connections) ---------- */ +function Vias() { + const positions: [number, number, number][] = [ + [8, 0, -6], [5, 0, -8], [2, 0, -11], + [-2, 0, -15], [-5, 0, -17], [-4, 0, -21], + [0, 0, -25], [4, 0, -29], [2, 0, -33], + // Extra decorative vias + [10, 0, -4], [-7, 0, -12], [7, 0, -22], + [-8, 0, -27], [8, 0, -35], [-2, 0, -38], ]; - useFrame(({ clock }) => { - if (!groupRef.current) return; - const scrollPos = globalScroll; - const fade = Math.max(0, Math.min(1, (scrollPos - 0.5) * 5)) * Math.max(0, Math.min(1, (0.8 - scrollPos) * 5)); - groupRef.current.position.y = -35; - groupRef.current.children.forEach(child => { - child.scale.setScalar(Math.max(0.01, fade)); - }); - }); - return ( - - {rings.map((ring, i) => { - const pts: THREE.Vector3[] = []; - for (let j = 0; j <= 128; j++) { - const a = (j / 128) * Math.PI * 2; - pts.push(new THREE.Vector3(Math.cos(a) * ring.radius, 0, Math.sin(a) * ring.radius)); - } - const geo = new THREE.BufferGeometry().setFromPoints(pts); - return ( - - - - - - ); - })} + + {positions.map(([x, y, z], i) => ( + + {/* Via ring */} + + + + + {/* Via hole */} + + + + + + ))} ); } -/* ---------- Contact section atom (returns at bottom) ---------- */ -function ContactAtom() { +/* ---------- 3D Electronic components on PCB ---------- */ + +/* IC / Microcontroller (black rectangle with pins) */ +function ICComponent({ position, size = [2, 0.3, 1.2], label, color = '#1a1a1a' }: { + position: [number, number, number]; size?: [number, number, number]; label: string; color?: string; +}) { const groupRef = useRef(null); + const pins = Math.floor(size[0] / 0.3); useFrame(({ clock }) => { if (!groupRef.current) return; - const scrollPos = globalScroll; - const t = clock.getElapsedTime(); - const fade = Math.max(0, (scrollPos - 0.75) * 4); - groupRef.current.position.y = -55; - groupRef.current.scale.setScalar(Math.max(0.01, fade * 0.6)); - groupRef.current.rotation.y = t * 0.2; - groupRef.current.rotation.x = Math.sin(t * 0.1) * 0.2; + const cameraZ = -scrollProgress * 38; + const dist = Math.abs(position[2] - cameraZ); + const glow = Math.max(0, 1 - dist / 6); + // Subtle float when active + groupRef.current.position.y = position[1] + glow * Math.sin(clock.getElapsedTime() * 2) * 0.03; }); return ( - - - - + + {/* IC body */} + + + - - - + {/* Dot (pin 1 marker) */} + + + - - - + {/* Silkscreen label */} + + + + + {/* Pins — both sides */} + {Array.from({ length: pins }).map((_, i) => ( + + + + + + + + + + + ))} + {/* Solder pads glow */} + + + ); } -/* ---------- Full scene ---------- */ -function Scene() { - const groupRef = useRef(null); +/* Capacitor (cylinder) */ +function Capacitor({ position, radius = 0.25, height = 0.5, color = '#2a4a8a' }: { + position: [number, number, number]; radius?: number; height?: number; color?: string; +}) { + const ref = useRef(null); - const orbits = useMemo(() => [ - { tilt: [-0.5, 0, 0], speed: 1.0, color: COLORS.cyan }, - { tilt: [0.5, 0.3, 0], speed: 0.72, color: COLORS.amber }, - { tilt: [1.57, 0.2, 0.4], speed: 0.55, color: COLORS.green }, - ], []); + useFrame(({ clock }) => { + if (!ref.current) return; + const cameraZ = -scrollProgress * 38; + const dist = Math.abs(position[2] - cameraZ); + const glow = Math.max(0, 1 - dist / 6); + (ref.current.material as THREE.MeshStandardMaterial).emissiveIntensity = glow * 0.5; + }); - useFrame(({ clock, pointer }) => { - if (!groupRef.current) return; + return ( + + + + + + {/* Top marking */} + + + + + {/* Stripe */} + + + + + + ); +} + +/* Resistor (small rectangle with color bands) */ +function Resistor({ position, rotation = [0, 0, 0] }: { position: [number, number, number]; rotation?: [number, number, number] }) { + return ( + + + + + + {/* Solder pads */} + + + + + + + + + + ); +} + +/* LED (small dome with glow) */ +function LED({ position, color = '#5bd1d8' }: { position: [number, number, number]; color?: string }) { + const ref = useRef(null); + const threeColor = useMemo(() => new THREE.Color(color), [color]); + + useFrame(({ clock }) => { + if (!ref.current) return; const t = clock.getElapsedTime(); - const mouseDist = Math.sqrt(pointer.x ** 2 + pointer.y ** 2); - const speedMul = 1 + mouseDist * 0.5; + const cameraZ = -scrollProgress * 38; + const dist = Math.abs(position[2] - cameraZ); + const proximity = Math.max(0, 1 - dist / 5); + (ref.current.material as THREE.MeshStandardMaterial).emissiveIntensity = proximity * (1.5 + Math.sin(t * 3) * 0.5); + }); - // Only the hero atom group rotates with mouse - const heroGroup = groupRef.current.children[0] as THREE.Group; - if (heroGroup) { - heroGroup.rotation.y = t * 0.12 * speedMul + pointer.x * 0.4; - heroGroup.rotation.x = -0.2 + pointer.y * 0.3; + return ( + + + + + + {/* LED pad */} + + + + + + ); +} + +/* All components assembled on the PCB */ +function PCBComponents() { + return ( + + {/* HERO — Main MCU (ESP32 style) */} + + + + + + + {/* ABOUT — Op-amp + passives */} + + + + + + + + {/* CASES — Power stage (MOSFET + caps) */} + + + + + + + {/* MEDIA area — Connector */} + + + + + {/* SPRINTS — Voltage regulator */} + + + + + + + {/* CONTACT — Big capacitor (energy storage) + indicator */} + + + + + + + {/* Extra scattered passives for realism */} + + + + + + + + + ); +} + +/* ---------- Ambient floating particles (dust/solder flux) ---------- */ +function AmbientDust({ count = 200 }) { + const ref = useRef(null); + + const positions = useMemo(() => { + const pos = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + pos[i * 3] = (Math.random() - 0.5) * 25; + pos[i * 3 + 1] = Math.random() * 3; + pos[i * 3 + 2] = Math.random() * -42; } + return pos; + }, [count]); + + useFrame(({ clock }) => { + if (!ref.current) return; + const arr = (ref.current.geometry.attributes.position as THREE.BufferAttribute).array as Float32Array; + const t = clock.getElapsedTime(); + for (let i = 0; i < count; i++) { + arr[i * 3 + 1] += Math.sin(t * 0.5 + i) * 0.001; + } + ref.current.geometry.attributes.position.needsUpdate = true; }); return ( - - {/* Hero atom group */} - - - {orbits.map((o, i) => ( - - ))} - - - {/* Persistent particles */} - - + + + + + + ); } -/* ---------- Scroll-reactive camera ---------- */ -function ScrollCamera() { +/* ---------- Camera that follows the trace path on scroll ---------- */ +function TraceCamera() { const { camera } = useThree(); - const smooth = useRef({ x: 0, y: 0, scroll: 0 }); + const curve = useMemo(() => createTraceCurve(), []); + const smooth = useRef({ progress: 0, mx: 0, my: 0 }); useFrame(({ pointer }) => { - // Smooth interpolation - smooth.current.x += (pointer.x - smooth.current.x) * 0.05; - smooth.current.y += (pointer.y - smooth.current.y) * 0.05; - smooth.current.scroll += (globalScroll - smooth.current.scroll) * 0.08; + // Smooth scroll interpolation + smooth.current.progress += (scrollProgress - smooth.current.progress) * 0.06; + smooth.current.mx += (pointer.x - smooth.current.mx) * 0.04; + smooth.current.my += (pointer.y - smooth.current.my) * 0.04; - const s = smooth.current.scroll; + const p = Math.min(0.98, smooth.current.progress); + const pos = curve.getPointAt(p); + const lookAt = curve.getPointAt(Math.min(0.99, p + 0.03)); - // Camera FIXED — slight parallax from mouse only, no scroll movement - camera.position.x = smooth.current.x * 1.2; - camera.position.y = smooth.current.y * 0.6; - camera.position.z = 14; + // Camera slightly above and offset by mouse + camera.position.set( + pos.x + smooth.current.mx * 1.5, + pos.y + 2.5 + smooth.current.my * 0.5, + pos.z + 1.5, + ); camera.lookAt( - smooth.current.x * 0.2, - smooth.current.y * 0.1, - 0 + lookAt.x + smooth.current.mx * 0.3, + lookAt.y + 0.2, + lookAt.z, ); }); return null; } +/* ---------- Full scene ---------- */ +function PCBScene() { + return ( + + + + + + + + + + + ); +} + /* ---------- Exported component ---------- */ export default function WebGLBackground() { useEffect(() => { const onScroll = () => { const el = document.documentElement; - globalMaxScroll = Math.max(1, el.scrollHeight - el.clientHeight); - globalScroll = el.scrollTop / globalMaxScroll; + const max = Math.max(1, el.scrollHeight - el.clientHeight); + scrollProgress = el.scrollTop / max; }; window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); @@ -454,28 +609,26 @@ export default function WebGLBackground() { aria-hidden="true" > - - + + - - - - - + + + + - - + + - - - - + + + diff --git a/src/pages/preview.astro b/src/pages/preview.astro index 13ac9b0..7625a5b 100644 --- a/src/pages/preview.astro +++ b/src/pages/preview.astro @@ -130,9 +130,11 @@ const headerItems = [ - -
-
+ +
+
+
+
@@ -325,8 +327,86 @@ const headerItems = [ color: #5bd1d8 !important; } + /* ============ CONTACT 3D POPUP ============ */ + .contact-3d-popup { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity 0.4s ease, visibility 0.4s ease; + padding: 0; + } + + .contact-3d-popup.contact-3d-popup--open { + pointer-events: auto; + opacity: 1; + visibility: visible; + } + + .contact-3d-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + } + + .contact-3d-tile { + position: relative; + z-index: 2; + max-width: 600px; + width: 90vw; + max-height: 85vh; + overflow-y: auto; + background: rgba(10, 10, 10, 0.9); + border: 1px solid rgba(91, 209, 216, 0.15); + border-radius: 20px; + padding: clamp(20px, 4vw, 40px); + box-shadow: + 0 0 40px rgba(91, 209, 216, 0.08), + 0 24px 80px rgba(0, 0, 0, 0.6); + transform: perspective(1200px) rotateY(-15deg) rotateX(5deg) scale(0.85) translateY(40px); + opacity: 0; + transition: transform 0.7s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.5s ease; + } + + .contact-3d-popup--open .contact-3d-tile { + transform: perspective(1200px) rotateY(0deg) rotateX(0deg) scale(1) translateY(0); + opacity: 1; + } + + .contact-3d-close { + position: absolute; + top: 12px; + right: 16px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 50%; + width: 32px; + height: 32px; + color: rgba(255, 255, 255, 0.6); + font-size: 16px; + cursor: pointer; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + .contact-3d-close:hover { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; + transform: rotate(90deg); + } + @media (prefers-reduced-motion: reduce) { .section-hero { min-height: auto; } + .contact-3d-tile { transform: none; } } @@ -410,9 +490,53 @@ const headerItems = [ initScrollAnimations(); setTimeout(initScrollTilt, 2000); initCarousels(); + // Contact 3D popup + function initContactPopup() { + const popup = document.getElementById('contact'); + const backdrop = document.getElementById('contact-backdrop'); + const closeBtn = document.getElementById('contact-close'); + if (!popup) return; + + function openContact() { + popup.classList.add('contact-3d-popup--open'); + } + function closeContact() { + popup.classList.remove('contact-3d-popup--open'); + } + + // Open on any #contact link click + document.querySelectorAll('a[href="#contact"]').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + openContact(); + }); + }); + + // Open at end of scroll (>95%) + let endTriggered = false; + window.addEventListener('scroll', () => { + const el = document.documentElement; + const pct = el.scrollTop / (el.scrollHeight - el.clientHeight); + if (pct > 0.95 && !endTriggered) { + endTriggered = true; + openContact(); + } + if (pct < 0.9) endTriggered = false; + }, { passive: true }); + + // Close + backdrop?.addEventListener('click', closeContact); + closeBtn?.addEventListener('click', closeContact); + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeContact(); + }); + } + + initContactPopup(); document.addEventListener('astro:page-load', () => { initScrollAnimations(); setTimeout(initScrollTilt, 2000); initCarousels(); + initContactPopup(); }); diff --git a/src/styles/webgl-cards.css b/src/styles/webgl-cards.css index 0a98605..51eb36d 100644 --- a/src/styles/webgl-cards.css +++ b/src/styles/webgl-cards.css @@ -58,18 +58,27 @@ transform: translateY(-2px) scale(1.01); } -/* ── Section atomic reveal ──────────────────────────── */ +/* ── Section atomic reveal — alternate orbit direction ── */ .webgl-section { opacity: 0; - transform: translateY(40px); transition: - opacity 1s cubic-bezier(0.16, 1, 0.3, 1), - transform 1s cubic-bezier(0.16, 1, 0.3, 1); + opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.8s cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Odd sections slide from left orbit */ +.webgl-section:nth-child(odd) { + transform: translateX(-60px) rotate(-1deg); +} + +/* Even sections slide from right orbit */ +.webgl-section:nth-child(even) { + transform: translateX(60px) rotate(1deg); } .webgl-section--visible { opacity: 1; - transform: translateY(0); + transform: translateX(0) rotate(0deg); } /* Staggered children reveal */ @@ -224,24 +233,61 @@ transform-style: preserve-3d; } +/* Alternating left/right orbit entry */ .webgl-card--visible { - animation: card-3d-enter 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; + animation: card-orbit-right 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; } -@keyframes card-3d-enter { +/* Even cards enter from right, odd from left */ +.webgl-section:nth-child(odd) .webgl-card--visible { + animation-name: card-orbit-left; +} +.webgl-section:nth-child(even) .webgl-card--visible { + animation-name: card-orbit-right; +} + +@keyframes card-orbit-left { 0% { opacity: 0; - transform: perspective(1000px) rotateX(8deg) rotateY(-5deg) translateY(40px) scale(0.92); - filter: blur(6px); + transform: perspective(1200px) translateX(-120px) rotateY(25deg) rotateZ(-3deg) scale(0.85); + filter: blur(8px); } - 60% { + 40% { + opacity: 0.7; + transform: perspective(1200px) translateX(-20px) rotateY(8deg) rotateZ(-1deg) scale(0.97); + filter: blur(2px); + } + 70% { opacity: 1; - transform: perspective(1000px) rotateX(-2deg) rotateY(1deg) translateY(-5px) scale(1.01); + transform: perspective(1200px) translateX(8px) rotateY(-2deg) rotateZ(0.5deg) scale(1.01); filter: blur(0); } 100% { opacity: 1; - transform: perspective(1000px) rotateX(0deg) rotateY(0deg) translateY(0) scale(1); + transform: perspective(1200px) translateX(0) rotateY(0deg) rotateZ(0deg) scale(1); + filter: blur(0); + } +} + +@keyframes card-orbit-right { + 0% { + opacity: 0; + transform: perspective(1200px) translateX(120px) rotateY(-25deg) rotateZ(3deg) scale(0.85); + filter: blur(8px); + } + 40% { + opacity: 0.7; + transform: perspective(1200px) translateX(20px) rotateY(-8deg) rotateZ(1deg) scale(0.97); + filter: blur(2px); + } + 70% { + opacity: 1; + transform: perspective(1200px) translateX(-8px) rotateY(2deg) rotateZ(-0.5deg) scale(1.01); + filter: blur(0); + } + 100% { + opacity: 1; + transform: perspective(1200px) translateX(0) rotateY(0deg) rotateZ(0deg) scale(1); filter: blur(0); } }