This commit is contained in:
monoid 2025-04-12 04:03:35 +09:00
commit 6c13ce3373
22 changed files with 3850 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

22
README.md Normal file
View file

@ -0,0 +1,22 @@
# React Three.js study
This is a simple React Three.js project that demonstrates how to use Three.js with React.
It includes a simple scene with a city and a globe.
## Getting Started
To get started, clone the repository and install the dependencies:
```bash
pnpm install
```
Then, start the development server:
```bash
pnpm run dev
```
Open your browser and navigate to `http://localhost:5173` to see the app in action.
You should see a simple scene with a city and a globe.

37
eslint.config.js Normal file
View file

@ -0,0 +1,37 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'unused-vars/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
args: 'none',
},
],
},
},
)

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "vite-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.0.6",
"@react-three/fiber": "^9.1.2",
"@tailwindcss/vite": "^4.1.3",
"@types/three": "^0.175.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"simplex-noise": "^4.0.3",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.3",
"three": "^0.175.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"
},
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
}

2883
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

0
src/App.css Normal file
View file

336
src/App1.tsx Normal file
View file

@ -0,0 +1,336 @@
import React, { useState, useRef, useMemo, FC } from 'react';
import { Canvas } from '@react-three/fiber';
// Environment, Cylinder, Sphere 임포트 추가
import { OrbitControls, Box, Plane, Environment, MeshReflectorMaterial, Cylinder, Sphere } from '@react-three/drei';
import * as THREE from 'three';
// --- 타입 정의 ---
// 건물 데이터 구조 정의
interface BuildingData {
id: number;
position: number[];
size: number[];
color: string;
hasRooftop: boolean; // 옥탑 구조물 유무 추가
}
// 나무 데이터 구조 정의
interface TreeData {
id: number;
position: number[];
}
// Building 컴포넌트 Props 타입 정의
interface BuildingProps extends BuildingData {
onClick: () => void;
}
// Tree 컴포넌트 Props 타입 정의
interface TreeProps extends TreeData {}
// Road 컴포넌트 Props 타입 정의
interface RoadProps {
position: number[];
size: number[];
}
// InfoBox 컴포넌트 Props 타입 정의
interface InfoBoxProps {
buildingInfo: BuildingData | null;
}
// --- 컴포넌트 정의 ---
/**
* ( )
*/
const Building: FC<BuildingProps> = ({ position, size, color, onClick, id, hasRooftop }) => {
const [hovered, setHovered] = useState<boolean>(false);
const meshRef = useRef<THREE.Mesh>(null!);
const effectiveColor = hovered ? '#007bff' : color;
const [width, height, depth] = size;
// 옥탑 구조물 크기 및 위치 계산
const rooftopHeight = height * 0.075;
const rooftopWidth = width * 0.7;
const rooftopDepth = depth * 0.7;
const rooftopYPosition = height / 2 + rooftopHeight / 2 - 0.01; // 본체 위에 살짝 겹치게
return (
<group position={position as [number, number, number]}>
{/* 본체 건물 */}
<Box
ref={meshRef}
args={[width, height, depth]}
// position 은 group 에서 설정하므로 여기선 [0, 0, 0] 기준
onClick={(event) => {
event.stopPropagation();
onClick();
}}
onPointerOver={(event) => {
event.stopPropagation();
setHovered(true);
}}
onPointerOut={(event) => setHovered(false)}
castShadow
receiveShadow
>
<meshStandardMaterial
color={effectiveColor}
metalness={0.1}
roughness={0.6}
/>
</Box>
{/* 옥탑 구조물 (선택적 렌더링) */}
{hasRooftop && (
<Box
args={[rooftopWidth, rooftopHeight, rooftopDepth]}
position={[0, rooftopYPosition, 0]} // 본체 상단 중앙
castShadow
receiveShadow
>
<meshStandardMaterial
color={effectiveColor} // 본체와 같은 색 또는 약간 다른 색
metalness={0.1}
roughness={0.6}
emissive={hovered ? '#555555' : '#000000'} // 호버 시 약간 밝게
/>
</Box>
)}
</group>
);
}
/**
*
*/
const Tree: FC<TreeProps> = ({ position }) => {
return (
<group position={position as [number, number, number]}>
{/* 바닥 */}
<Plane
args={[10, 10]} // 바닥 크기
rotation={[-Math.PI / 2, 0, 0]} // 바닥에 눕히기 위해 x축으로 -90도 회전
receiveShadow
>
<meshStandardMaterial color="#138b31" roughness={0.8} metalness={0.1} />
</Plane>
</group>
);
}
/**
*
*/
const Road: FC<RoadProps> = ({ position, size }) => {
return (
<Plane
args={size as [number, number]} // Plane args 타입에 맞게 캐스팅
position={position as [number, number, number]} // position 타입 캐스팅
rotation={[-Math.PI / 2, 0, 0]} // 바닥에 눕히기 위해 x축으로 -90도 회전
>
{/* 도로 색상을 중간 회색으로 변경 */}
<meshStandardMaterial color="#666666" side={THREE.DoubleSide} roughness={0.8} metalness={0.1} />
</Plane>
);
}
/**
* (Tailwind CSS )
*/
const InfoBox: FC<InfoBoxProps> = ({ buildingInfo }) => {
if (!buildingInfo) return null;
// 위치와 크기 정보를 소수점 아래 두 자리까지만 표시
const formatArray = (arr: number[]) => arr.map(n => n.toFixed(2)).join(', ');
return (
// Tailwind CSS 클래스 적용
<div className="absolute top-5 left-5 p-3 px-4 bg-black/70 rounded-lg text-white font-sans text-xs shadow-lg pointer-events-none z-50">
<h3 className="text-sm font-semibold mb-2">Selected Building</h3>
<p className="my-1">ID: {buildingInfo.id}</p>
{/* position[1]은 그룹 기준이므로 실제 바닥부터의 높이 표시 위해 size[1]/2 추가 */}
<p className="my-1">Position: [{formatArray([buildingInfo.position[0], buildingInfo.position[1] - buildingInfo.size[1]/2, buildingInfo.position[2]])}]</p>
<p className="my-1">Size: [{formatArray(buildingInfo.size)}]</p>
{/* 색상 정보는 hex 코드로 표시 */}
<p className="my-1">Color: {buildingInfo.color}</p>
</div>
);
}
// --- 메인 앱 컴포넌트 ---
const App: FC = () => {
// useState 타입 명시: BuildingData 또는 null
const [selectedBuilding, setSelectedBuilding] = useState<BuildingData | null>(null);
// 건물 클릭 시 호출될 함수
const handleBuildingClick = (buildingData: BuildingData) => {
setSelectedBuilding(buildingData);
};
// 도시 데이터 정의 (건물, 도로, 나무) - 모던 스타일 적용
const cityData = useMemo<{ buildings: BuildingData[]; roads: { position: number[]; size: number[] }[]; trees: TreeData[] }>(() => {
const buildings: BuildingData[] = [];
const roads: { position: number[]; size: number[] }[] = [];
const trees: TreeData[] = [];
const gridSize = 7;
const spacing = 5;
const roadWidth = 1.5;
const buildingBaseSize = 4;
const parkProbability = 0.15; // 공원(나무)이 될 확률
// 건물 또는 나무 생성
for (let i = -gridSize; i <= gridSize; i++) {
for (let j = -gridSize; j <= gridSize; j++) {
// 격자 교차점이 아닌 곳
if (Math.abs(i % 2) === 1 && Math.abs(j % 2) === 1) {
// 공원 확률 체크
if (Math.random() < parkProbability) {
// 나무 생성
trees.push({
id: trees.length + 1,
position: [i * spacing, 0, j * spacing] // 나무는 바닥(y=0)에서 시작
});
} else {
// 건물 생성
const height = Math.random() * 20 + 6;
const width = buildingBaseSize;
const depth = buildingBaseSize;
// 건물의 position.y는 건물의 중앙 높이
const position = [i * spacing, height / 2, j * spacing];
const size = [width, height, depth];
const hasRooftop = Math.random() > 0.6; // 40% 확률로 옥탑 구조물 생성
let color: string;
if (Math.random() < 0.15) {
const darkShade = Math.random() * 0.2 + 0.1;
color = new THREE.Color(darkShade, darkShade, darkShade).getHexString();
color = `#${color}`;
} else {
const lightShade = Math.random() * 0.3 + 0.7;
color = new THREE.Color(lightShade, lightShade, lightShade).getHexString();
color = `#${color}`;
}
buildings.push({
id: buildings.length + 1,
position: position,
size: size,
color: color,
hasRooftop: hasRooftop
});
}
}
}
}
// 도로 생성
const roadYPosition = 0.01;
const totalGridWidth = (gridSize * 2 + 1) * spacing;
for (let j = -gridSize; j <= gridSize; j++) {
if (Math.abs(j % 2) === 0) {
roads.push({ position: [0, roadYPosition, j * spacing], size: [totalGridWidth, roadWidth] });
}
}
for (let i = -gridSize; i <= gridSize; i++) {
if (Math.abs(i % 2) === 0) {
roads.push({ position: [i * spacing, roadYPosition, 0], size: [roadWidth, totalGridWidth] });
}
}
return { buildings, roads, trees };
}, []);
const groundColor = "#e0e0e0"; // 밝은 회색 바닥
return (
<div className="w-screen h-screen relative">
<InfoBox buildingInfo={selectedBuilding} />
<Canvas
camera={{ position: [0, 50, 50], fov: 50 }}
onPointerMissed={() => setSelectedBuilding(null)}
shadows
>
<color attach="background" args={[groundColor]} />
<fog attach="fog" args={[groundColor, 60, 150]} />
{/* 조명: Environment 사용 시 강도 조절 */}
<ambientLight intensity={0.1} />
{/* Environment preset과 어울리도록 DirectionalLight 강도/색상 조절 가능 */}
<directionalLight
position={[20, 30, 15]}
intensity={2} // 강도 약간 줄임
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
shadow-camera-far={70}
shadow-camera-left={-40}
shadow-camera-right={40}
shadow-camera-top={40}
shadow-camera-bottom={-40}
shadow-bias={-0.0005}
/>
<OrbitControls makeDefault maxPolarAngle={Math.PI / 2.1} />
{/* --- 환경 맵 추가 --- */}
{/* preset="city" 또는 "sunset", "dawn", "apartment" 등 시도 */}
<Environment preset="city" />
{/* 건물 렌더링 */}
{cityData.buildings.map((building) => (
<Building
key={building.id}
{...building} // 모든 속성 전달
onClick={() => handleBuildingClick(building)}
/>
))}
{/* 도로 렌더링 */}
{cityData.roads.map((road, index) => (
<Road key={`road-${index}`} position={road.position} size={road.size} />
))}
{/* 나무 렌더링 */}
{cityData.trees.map((tree) => (
<Tree key={tree.id} {...tree} />
))}
{/* --- 바닥 평면에 MeshReflectorMaterial 적용 --- */}
<Plane
args={[300, 300]}
rotation={[-Math.PI / 2, 0, 0]}
position={[0, -0.01, 0]}
receiveShadow
>
<MeshReflectorMaterial
resolution={1024}
mirror={0.8} // 반사 선명도 약간 낮춤
mixBlur={0.2} // 블러 약간 추가
mixStrength={0.8}
roughness={0.4}
metalness={0.6}
color={groundColor}
blur={[100, 100]} // 블러 값 조정 (성능 영향 주의)
/>
</Plane>
</Canvas>
</div>
);
}
export default App;
// --- TypeScript 및 Tailwind CSS 설정 참고 ---
// 이전과 동일
// --- 필요 라이브러리 설치 ---
// 이전과 동일
// --- 실행 방법 ---
// 이전과 동일

