react-threejs-example/src/App2.tsx
2025-04-12 04:03:35 +09:00

255 lines
9.3 KiB
TypeScript

import React, { useState, useRef, useMemo, FC, useEffect } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Sphere, Cone, Box, Text } from '@react-three/drei';
import * as THREE from 'three';
import { createNoise3D } from 'simplex-noise';
import { generateGlobeTextureData } from './texture-generator';
// --- 타입 정의 ---
interface CityData {
name: string;
lat: number; // 위도 (degrees)
lon: number; // 경도 (degrees)
population?: number; // 예시 데이터
}
interface SymbolicBuildingProps {
position: THREE.Vector3;
cityName: string;
scale?: number;
onPointerOver: () => void;
onPointerOut: () => void;
}
interface GlobeProps {
radius: number;
}
// --- 유틸리티 함수 ---
/**
* 위도, 경도를 3D 벡터 좌표로 변환
* @param lat 위도 (degrees)
* @param lon 경도 (degrees)
* @param radius 구의 반지름
* @returns THREE.Vector3 좌표
*/
const latLonToVector3 = (lat: number, lon: number, radius: number): THREE.Vector3 => {
const phi = (90 - lat) * (Math.PI / 180); // 위도를 극 각도(phi)로 변환 (라디안)
const theta = (lon + 180) * (Math.PI / 180); // 경도를 방위각(theta)으로 변환 (라디안)
const x = -(radius * Math.sin(phi) * Math.cos(theta));
const z = radius * Math.sin(phi) * Math.sin(theta);
const y = radius * Math.cos(phi);
return new THREE.Vector3(x, y, z);
};
/**
* 간단한 절차적 지구본 텍스처 생성 (Simplex Noise 사용)
* @param width 텍스처 너비
* @param height 텍스처 높이
* @returns THREE.DataTexture
*/
const generateGlobeTexture = (width: number, height: number, seed?: number): THREE.DataTexture => {
const { data } = generateGlobeTextureData(width, height, seed); // 텍스처 데이터 생성
const texture = new THREE.DataTexture(
data,
width,
height,
THREE.RGBAFormat // RGBA 포맷 사용
);
texture.needsUpdate = true;
// 텍스처 필터링 및 래핑 설정 (선택 사항)
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter; // 밉맵 사용 시
texture.wrapS = THREE.ClampToEdgeWrapping; // 구 텍스처는 ClampToEdge가 더 적합할 수 있음
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.generateMipmaps = true; // 밉맵 생성
return texture;
};
// --- 컴포넌트 정의 ---
/**
* 도시를 상징하는 건물 컴포넌트 (Cone 사용)
*/
const SymbolicBuilding: FC<SymbolicBuildingProps> = ({ position, cityName, scale = 1, onPointerOver, onPointerOut }) => {
const coneRef = useRef<THREE.Mesh>(null!);
const textRef = useRef<any>(null); // drei Text 타입 추론 어려움
const [hovered, setHovered] = useState(false);
// 건물이 구 표면에서 바깥쪽을 향하도록 방향 설정
useEffect(() => {
if (coneRef.current) {
coneRef.current.lookAt(0, 0, 0); // 구의 중심을 바라보게 하여 위쪽이 바깥을 향하도록
coneRef.current.rotateX(Math.PI / 2); // Cone은 기본적으로 Y축 방향이므로 X축 회전 추가
}
if (textRef.current) {
// 텍스트도 같은 방향을 보도록 설정
textRef.current.lookAt(1000 * position.x, 1000 * position.y, 1000 * position.z); // 카메라를 향하도록
}
}, [position]);
const coneHeight = 0.5 * scale;
const coneRadius = 0.1 * scale;
return (
<group position={position}>
<Cone
ref={coneRef}
args={[coneRadius, coneHeight, 8]} // 반지름, 높이, 분할 수
position={[0, coneHeight / 2, 0]} // 위치 조정 (Cone의 기준점은 바닥 중앙)
castShadow
onPointerOver={(e) => { e.stopPropagation(); setHovered(true); onPointerOver(); }}
onPointerOut={(e) => { setHovered(false); onPointerOut(); }}
>
<meshStandardMaterial color={hovered ? 'red' : 'yellow'} emissive={hovered ? 'darkred' : 'black'} />
</Cone>
{/* 호버 시 도시 이름 표시 */}
<Text
ref={textRef}
position={[0, coneHeight + 0.2, 0]} // 콘 위쪽에 위치
fontSize={0.15}
color="white"
anchorX="center"
anchorY="middle"
visible={hovered}
depthOffset={-1000} // 다른 객체 위에 렌더링되도록
>
{cityName}
</Text>
</group>
);
};
const mapSeed = Math.random() * 1000; // 랜덤 시드 (선택 사항)
/**
* 지구본 컴포넌트
*/
const Globe: FC<GlobeProps> = ({ radius }) => {
const meshRef = useRef<THREE.Mesh>(null!);
const [globeTexture, setGlobeTexture] = useState<THREE.DataTexture | null>(
() => generateGlobeTexture(256, 128, mapSeed) // 초기 텍스처 생성 (메인 스레드에서)
);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
// 웹 워커를 사용하여 텍스처 생성
workerRef.current = new Worker(new URL('./textureWorker.ts', import.meta.url), { type: 'module' });
workerRef.current.onmessage = (event) => {
const { data, width, height } = event.data;
const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat);
texture.needsUpdate = true;
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.generateMipmaps = true;
console.log('Texture generated in worker:', texture);
setGlobeTexture(texture);
};
workerRef.current.postMessage({ width: 4096, height: 2048, seed: mapSeed });
return () => {
if (workerRef.current) {
workerRef.current.terminate();
}
};
}, []);
return (
<Sphere ref={meshRef} args={[radius, 64, 64]} receiveShadow castShadow>
{/* 절차적 텍스처 적용 */}
<meshStandardMaterial
map={globeTexture}
roughness={1}
metalness={0.5}
color={"#ffffff"} // 기본 색상 (흰색)
// bumpMap={globeTexture} // 같은 텍스처를 범프맵으로 사용 (선택 사항)
// bumpScale={0.01}
/>
</Sphere>
);
};
// --- 메인 앱 컴포넌트 ---
const App: FC = () => {
const globeRadius = 5; // 지구본 반지름
const [hoveredCity, setHoveredCity] = useState<string | null>(null);
// 주요 도시 데이터
const cities: CityData[] = useMemo(() => [
{ name: 'Seoul', lat: 37.5665, lon: 126.9780 },
{ name: 'New York', lat: 40.7128, lon: -74.0060 },
{ name: 'London', lat: 51.5074, lon: -0.1278 },
{ name: 'Tokyo', lat: 35.6895, lon: 139.6917 },
{ name: 'Sydney', lat: -33.8688, lon: 151.2093 },
{ name: 'Cairo', lat: 30.0444, lon: 31.2357 },
{ name: 'Rio de Janeiro', lat: -22.9068, lon: -43.1729 },
{ name: 'Moscow', lat: 55.7558, lon: 37.6173 },
], []);
return (
// Tailwind CSS 적용
<div className="w-screen h-screen relative bg-black">
{/* 호버된 도시 이름 표시 (선택 사항) */}
{/*
<div className="absolute top-5 left-5 text-white font-sans z-50">
Hovered: {hoveredCity || 'None'}
</div>
*/}
<Canvas shadows camera={{ position: [0, 0, 15], fov: 45 }}>
{/* 배경색 제거 (우주처럼 검게) */}
{/* <color attach="background" args={['#111111']} /> */}
<ambientLight intensity={0.3} />
<directionalLight
position={[5, 5, 5]} // 태양광처럼 멀리서 비추는 빛
intensity={5}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
<OrbitControls enablePan={false} minDistance={8} maxDistance={30} />
{/* 지구본 렌더링 */}
<Globe radius={globeRadius} />
{/* 도시 건물 렌더링 */}
{cities.map((city) => {
const position = latLonToVector3(city.lat, city.lon, globeRadius);
return (
<SymbolicBuilding
key={city.name}
position={position}
cityName={city.name}
scale={0.5} // 건물 크기 조절
onPointerOver={() => setHoveredCity(city.name)}
onPointerOut={() => setHoveredCity(null)}
/>
);
})}
</Canvas>
</div>
);
}
export default App;
// --- TypeScript 및 Tailwind CSS 설정 참고 ---
// 이전과 동일
// --- 필요 라이브러리 설치 ---
// npm install react react-dom three @react-three/fiber @react-three/drei
// yarn add react react-dom three @react-three/fiber @react-three/drei
// npm install --save-dev @types/three
// --- 실행 방법 ---
// 이전과 동일 (App.tsx 사용)