diff --git a/services/frontend/src/widgets/particles/ParticleBackground.tsx b/services/frontend/src/widgets/particles/ParticleBackground.tsx new file mode 100644 index 0000000..10ea892 --- /dev/null +++ b/services/frontend/src/widgets/particles/ParticleBackground.tsx @@ -0,0 +1,163 @@ +import { Sparkles } from "@react-three/drei"; +import { Canvas, type ThreeElement, useFrame, useThree } from "@react-three/fiber"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type * as THREE from "three"; + +// ─── Sakura petal geometry ─────────────────────────────────────────────────── + +function createPetalShape() { + const shape = new THREE.Shape(); + // Teardrop/petal shape + shape.moveTo(0, 0.5); + shape.bezierCurveTo(0.3, 0.3, 0.4, -0.1, 0, -0.5); + shape.bezierCurveTo(-0.4, -0.1, -0.3, 0.3, 0, 0.5); + return shape; +} + +const petalShape = createPetalShape(); +const petalGeometry = new THREE.ShapeGeometry(petalShape); + +// ─── Inner particle scene ──────────────────────────────────────────────────── + +function Petal({ mouse }: { mouse: React.RefObject }) { + const meshRef = useRef(null); + const initial = useMemo(() => ({ + x: (Math.random() - 0.5) * 20, + y: Math.random() * 20 - 5, + z: (Math.random() - 0.5) * 10 - 2, + rotSpeed: (Math.random() - 0.5) * 2, + driftX: (Math.random() - 0.5) * 0.005, + driftZ: (Math.random() - 0.5) * 0.003, + hue: 340 + Math.random() * 30, // pink range: 340–370 + saturation: 60 + Math.random() * 30, + lightness: 65 + Math.random() * 20, + }), []); + + useFrame((_, delta) => { + if (!meshRef.current) return; + const mesh = meshRef.current; + + // Slow drift downward + mesh.position.y -= delta * 0.3; + mesh.position.x += Math.sin(Date.now() * initial.driftX) * 0.002; + mesh.position.z += Math.cos(Date.now() * initial.driftZ) * 0.001; + mesh.rotation.z += delta * initial.rotSpeed; + mesh.rotation.x += delta * initial.rotSpeed * 0.3; + + // Gentle mouse attraction + const dx = mouse.current.x * 3 - mesh.position.x; + const dy = mouse.current.y * 2 - mesh.position.y; + mesh.position.x += dx * 0.0001; + mesh.position.y += dy * 0.0001; + + // Reset when out of view + if (mesh.position.y < -10) { + mesh.position.y = 8; + mesh.position.x = (Math.random() - 0.5) * 20; + mesh.position.z = (Math.random() - 0.5) * 10 - 2; + } + }); + + return ( + + + + ); +} + +function ParticleScene() { + const mouse = useRef(new THREE.Vector2(0, 0)); + const { size } = useThree(); + const hiddenRef = useRef(false); + + useEffect(() => { + const onMouse = (e: MouseEvent) => { + // Normalise to -1..1 relative to viewport + mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1; + mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1; + }; + window.addEventListener("mousemove", onMouse); + + const onVisibility = () => { + hiddenRef.current = document.hidden; + }; + document.addEventListener("visibilitychange", onVisibility); + + return () => { + window.removeEventListener("mousemove", onMouse); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, []); + + // Petal count depends on viewport width (fewer on mobile) + const petalCount = size.width < 640 ? 10 : size.width < 1024 ? 20 : 35; + const petals = useMemo( + () => Array.from({ length: petalCount }, (_, i) => ), + [petalCount], + ); + + return ( + <> + {/* Ambient + directional for soft lighting */} + + + + {/* Petals */} + {!hiddenRef.current && petals} + + {/* Sparkles from drei */} + + + ); +} + +// ─── Exported component ────────────────────────────────────────────────────── + +export function ParticleBackground() { + const [reducedMotion, setReducedMotion] = useState(false); + + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setReducedMotion(mq.matches); + const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + if (reducedMotion) return null; + + return ( + + ); +}