255
src/App2.tsx Normal file
View file

@ -0,0 +1,255 @@
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 사용)

24
src/AppSwitch.tsx Normal file
View file

@ -0,0 +1,24 @@
import { useState } from 'react';
import App1 from './App1.tsx';
import App2 from './App2.tsx';
export function AppSwitch() {
const [app, setApp] = useState('app2');
return (
<div className="flex flex-col items-center justify-center h-screen relative">
<div className='absolute top-0 right-0 z-50'>
<select className='bg-gray-200 p-2 rounded-md' value={app} onChange={(e) => setApp(e.target.value)}>
<option value="app1">App 1</option>
<option value="app2">App 2</option>
<option value="app3">App 3</option>
</select>
</div>
<div className="w-full h-full flex items-center justify-center">
{app === 'app1' && <App1 />}
{app === 'app2' && <App2 />}
{app === 'app3' && <div>App 3</div>}
</div>
</div>
);
}

1
src/assets/react.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

12
src/index.css Normal file
View file

@ -0,0 +1,12 @@
@import "tailwindcss";
/**
import inter font
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { AppSwitch } from './AppSwitch.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AppSwitch />
</StrictMode>,
)

107
src/texture-generator.ts Normal file
View file

@ -0,0 +1,107 @@
import { createNoise3D } from "simplex-noise";
export const generateGlobeTextureData = (width: number, height: number, seed?: number): {
data: Uint8Array;
width: number;
height: number;
} => {
const size = width * height;
const data = new Uint8Array(4 * size); // RGBA
seed = seed ?? Math.floor(Math.random() * 10000); // 시드 초기화 (랜덤 시드 생성)
const getRandom = (seed: number) => () => {
// 시드가 주어지면 고정된 랜덤 생성기 사용
let x = Math.sin(seed) * 10000;
x = (x - Math.floor(x)) * 10000;
seed += 1; // 시드 증가
return x - Math.floor(x); // 0 ~ 1 사이 값 반환
} // 시드가 주어지면 고정된 랜덤 생성기 사용 (선택 사항)
const noise3D = createNoise3D(getRandom(seed)); // 3D 노이즈 생성 함수
const noise3D2 = createNoise3D(getRandom(seed ^ 12)); // 3D 노이즈 생성 함수 (다른 스케일을 위해)
const noise3D3 = createNoise3D(getRandom(seed ^ 224)); // 3D 노이즈 생성 함수 (다른 스케일을 위해)
const noiseScale = 2; // 노이즈 스케일 (값이 클수록 지형이 작아짐)
const waterLevel = 0.05; // 해수면 높이 (-1 ~ 1 사이 값)
const mountainLevel = 0.4; // 산악 지형 시작 높이
const snowLine = 0.8; // 만년설 시작 높이 (위도와 조합)
for (let i = 0; i < size; i++) {
const stride = i * 4;
const u = (i % width) / width; // 0 to 1 (경도 방향)
const v = Math.floor(i / width) / height; // 0 to 1 (위도 방향)
// 위도, 경도를 3D 좌표로 변환 (단위 구 기준)
const lat = (v - 0.5) * 180;
const lon = (u - 0.5) * 360;
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lon + 180) * (Math.PI / 180);
const x = -Math.sin(phi) * Math.cos(theta);
const z = Math.sin(phi) * Math.sin(theta);
const y = Math.cos(phi);
// 3D Simplex Noise 값 계산 (-1 ~ 1)
const noiseValue = noise3D(x * noiseScale, y * noiseScale, z * noiseScale)
+ noise3D2(x * noiseScale * 0.5, y * noiseScale * 0.5, z * noiseScale * 0.5) * 0.5
+ noise3D3(x * noiseScale * 0.25, y * noiseScale * 0.25, z * noiseScale * 0.25) * 0.25; // 노이즈 조합
let r = 50; // 바다 기본 R
let g = 100; // 바다 기본 G
let b = 200; // 바다 기본 B
let a = 255; // Alpha
const absLat = Math.abs(lat);
if (noiseValue > waterLevel) {
// 육지
const elevation = (noiseValue - waterLevel) / (1 - waterLevel); // 0 ~ 1 정규화
if (elevation < mountainLevel) {
// 평지/언덕 (녹색)
r = 80 + 70 * elevation + Math.random() * 10;
g = 140 + 60 * (1 - elevation) + Math.random() * 10;
b = 40 + 30 * elevation + Math.random() * 5;
} else {
// 산악 (갈색/회색)
const mountainFactor = (elevation - mountainLevel) / (1 - mountainLevel); // 0 ~ 1
r = 140 + 40 * mountainFactor;
g = 120 + 20 * mountainFactor;
b = 100 + 10 * mountainFactor;
// 높은 산 + 높은 위도 = 눈 (흰색)
const snowChance = Math.max(0, (absLat - 60) / 30) + mountainFactor * 0.5; // 위도 60도 이상부터 눈 확률 증가
if (elevation > snowLine || snowChance > 0.5) {
r = 240;
g = 240;
b = 245;
}
}
} else {
// 바다 (깊이에 따라 색상 변화)
const depthFactor = Math.abs((noiseValue - waterLevel) / ( 1 + waterLevel)); // 0 ~ 1 정규화 (깊이 비율)
r = 50 - 30 * depthFactor;
g = 100 - 50 * depthFactor;
b = 200 - 40 * depthFactor;
}
// 극지방 얼음 (기존 로직과 유사하게, 노이즈 값과 무관하게 덮어쓰기)
if (absLat > 75) {
let polarFactor = (absLat - 75) / 15; // 0 to 1
polarFactor = 1 - Math.pow(1 - polarFactor, 5);
// 얼음/눈 (흰색)
r = 245 * polarFactor + (1 - polarFactor) * r;
g = 245 * polarFactor + (1 - polarFactor) * g;
b = 250 * polarFactor + (1 - polarFactor) * b;
}
data[stride] = Math.max(0, Math.min(255, r));
data[stride + 1] = Math.max(0, Math.min(255, g));
data[stride + 2] = Math.max(0, Math.min(255, b));
data[stride + 3] = a; // Alpha 값 설정
}
// 데이터 배열과 크기를 반환
return { data, width, height };
};

17
src/textureWorker.ts Normal file
View file

@ -0,0 +1,17 @@
/// <reference lib="webworker" />
import { generateGlobeTextureData } from "./texture-generator";
// Worker 메시지 리스너
self.onmessage = (event: MessageEvent<{ width: number, height: number, seed?: number }>) => {
const { width, height, seed } = event.data;
console.log("Worker: ", width, height, seed);
const textureData = generateGlobeTextureData(width, height, seed);
// 생성된 데이터를 메인 스레드로 전송 (Transferable Object로 성능 향상)
self.postMessage(textureData,
// transferable objects
[textureData.data.buffer]
);
};
export {}; // 모듈로 인식시키기 위함

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

26
tsconfig.app.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

8
vite.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [tailwindcss(), react()],
})