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 = ({ position, cityName, scale = 1, onPointerOver, onPointerOut }) => { const coneRef = useRef(null!); const textRef = useRef(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 ( { e.stopPropagation(); setHovered(true); onPointerOver(); }} onPointerOut={(e) => { setHovered(false); onPointerOut(); }} > {/* 호버 시 도시 이름 표시 */} {cityName} ); }; const mapSeed = Math.random() * 1000; // 랜덤 시드 (선택 사항) /** * 지구본 컴포넌트 */ const Globe: FC = ({ radius }) => { const meshRef = useRef(null!); const [globeTexture, setGlobeTexture] = useState( () => generateGlobeTexture(256, 128, mapSeed) // 초기 텍스처 생성 (메인 스레드에서) ); const workerRef = useRef(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 ( {/* 절차적 텍스처 적용 */} ); }; // --- 메인 앱 컴포넌트 --- const App: FC = () => { const globeRadius = 5; // 지구본 반지름 const [hoveredCity, setHoveredCity] = useState(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 적용
{/* 호버된 도시 이름 표시 (선택 사항) */} {/*
Hovered: {hoveredCity || 'None'}
*/} {/* 배경색 제거 (우주처럼 검게) */} {/* */} {/* 지구본 렌더링 */} {/* 도시 건물 렌더링 */} {cities.map((city) => { const position = latLonToVector3(city.lat, city.lon, globeRadius); return ( setHoveredCity(city.name)} onPointerOut={() => setHoveredCity(null)} /> ); })}
); } 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 사용)