feat(frontend): refactor ParticleBackground for performance and type safety
This commit is contained in:
@@ -15,6 +15,8 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@react-three/drei": "^9.6.1",
|
||||||
|
"@react-three/fiber": "^9.6.1",
|
||||||
"@tanstack/react-query": "^5.100.14",
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.4.0",
|
"framer-motion": "^12.4.0",
|
||||||
@@ -23,8 +25,6 @@
|
|||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"@react-three/drei": "^9.6.1",
|
|
||||||
"@react-three/fiber": "^9.6.1",
|
|
||||||
"three": "^0.174.0"
|
"three": "^0.174.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "^0.184.1",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
|
|||||||
@@ -1,54 +1,73 @@
|
|||||||
import { Sparkles } from "@react-three/drei";
|
import { Sparkles } from "@react-three/drei";
|
||||||
import { Canvas, type ThreeElement, useFrame, useThree } from "@react-three/fiber";
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type * as THREE from "three";
|
|
||||||
|
|
||||||
// ─── Sakura petal geometry ───────────────────────────────────────────────────
|
// ─── Particle count by viewport ──────────────────────────────────────────────
|
||||||
|
|
||||||
function createPetalShape() {
|
function getPetalCount(width: number) {
|
||||||
const shape = new THREE.Shape();
|
if (width < 640) return 10;
|
||||||
// Teardrop/petal shape
|
if (width < 1024) return 20;
|
||||||
shape.moveTo(0, 0.5);
|
return 35;
|
||||||
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();
|
function getSparkleCount(width: number) {
|
||||||
const petalGeometry = new THREE.ShapeGeometry(petalShape);
|
if (width < 640) return 15;
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Inner particle scene ────────────────────────────────────────────────────
|
// ─── Sakura petal mesh ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Petal({ mouse }: { mouse: React.RefObject<THREE.Vector2 | null> }) {
|
interface PetalData {
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
x: number;
|
||||||
const initial = useMemo(() => ({
|
y: number;
|
||||||
|
z: number;
|
||||||
|
rotSpeed: number;
|
||||||
|
driftX: number;
|
||||||
|
driftZ: number;
|
||||||
|
hue: number;
|
||||||
|
sat: number;
|
||||||
|
light: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPetals(count: number): PetalData[] {
|
||||||
|
return Array.from({ length: count }, () => ({
|
||||||
x: (Math.random() - 0.5) * 20,
|
x: (Math.random() - 0.5) * 20,
|
||||||
y: Math.random() * 20 - 5,
|
y: Math.random() * 20 - 5,
|
||||||
z: (Math.random() - 0.5) * 10 - 2,
|
z: (Math.random() - 0.5) * 10 - 2,
|
||||||
rotSpeed: (Math.random() - 0.5) * 2,
|
rotSpeed: (Math.random() - 0.5) * 2,
|
||||||
driftX: (Math.random() - 0.5) * 0.005,
|
driftX: (Math.random() - 0.5) * 0.005,
|
||||||
driftZ: (Math.random() - 0.5) * 0.003,
|
driftZ: (Math.random() - 0.5) * 0.003,
|
||||||
hue: 340 + Math.random() * 30, // pink range: 340–370
|
hue: 340 + Math.random() * 30,
|
||||||
saturation: 60 + Math.random() * 30,
|
sat: 60 + Math.random() * 30,
|
||||||
lightness: 65 + Math.random() * 20,
|
light: 65 + Math.random() * 20,
|
||||||
}), []);
|
scale: 0.08 + Math.random() * 0.08,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
function Petal({ data, mouseRef }: { data: PetalData; mouseRef: React.RefObject<{ x: number; y: number } | null> }) {
|
||||||
if (!meshRef.current) return;
|
const meshRef = useRef(null);
|
||||||
const mesh = meshRef.current;
|
|
||||||
|
useFrame((_state, delta) => {
|
||||||
|
const mesh = meshRef.current as unknown as
|
||||||
|
| { position: { x: number; y: number; z: number }; rotation: { z: number; x: number } }
|
||||||
|
| null;
|
||||||
|
if (!mesh) return;
|
||||||
|
|
||||||
// Slow drift downward
|
// Slow drift downward
|
||||||
mesh.position.y -= delta * 0.3;
|
mesh.position.y -= delta * 0.3;
|
||||||
mesh.position.x += Math.sin(Date.now() * initial.driftX) * 0.002;
|
mesh.position.x += Math.sin(Date.now() * data.driftX) * 0.002;
|
||||||
mesh.position.z += Math.cos(Date.now() * initial.driftZ) * 0.001;
|
mesh.position.z += Math.cos(Date.now() * data.driftZ) * 0.001;
|
||||||
mesh.rotation.z += delta * initial.rotSpeed;
|
mesh.rotation.z += delta * data.rotSpeed;
|
||||||
mesh.rotation.x += delta * initial.rotSpeed * 0.3;
|
mesh.rotation.x += delta * data.rotSpeed * 0.3;
|
||||||
|
|
||||||
// Gentle mouse attraction
|
// Gentle mouse attraction
|
||||||
const dx = mouse.current.x * 3 - mesh.position.x;
|
if (mouseRef.current) {
|
||||||
const dy = mouse.current.y * 2 - mesh.position.y;
|
const dx = mouseRef.current.x * 3 - mesh.position.x;
|
||||||
mesh.position.x += dx * 0.0001;
|
const dy = mouseRef.current.y * 2 - mesh.position.y;
|
||||||
mesh.position.y += dy * 0.0001;
|
mesh.position.x += dx * 0.0001;
|
||||||
|
mesh.position.y += dy * 0.0001;
|
||||||
|
}
|
||||||
|
|
||||||
// Reset when out of view
|
// Reset when out of view
|
||||||
if (mesh.position.y < -10) {
|
if (mesh.position.y < -10) {
|
||||||
@@ -61,64 +80,63 @@ function Petal({ mouse }: { mouse: React.RefObject<THREE.Vector2 | null> }) {
|
|||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
ref={meshRef}
|
ref={meshRef}
|
||||||
geometry={petalGeometry}
|
position={[data.x, data.y, data.z]}
|
||||||
position={[initial.x, initial.y, initial.z]}
|
scale={[data.scale, data.scale, data.scale]}
|
||||||
scale={[0.12, 0.12, 0.12]}
|
|
||||||
>
|
>
|
||||||
|
<planeGeometry args={[1, 1]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={`hsl(${initial.hue}, ${initial.saturation}%, ${initial.lightness}%)`}
|
color={`hsl(${data.hue}, ${data.sat}%, ${data.light}%)`}
|
||||||
transparent
|
transparent
|
||||||
opacity={0.7}
|
opacity={0.65}
|
||||||
side={THREE.DoubleSide}
|
side={2}
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Scene ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ParticleScene() {
|
function ParticleScene() {
|
||||||
const mouse = useRef(new THREE.Vector2(0, 0));
|
const mouseRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const { size } = useThree();
|
const [viewportWidth, setViewportWidth] = useState(
|
||||||
const hiddenRef = useRef(false);
|
typeof window !== "undefined" ? window.innerWidth : 1024,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMouse = (e: MouseEvent) => {
|
const onMouse = (e: MouseEvent) => {
|
||||||
// Normalise to -1..1 relative to viewport
|
mouseRef.current = {
|
||||||
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
x: (e.clientX / window.innerWidth) * 2 - 1,
|
||||||
mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
y: -(e.clientY / window.innerHeight) * 2 + 1,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
window.addEventListener("mousemove", onMouse);
|
window.addEventListener("mousemove", onMouse);
|
||||||
|
|
||||||
const onVisibility = () => {
|
const onResize = () => setViewportWidth(window.innerWidth);
|
||||||
hiddenRef.current = document.hidden;
|
window.addEventListener("resize", onResize);
|
||||||
};
|
|
||||||
document.addEventListener("visibilitychange", onVisibility);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("mousemove", onMouse);
|
window.removeEventListener("mousemove", onMouse);
|
||||||
document.removeEventListener("visibilitychange", onVisibility);
|
window.removeEventListener("resize", onResize);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Petal count depends on viewport width (fewer on mobile)
|
const petalCount = getPetalCount(viewportWidth);
|
||||||
const petalCount = size.width < 640 ? 10 : size.width < 1024 ? 20 : 35;
|
const sparkleCount = getSparkleCount(viewportWidth);
|
||||||
const petals = useMemo(
|
|
||||||
() => Array.from({ length: petalCount }, (_, i) => <Petal key={i} mouse={mouse} />),
|
const petals = useMemo(() => createPetals(petalCount), [petalCount]);
|
||||||
[petalCount],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Ambient + directional for soft lighting */}
|
|
||||||
<ambientLight intensity={0.6} />
|
<ambientLight intensity={0.6} />
|
||||||
<directionalLight position={[5, 5, 5]} intensity={0.3} />
|
<directionalLight position={[5, 5, 5]} intensity={0.3} />
|
||||||
|
|
||||||
{/* Petals */}
|
{petals.map((data, i) => (
|
||||||
{!hiddenRef.current && petals}
|
<Petal key={i} data={data} mouseRef={mouseRef} />
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Sparkles from drei */}
|
|
||||||
<Sparkles
|
<Sparkles
|
||||||
count={size.width < 640 ? 15 : 30}
|
count={sparkleCount}
|
||||||
scale={12}
|
scale={12}
|
||||||
size={0.8}
|
size={0.8}
|
||||||
speed={0.2}
|
speed={0.2}
|
||||||
@@ -148,13 +166,13 @@ export function ParticleBackground() {
|
|||||||
<div
|
<div
|
||||||
className="fixed inset-0 pointer-events-none"
|
className="fixed inset-0 pointer-events-none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{ zIndex: -1 }}
|
style={{ zIndex: -1, isolation: "isolate" }}
|
||||||
>
|
>
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 0, 8], fov: 75 }}
|
camera={{ position: [0, 0, 8], fov: 75 }}
|
||||||
dpr={[1, 2]}
|
dpr={[1, 2]}
|
||||||
gl={{ antialias: false, alpha: true }}
|
gl={{ antialias: false, alpha: true }}
|
||||||
style={{ background: "transparent" }}
|
style={{ background: "transparent", width: "100%", height: "100%" }}
|
||||||
>
|
>
|
||||||
<ParticleScene />
|
<ParticleScene />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|||||||
Reference in New Issue
Block a user