255 lines
9.3 KiB
TypeScript
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 사용)
|