|
| 1 | +/** |
| 2 | + * Flowing Wave Field — abstract background animation using Three.js. |
| 3 | + * |
| 4 | + * A grid of particles undulates using layered sine waves, creating an |
| 5 | + * organic, ocean-like surface. Particles are orange (#F97316), connections |
| 6 | + * are white with low opacity. Mouse movement warps the wave locally. |
| 7 | + * |
| 8 | + */ |
| 9 | + |
| 10 | +;(function () { |
| 11 | + // ── Bookkeeping for teardown ── |
| 12 | + let animationId = null; |
| 13 | + let renderer = null; |
| 14 | + let mouseMoveHandler = null; |
| 15 | + let resizeHandler = null; |
| 16 | + |
| 17 | + function teardown() { |
| 18 | + if (animationId) { |
| 19 | + cancelAnimationFrame(animationId); |
| 20 | + animationId = null; |
| 21 | + } |
| 22 | + if (renderer) { |
| 23 | + renderer.dispose(); |
| 24 | + renderer = null; |
| 25 | + } |
| 26 | + if (mouseMoveHandler) { |
| 27 | + document.removeEventListener('mousemove', mouseMoveHandler); |
| 28 | + mouseMoveHandler = null; |
| 29 | + } |
| 30 | + if (resizeHandler) { |
| 31 | + window.removeEventListener('resize', resizeHandler); |
| 32 | + resizeHandler = null; |
| 33 | + } |
| 34 | + } |
| 35 | + |
| 36 | + function init() { |
| 37 | + teardown(); |
| 38 | + |
| 39 | + const canvas = document.getElementById('hero-canvas'); |
| 40 | + if (!canvas) return; |
| 41 | + |
| 42 | + // ── Scene & Renderer ── |
| 43 | + const scene = new THREE.Scene(); |
| 44 | + renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); |
| 45 | + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| 46 | + |
| 47 | + // ── Camera — angled top-down for wave perspective ── |
| 48 | + const camera = new THREE.PerspectiveCamera( |
| 49 | + 60, |
| 50 | + window.innerWidth / window.innerHeight, |
| 51 | + 1, |
| 52 | + 1000 |
| 53 | + ); |
| 54 | + camera.position.set(0, 120, 200); |
| 55 | + camera.lookAt(0, 0, 0); |
| 56 | + |
| 57 | + // ── Wave grid configuration ── |
| 58 | + const COLS = 50; |
| 59 | + const ROWS = 50; |
| 60 | + const SPACING = 8; |
| 61 | + const PARTICLE_COUNT = COLS * ROWS; |
| 62 | + |
| 63 | + // Center the grid so it looks symmetric |
| 64 | + const offsetX = ((COLS - 1) * SPACING) / 2; |
| 65 | + const offsetZ = ((ROWS - 1) * SPACING) / 2; |
| 66 | + |
| 67 | + // ── Create particles (Points) ── |
| 68 | + const geometry = new THREE.BufferGeometry(); |
| 69 | + const positions = new Float32Array(PARTICLE_COUNT * 3); |
| 70 | + |
| 71 | + // Initialize flat grid positions |
| 72 | + for (let row = 0; row < ROWS; row++) { |
| 73 | + for (let col = 0; col < COLS; col++) { |
| 74 | + const i = (row * COLS + col) * 3; |
| 75 | + positions[i] = col * SPACING - offsetX; // x |
| 76 | + positions[i + 1] = 0; // y (will be animated) |
| 77 | + positions[i + 2] = row * SPACING - offsetZ; // z |
| 78 | + } |
| 79 | + } |
| 80 | + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); |
| 81 | + |
| 82 | + // Circle texture for soft round particles |
| 83 | + function createCircleTexture() { |
| 84 | + const c = document.createElement('canvas'); |
| 85 | + c.width = 32; |
| 86 | + c.height = 32; |
| 87 | + const ctx = c.getContext('2d'); |
| 88 | + // Soft radial gradient for glow effect |
| 89 | + const grad = ctx.createRadialGradient(16, 16, 0, 16, 16, 16); |
| 90 | + grad.addColorStop(0, 'rgba(255, 255, 255, 1)'); |
| 91 | + grad.addColorStop(0.4, 'rgba(255, 255, 255, 0.8)'); |
| 92 | + grad.addColorStop(1, 'rgba(255, 255, 255, 0)'); |
| 93 | + ctx.fillStyle = grad; |
| 94 | + ctx.fillRect(0, 0, 32, 32); |
| 95 | + const tex = new THREE.Texture(c); |
| 96 | + tex.needsUpdate = true; |
| 97 | + return tex; |
| 98 | + } |
| 99 | + |
| 100 | + const pointsMaterial = new THREE.PointsMaterial({ |
| 101 | + color: 0xF97316, // Orange accent |
| 102 | + size: 2.5, |
| 103 | + map: createCircleTexture(), |
| 104 | + transparent: true, |
| 105 | + alphaTest: 0.01, |
| 106 | + opacity: 0.9, |
| 107 | + depthWrite: false, |
| 108 | + blending: THREE.AdditiveBlending |
| 109 | + }); |
| 110 | + |
| 111 | + const points = new THREE.Points(geometry, pointsMaterial); |
| 112 | + scene.add(points); |
| 113 | + |
| 114 | + // ── Connection lines between nearby particles ── |
| 115 | + const lineGeometry = new THREE.BufferGeometry(); |
| 116 | + // Max possible lines: each particle connects to right + down neighbor |
| 117 | + const maxLines = (COLS - 1) * ROWS + COLS * (ROWS - 1); |
| 118 | + const linePositions = new Float32Array(maxLines * 6); |
| 119 | + lineGeometry.setAttribute( |
| 120 | + 'position', |
| 121 | + new THREE.BufferAttribute(linePositions, 3).setUsage(THREE.DynamicDrawUsage) |
| 122 | + ); |
| 123 | + |
| 124 | + const lineMaterial = new THREE.LineBasicMaterial({ |
| 125 | + color: 0xffffff, // White connections |
| 126 | + transparent: true, |
| 127 | + opacity: 0.08, |
| 128 | + depthWrite: false |
| 129 | + }); |
| 130 | + |
| 131 | + const lines = new THREE.LineSegments(lineGeometry, lineMaterial); |
| 132 | + scene.add(lines); |
| 133 | + |
| 134 | + // ── Mouse tracking ── |
| 135 | + const mouse = { x: 9999, y: 9999 }; |
| 136 | + mouseMoveHandler = (e) => { |
| 137 | + // Normalize mouse to [-1, 1] range relative to canvas |
| 138 | + const rect = canvas.parentNode.getBoundingClientRect(); |
| 139 | + mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; |
| 140 | + mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; |
| 141 | + }; |
| 142 | + document.addEventListener('mousemove', mouseMoveHandler); |
| 143 | + |
| 144 | + // ── Resize handler ── |
| 145 | + resizeHandler = () => { |
| 146 | + const container = canvas.parentNode; |
| 147 | + if (!container) return; |
| 148 | + const w = container.clientWidth; |
| 149 | + const h = container.clientHeight; |
| 150 | + renderer.setSize(w, h); |
| 151 | + camera.aspect = w / h; |
| 152 | + camera.updateProjectionMatrix(); |
| 153 | + }; |
| 154 | + window.addEventListener('resize', resizeHandler); |
| 155 | + resizeHandler(); |
| 156 | + |
| 157 | + // ── Animation loop ── |
| 158 | + const clock = new THREE.Clock(); |
| 159 | + |
| 160 | + function animate() { |
| 161 | + animationId = requestAnimationFrame(animate); |
| 162 | + const t = clock.getElapsedTime(); |
| 163 | + const pos = points.geometry.attributes.position.array; |
| 164 | + |
| 165 | + // Project mouse into world space for local wave distortion |
| 166 | + const mouseVec = new THREE.Vector3(mouse.x, mouse.y, 0.5); |
| 167 | + mouseVec.unproject(camera); |
| 168 | + const dir = mouseVec.sub(camera.position).normalize(); |
| 169 | + // Intersect with y=0 plane |
| 170 | + const dist = -camera.position.y / dir.y; |
| 171 | + const mouseWorld = camera.position.clone().add(dir.multiplyScalar(dist)); |
| 172 | + |
| 173 | + // Update each particle's Y using layered sine waves |
| 174 | + for (let row = 0; row < ROWS; row++) { |
| 175 | + for (let col = 0; col < COLS; col++) { |
| 176 | + const i = (row * COLS + col) * 3; |
| 177 | + const x = pos[i]; |
| 178 | + const z = pos[i + 2]; |
| 179 | + |
| 180 | + // Layer 1: primary wave (large, slow) |
| 181 | + let y = Math.sin(x * 0.04 + t * 0.8) * 12; |
| 182 | + // Layer 2: cross wave (medium, faster) |
| 183 | + y += Math.sin(z * 0.06 + t * 1.2) * 8; |
| 184 | + // Layer 3: diagonal ripple (detail) |
| 185 | + y += Math.sin((x + z) * 0.05 + t * 0.6) * 5; |
| 186 | + |
| 187 | + // Mouse influence — push wave up near cursor |
| 188 | + const dx = x - mouseWorld.x; |
| 189 | + const dz = z - mouseWorld.z; |
| 190 | + const mouseDist = Math.sqrt(dx * dx + dz * dz); |
| 191 | + if (mouseDist < 60) { |
| 192 | + const influence = 1 - mouseDist / 60; |
| 193 | + y += influence * 20; |
| 194 | + } |
| 195 | + |
| 196 | + pos[i + 1] = y; |
| 197 | + } |
| 198 | + } |
| 199 | + points.geometry.attributes.position.needsUpdate = true; |
| 200 | + |
| 201 | + // Update connection lines between grid neighbors |
| 202 | + const lnPos = lines.geometry.attributes.position.array; |
| 203 | + let li = 0; |
| 204 | + |
| 205 | + for (let row = 0; row < ROWS; row++) { |
| 206 | + for (let col = 0; col < COLS; col++) { |
| 207 | + const i = (row * COLS + col) * 3; |
| 208 | + |
| 209 | + // Connect to right neighbor |
| 210 | + if (col < COLS - 1) { |
| 211 | + const j = (row * COLS + col + 1) * 3; |
| 212 | + lnPos[li++] = pos[i]; lnPos[li++] = pos[i + 1]; lnPos[li++] = pos[i + 2]; |
| 213 | + lnPos[li++] = pos[j]; lnPos[li++] = pos[j + 1]; lnPos[li++] = pos[j + 2]; |
| 214 | + } |
| 215 | + // Connect to bottom neighbor |
| 216 | + if (row < ROWS - 1) { |
| 217 | + const j = ((row + 1) * COLS + col) * 3; |
| 218 | + lnPos[li++] = pos[i]; lnPos[li++] = pos[i + 1]; lnPos[li++] = pos[i + 2]; |
| 219 | + lnPos[li++] = pos[j]; lnPos[li++] = pos[j + 1]; lnPos[li++] = pos[j + 2]; |
| 220 | + } |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + lines.geometry.attributes.position.needsUpdate = true; |
| 225 | + lines.geometry.setDrawRange(0, li / 3); |
| 226 | + |
| 227 | + // Slow rotation for dynamism |
| 228 | + scene.rotation.y += 0.0008; |
| 229 | + |
| 230 | + renderer.render(scene, camera); |
| 231 | + } |
| 232 | + |
| 233 | + animate(); |
| 234 | + } |
| 235 | + |
| 236 | + // ── Hook into Zensical SPA navigation ── |
| 237 | + function safeInit() { |
| 238 | + if (typeof THREE === 'undefined') return; |
| 239 | + init(); |
| 240 | + } |
| 241 | + |
| 242 | + if (typeof document$ !== 'undefined') { |
| 243 | + document$.subscribe(safeInit); |
| 244 | + } |
| 245 | + |
| 246 | + if (document.readyState === 'loading') { |
| 247 | + document.addEventListener('DOMContentLoaded', safeInit); |
| 248 | + } else { |
| 249 | + safeInit(); |
| 250 | + } |
| 251 | +})(); |
0 commit comments