Skip to content

Commit 51f9586

Browse files
committed
Add RewindSection and RewindScene components for interactive year-in-review visualization
1 parent deb2135 commit 51f9586

3 files changed

Lines changed: 586 additions & 0 deletions

File tree

src/templates/threejs/HomePage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import siteContent from '../../siteContent.json'
22
import { githubConfig } from '../../generated/githubData'
33
import HeroSection from './HeroSection'
44
import PhilosophySection from './PhilosophySection'
5+
import RewindSection from './RewindSection'
56
import VideosSection from './VideosSection'
67
import BlogsSection from './BlogsSection'
78
import CustomProjectsSection from './CustomProjectsSection'
@@ -41,6 +42,7 @@ export default function ThreejsHomePage() {
4142
return (
4243
<div className="bg-[#050509]">
4344
<HeroSection hero={hero} snapshot={snapshot} />
45+
<RewindSection stats={stats ?? null} name={hero?.title} />
4446
<PhilosophySection philosophy={philosophy} />
4547
{showVideos && <VideosSection />}
4648
{showBlogs && <BlogsSection />}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { useEffect, useRef } from 'react'
2+
import * as THREE from 'three'
3+
4+
// One hex colour per slide — must match ACCENT in RewindSection
5+
const SLIDE_HEX = [0x3b82f6, 0x8b5cf6, 0x06b6d4, 0xf59e0b, 0x10b981, 0xa78bfa]
6+
7+
export default function RewindScene({ slideIndex }: { slideIndex: number }) {
8+
const mountRef = useRef<HTMLDivElement>(null)
9+
const slideRef = useRef(slideIndex)
10+
11+
useEffect(() => { slideRef.current = slideIndex }, [slideIndex])
12+
13+
useEffect(() => {
14+
const mount = mountRef.current
15+
if (!mount) return
16+
17+
const w = mount.clientWidth
18+
const h = mount.clientHeight
19+
20+
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true })
21+
renderer.setPixelRatio(1)
22+
renderer.setSize(w, h)
23+
renderer.setClearColor(0x000000, 0)
24+
mount.appendChild(renderer.domElement)
25+
26+
const scene = new THREE.Scene()
27+
const camera = new THREE.PerspectiveCamera(70, w / h, 0.1, 100)
28+
camera.position.z = 14
29+
30+
// ── Particle field ──────────────────────────────────────────
31+
const N = 80
32+
const pos = new Float32Array(N * 3)
33+
const vel: [number, number][] = []
34+
for (let i = 0; i < N; i++) {
35+
pos[i * 3] = (Math.random() - 0.5) * 28
36+
pos[i * 3 + 1] = (Math.random() - 0.5) * 16
37+
pos[i * 3 + 2] = (Math.random() - 0.5) * 4
38+
vel.push([(Math.random() - 0.5) * 0.007, (Math.random() - 0.5) * 0.005])
39+
}
40+
const pGeo = new THREE.BufferGeometry()
41+
const pAttr = new THREE.BufferAttribute(pos, 3)
42+
pAttr.setUsage(THREE.DynamicDrawUsage)
43+
pGeo.setAttribute('position', pAttr)
44+
const pMat = new THREE.PointsMaterial({ color: SLIDE_HEX[0], size: 0.07, transparent: true, opacity: 0.35 })
45+
scene.add(new THREE.Points(pGeo, pMat))
46+
47+
// ── Connection lines ─────────────────────────────────────────
48+
const lPos = new Float32Array(N * N * 6)
49+
const lGeo = new THREE.BufferGeometry()
50+
const lAttr = new THREE.BufferAttribute(lPos, 3)
51+
lAttr.setUsage(THREE.DynamicDrawUsage)
52+
lGeo.setAttribute('position', lAttr)
53+
const lMat = new THREE.LineBasicMaterial({ color: SLIDE_HEX[0], transparent: true, opacity: 0.08 })
54+
const lineSegs = new THREE.LineSegments(lGeo, lMat)
55+
scene.add(lineSegs)
56+
57+
// ── Wireframe icosahedron (central 3-D element) ───────────────
58+
const icoGeo = new THREE.IcosahedronGeometry(2.4, 1)
59+
const icoEdges = new THREE.EdgesGeometry(icoGeo)
60+
const icoMat = new THREE.LineBasicMaterial({ color: SLIDE_HEX[0], transparent: true, opacity: 0.3 })
61+
const ico = new THREE.LineSegments(icoEdges, icoMat)
62+
scene.add(ico)
63+
64+
const innerGeo = new THREE.IcosahedronGeometry(1.9, 0)
65+
const innerMat = new THREE.MeshBasicMaterial({ color: SLIDE_HEX[0], transparent: true, opacity: 0.05 })
66+
const inner = new THREE.Mesh(innerGeo, innerMat)
67+
scene.add(inner)
68+
69+
// ── Colour lerp state ─────────────────────────────────────────
70+
const cur = new THREE.Color(SLIDE_HEX[0])
71+
const tgt = new THREE.Color(SLIDE_HEX[0])
72+
const CONNECT_DIST = 5
73+
let frameId: number
74+
75+
const tick = () => {
76+
frameId = requestAnimationFrame(tick)
77+
78+
const idx = Math.min(slideRef.current, SLIDE_HEX.length - 1)
79+
tgt.set(SLIDE_HEX[idx])
80+
cur.lerp(tgt, 0.025)
81+
82+
pMat.color.copy(cur)
83+
lMat.color.copy(cur)
84+
icoMat.color.copy(cur)
85+
innerMat.color.copy(cur)
86+
87+
// Move particles + wrap
88+
for (let i = 0; i < N; i++) {
89+
pos[i * 3] += vel[i][0]; pos[i * 3 + 1] += vel[i][1]
90+
if (pos[i * 3] > 14) pos[i * 3] = -14
91+
if (pos[i * 3] < -14) pos[i * 3] = 14
92+
if (pos[i * 3 + 1] > 8) pos[i * 3 + 1] = -8
93+
if (pos[i * 3 + 1] < -8) pos[i * 3 + 1] = 8
94+
}
95+
pAttr.needsUpdate = true
96+
97+
// Connection lines
98+
let li = 0
99+
for (let i = 0; i < N; i++) {
100+
for (let j = i + 1; j < N; j++) {
101+
const dx = pos[i * 3] - pos[j * 3]
102+
const dy = pos[i * 3 + 1] - pos[j * 3 + 1]
103+
if (dx * dx + dy * dy < CONNECT_DIST * CONNECT_DIST) {
104+
lPos[li * 6] = pos[i * 3]; lPos[li * 6 + 1] = pos[i * 3 + 1]; lPos[li * 6 + 2] = pos[i * 3 + 2]
105+
lPos[li * 6 + 3] = pos[j * 3]; lPos[li * 6 + 4] = pos[j * 3 + 1]; lPos[li * 6 + 5] = pos[j * 3 + 2]
106+
li++
107+
}
108+
}
109+
}
110+
lGeo.setDrawRange(0, li * 2)
111+
lAttr.needsUpdate = true
112+
113+
// Rotate central geometry
114+
ico.rotation.y += 0.003
115+
ico.rotation.x += 0.0015
116+
inner.rotation.y = ico.rotation.y
117+
inner.rotation.x = ico.rotation.x
118+
119+
renderer.render(scene, camera)
120+
}
121+
tick()
122+
123+
const onResize = () => {
124+
if (!mount) return
125+
camera.aspect = mount.clientWidth / mount.clientHeight
126+
camera.updateProjectionMatrix()
127+
renderer.setSize(mount.clientWidth, mount.clientHeight)
128+
}
129+
window.addEventListener('resize', onResize)
130+
131+
return () => {
132+
cancelAnimationFrame(frameId)
133+
window.removeEventListener('resize', onResize)
134+
;[pGeo, lGeo, icoGeo, icoEdges, innerGeo].forEach(g => g.dispose())
135+
;[pMat, lMat, icoMat, innerMat].forEach(m => m.dispose())
136+
renderer.dispose()
137+
try { mount.removeChild(renderer.domElement) } catch { /* already removed */ }
138+
}
139+
}, [])
140+
141+
return (
142+
<div
143+
ref={mountRef}
144+
className="pointer-events-none absolute inset-0 w-full h-full"
145+
aria-hidden="true"
146+
/>
147+
)
148+
}

0 commit comments

Comments
 (0)