Initial commit of Worldview_Dashboard

This commit is contained in:
Flook 2025-07-04 11:39:17 +07:00
commit 57797bc5e0
37 changed files with 5561 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# node_modules ไม่ควรเก็บใน git
node_modules/
# build output
dist/
.vite/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# dotenv
.env
.env.local
.env.*.local
# IDE files
.vscode/
.idea/
.DS_Store

106
README.md Normal file
View File

@ -0,0 +1,106 @@
# Worldview Dashboard
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
## Table of Contents
- [เกี่ยวกับโปรเจกต์](#เกี่ยวกับโปรเจกต์)
- [ฟีเจอร์หลัก](#ฟีเจอร์หลัก)
- [ภาพรวม Tech Stack](#ภาพรวม-tech-stack)
- [การติดตั้งและเริ่มต้นใช้งาน](#การติดตั้งและเริ่มต้นใช้งาน)
- [Prerequisites](#prerequisites)
- [Clone Repository](#clone-repository)
- [การติดตั้ง Dependencies](#การติดตั้ง-dependencies)
- [การรัน Development Server](#การรัน-development-server)
- [การสร้าง Production Build](#การสร้าง-production-build)
- [โครงสร้างไฟล์ของโปรเจกต์](#โครงสร้างไฟล์ของโปรเจกต์)
- [วิธีใช้งาน Dashboard](#วิธีใช้งาน-dashboard)
- [การมีส่วนร่วม](#การมีส่วนร่วม)
- [License](#license)
- [ติดต่อ](#ติดต่อ)
- [Acknowledgements](#acknowledgements)
---
## เกี่ยวกับโปรเจกต์
**Worldview Dashboard** เป็นเว็บแอปพลิเคชันเชิงโต้ตอบที่นำเสนอข้อมูลข่าวสารและสถิติสำคัญทั่วโลกผ่านแผนที่ 3 มิติแบบไดนามิก และสามารถสลับไปเป็นแผนที่ 2 มิติในมุมมองเฉพาะพื้นที่ได้ พัฒนาขึ้นเพื่อเป็นเครื่องมือในการสำรวจข้อมูลเชิงภูมิศาสตร์ในรูปแบบที่ทันสมัยและน่าดึงดูดใจ
โปรเจกต์นี้ใช้ประโยชน์จากพลังของ React และ React Three Fiber ในการเรนเดอร์โลก 3 มิติที่สวยงาม พร้อมด้วย Three.js สำหรับการจัดการฉาก 3D ที่ซับซ้อน และ Leaflet สำหรับการแสดงแผนที่ 2 มิติที่คุ้นเคย
## ฟีเจอร์หลัก
* **Interactive 3D Globe (Global View):**
* แสดงโลก 3 มิติแบบหมุนได้ พร้อมเอฟเฟกต์พื้นหลังดวงดาว (Starfield).
* **Markers (หมุด):** แสดงตำแหน่งของเหตุการณ์ข่าวสารหรือจุดข้อมูลสถิติต่างๆ ทั่วโลก (สามารถกรองตามโหมด News/Stats).
* **Connection Lines:** เส้นเชื่อมโยงระหว่างจุดที่สำคัญหรือมีความสัมพันธ์กัน.
* **Orbiting Satellites:** ดาวเทียม 3D โคจรรอบโลก เพิ่มความสมจริงและน่าสนใจ (หากได้ทำการเพิ่มฟีเจอร์นี้แล้ว).
* **Dynamic Rotation:** โลกจะหมุนไปยังตำแหน่งของ Panel ที่ถูกเลือกโดยอัตโนมัติ.
* **Local 2D Map View:**
* **Seamless Transition:** เมื่อผู้ใช้ซูมเข้าใกล้โลก 3D ถึงระดับหนึ่ง จะสลับไปแสดงแผนที่ 2 มิติ (Leaflet) ในบริเวณนั้นโดยอัตโนมัติ.
* แสดง Marker ในมุมมองแผนที่ 2 มิติ.
* ปุ่มสำหรับกลับไปยัง Global 3D View.
* **Dynamic Information Panels:**
* แผงควบคุมด้านข้างที่แสดงข้อมูลรายละเอียดเมื่อคลิกที่ Marker หรือเลือก Panel จาก Global Navigation.
* **โหมด News:** แสดงหัวข้อข่าวสารและเนื้อหาที่เกี่ยวข้องกับภูมิภาคที่เลือก.
* **โหมด Statistics:** แสดงข้อมูลสถิติต่างๆ เช่น ดัชนีตลาด, สถิติประชากร, การใช้พลังงาน.
* **Live Time & Population Display:** แสดงเวลาปัจจุบันและตัวเลขประมาณการประชากรโลกที่มุมขวาบน.
* **Global Navigation:**
* เมนูนำทางทางซ้ายมือสำหรับเลือกดูข้อมูลข่าวสารหรือสถิติในแต่ละ Panel.
* สลับโหมดหลักระหว่าง "NEWS" และ "STATISTICS".
* **Pop-up Details:**
* แสดงข้อมูลสรุปสั้นๆ เมื่อคลิกที่ Marker บนโลก 3D หรือแผนที่ 2D.
## ภาพรวม Tech Stack
* **Frontend Framework:** [React](https://react.dev/)
* **3D Graphics:** [Three.js](https://threejs.org/) และ [React Three Fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) (React renderer สำหรับ Three.js)
* **3D Utilities (Drei):** [@react-three/drei](https://github.com/pmndrs/drei)
* **2D Mapping:** [Leaflet](https://leafletjs.com/) และ [React-Leaflet](https://react-leaflet.js.org/) (สำหรับใช้งาน Leaflet ใน React)
* **Styling:** [Tailwind CSS](https://tailwindcss.com/) และ [DaisyUI](https://daisyui.com/) (Component library สำหรับ Tailwind)
* **Version Control:** [Git](https://git-scm.com/)
* **Git Hosting:** [Gitea](https://gitea.io/en-us/)
## การติดตั้งและเริ่มต้นใช้งาน
ทำตามขั้นตอนด้านล่างเพื่อตั้งค่าและรัน Worldview Dashboard บนเครื่องของคุณ
### Prerequisites
ตรวจสอบให้แน่ใจว่าคุณได้ติดตั้งสิ่งต่อไปนี้แล้ว:
* [Node.js](https://nodejs.org/en) (แนะนำ LTS version)
* [npm](https://www.npmjs.com/) (มาพร้อมกับ Node.js) หรือ [Yarn](https://yarnpkg.com/)
### Clone Repository
เริ่มต้นด้วยการ clone repository ของโปรเจกต์นี้:
```bash
git clone https://gitea.softwarecraft.tech/gitea/Worldview_Dashboard.git
```
### การติดตั้ง Dependencies
หลังจาก clone โปรเจกต์แล้ว ให้ติดตั้ง packages ที่จำเป็นทั้งหมด:
```bash
npm install
```
### การรัน Development Server
หากต้องการรันแอปพลิเคชันในโหมดพัฒนา (development mode):
```bash
npm run dev
```
### License
โปรเจกต์นี้เผยแพร่ภายใต้ MIT License
### Acknowledgements
ขอบคุณผู้สร้างและทีมพัฒนาของ React, React Three Fiber, Three.js, Leaflet, Tailwind CSS, DaisyUI, และไลบรารี Open Source อื่น ๆ ที่เป็นส่วนสำคัญของโปรเจกต์นี้ครับ

29
eslint.config.js Normal file
View File

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="corporate">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/globe.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Worldview Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4115
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "future_website",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.4.2",
"@react-three/fiber": "^9.1.4",
"@tailwindcss/vite": "^4.1.11",
"daisyui": "^5.0.43",
"gsap": "^3.13.0",
"leaflet": "^1.9.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"tailwindcss": "^4.1.11",
"three": "^0.178.0"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"vite": "^7.0.0",
"vite-plugin-glsl": "^1.5.1"
}
}

BIN
public/globe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
public/models/satellite.glb Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

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

266
src/App.jsx Normal file
View File

@ -0,0 +1,266 @@
// src/App.jsx
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import GlobeCanvas from './components/GlobeCanvas';
import NewsPanel from './components/NewsPanel';
import StatsPanel from './components/StatsPanel';
import GlobalNav from './components/GlobalNav';
import PopUpDetail from './components/PopUpDetail';
import LocalMap from './components/LocalMap'; // ()
function App() {
const [isNewsMode, setIsNewsMode] = useState(true);
const [activePanel, setActivePanel] = useState('world_news');
const [selectedPoint, setSelectedPoint] = useState(null);
const [isPanelVisible, setIsPanelVisible] = useState(true);
// State Local View
const [isLocalView, setIsLocalView] = useState(false);
const [localViewCenter, setLocalViewCenter] = useState({ lat: 0, lon: 0 });
const [localViewZoom, setLocalViewZoom] = useState(10); // Leaflet
// Data Panel -
const panelData = {
'world_news': { title: 'WORLD NEWS', type: 'news', content: 'Global events unfold across continents, shaping economies and societies.', youtubeVideoId: 'dQw4w9WgXcQ' },
'euro_news': { title: 'EURO NEWS', type: 'news', content: 'Key developments in European politics and markets are impacting global trends.', youtubeVideoId: '1' },
'america_news': { title: 'AMERICA NEWS', type: 'news', content: 'Breaking news from North and South America, focusing on economic shifts.', youtubeVideoId: '6' },
'china_japan_news': { title: 'CHINA / JAPAN NEWS', type: 'news', content: 'Latest economic and cultural updates from China and Japan, shaping regional dynamics.', youtubeVideoId: '11' },
'asia_news': { title: 'ASIA NEWS', type: 'news', content: 'Emerging tech and market news from Asia, driving innovation forward.', youtubeVideoId: 'k4F9c40tWnQ' },
'market_review': {
title: 'MARKET REVIEW', type: 'stats',
data: [
{ label: 'DOW JONES', value: '38,712', trend: 'up' },
{ label: 'S&P 500', value: '5,472', trend: 'down' },
{ label: 'NASDAQ', value: '17,857', trend: 'up' },
{ label: 'NIKKEI', value: '38,622', trend: 'same' },
{ label: 'FTSE 100', value: '8,208', trend: 'up' },
],
youtubeVideoId: 'F90M73o9y5c'
},
'next_dollar_currencies': {
title: 'NEXT DOLLAR CURRENCIES', type: 'stats',
data: [],
youtubeVideoId: '041pM7vYwYI'
},
'global_population': {
title: 'GLOBAL POPULATION', type: 'stats',
data: [
{ label: 'Current', value: '7.42 Billion People', trend: 'up' },
{ label: 'Growth Rate', value: '1.09%', trend: 'same' },
{ label: 'Births Today', value: '385,000', trend: 'up' },
{ label: 'Deaths Today', value: '160,000', trend: 'up' },
]
},
'energy_consumption': {
title: 'ENERGY CONSUMPTION', type: 'stats',
data: [
{ label: 'Oil Price (USD)', value: '80.50', trend: 'down' },
{ label: 'Natural Gas', value: '2.80 MMBtu', trend: 'up' },
{ label: 'Renewable Share', value: '25.3%', trend: 'up' },
{ label: 'Coal Consumption', value: 'Down 5%', trend: 'down' },
]
},
'russia_news_stats': {
title: 'RUSSIA ECONOMIC NEWS', type: 'stats',
data: [
{ label: 'BIX Index', value: '124.5', trend: 'up' },
{ label: 'MICEX Index', value: '3,200', trend: 'up' },
{ label: 'Oil Production', value: '10.5 M bbl/d', trend: 'same' },
],
youtubeVideoId: 'your-russia-video-id'
},
};
const globePoints = [
{ id: 'us', name: 'USA', lat: 39.8283, lon: -98.5795, type: 'news', panel: 'america_news', news: ['US stock market surges.', 'Tech innovation continues.'] },
{ id: 'uk', name: 'UK', lat: 51.5074, lon: -0.1278, type: 'news', panel: 'euro_news', news: ['Brexit impact on trade.', 'New UK climate policies.'] },
{ id: 'germany', name: 'Germany', lat: 51.1657, lon: 10.4515, type: 'news', panel: 'euro_news', news: ['German economy outlook.', 'Renewable energy growth.'] },
{ id: 'china', name: 'China', lat: 35.8617, lon: 104.1954, type: 'news', panel: 'china_japan_news', news: ['China trade balance.', 'Digital currency trials.'] },
{ id: 'japan', name: 'Japan', lat: 36.2048, lon: 138.2529, type: 'news', panel: 'china_japan_news', news: ['Japan tech advancements.', 'Olympic preparations.'] },
{ id: 'russia', name: 'Russia', lat: 61.5240, lon: 105.3188, type: 'stats', panel: 'russia_news_stats', news: ['Russia energy exports.', 'Geopolitical developments.'] },
{ id: 'brazil', name: 'Brazil', lat: -14.2350, lon: -51.9253, type: 'stats', panel: 'next_dollar_currencies', news: ['Brazilian Real fluctuation.', 'Agricultural exports outlook.'] },
{ id: 'india', name: 'India', lat: 20.5937, lon: 78.9629, type: 'stats', panel: 'market_review', news: ['Indian market growth.', 'Startup ecosystem booming.'] },
{ id: 'australia', name: 'Australia', lat: -25.2744, lon: 133.7751, type: 'news', panel: 'world_news', news: ['Australia bushfire recovery.', 'Mining sector update.'] },
];
const globePointsWithRussia = useMemo(() => {
const existingRussiaPoint = globePoints.find(p => p.id === 'russia');
if (!existingRussiaPoint) {
return [...globePoints, { id: 'russia', name: 'Russia', lat: 61.5240, lon: 105.3188, type: 'stats', panel: 'russia_news_stats', news: ['Russia energy exports.', 'Geopolitical developments.'] }];
}
return globePoints;
}, [globePoints]);
const globeConnections = [
{ id: 'us-uk', startLat: 39.8283, startLon: -98.5795, endLat: 51.5074, endLon: -0.1278 },
{ id: 'uk-germany', startLat: 51.5074, startLon: -0.1278, endLat: 51.1657, endLon: 10.4515 },
{ id: 'china-japan', startLat: 35.8617, startLon: 104.1954, endLat: 36.2048, endLon: 138.2529 },
{ id: 'us-china', startLat: 39.8283, startLon: -98.5795, endLat: 35.8617, endLon: 104.1954 },
];
const panelLocations = useMemo(() => {
const locations = {};
for (const panelId in panelData) {
locations[panelId] = { ...panelData[panelId] };
}
globePointsWithRussia.forEach(point => {
if (point.panel && locations[point.panel]) {
locations[point.panel].lat = point.lat;
locations[point.panel].lon = point.lon;
}
});
if (!locations['world_news'].lat && !locations['world_news'].lon) { locations['world_news'].lat = -25.2744; locations['world_news'].lon = 133.7751; }
if (!locations['asia_news'].lat && !locations['asia_news'].lon) { locations['asia_news'].lat = 30; locations['asia_news'].lon = 90; }
if (!locations['euro_news'].lat && !locations['euro_news'].lon) { locations['euro_news'].lat = 50; locations['euro_news'].lon = 10; }
if (!locations['america_news'].lat && !locations['america_news'].lon) { locations['america_news'].lat = 35; locations['america_news'].lon = -100; }
if (!locations['global_population'].lat && !locations['global_population'].lon) { locations['global_population'].lat = 0; locations['global_population'].lon = 0; }
if (!locations['energy_consumption'].lat && !locations['energy_consumption'].lon) { locations['energy_consumption'].lat = 0; locations['energy_consumption'].lon = 0; }
if (!locations['russia_news_stats'].lat && !locations['russia_news_stats'].lon) { locations['russia_news_stats'].lat = 61.5240; locations['russia_news_stats'].lon = 105.3188; }
return locations;
}, [panelData, globePointsWithRussia]);
const handlePointClick = useCallback((point) => {
setSelectedPoint(point);
if (point.panel) {
setActivePanel(point.panel);
if (point.type === 'news') setIsNewsMode(true);
else if (point.type === 'stats') setIsNewsMode(false);
setIsPanelVisible(true);
// Local View GlobeCanvas
}
}, []);
const closePopUp = useCallback(() => {
setSelectedPoint(null);
}, []);
// Callback GlobeCanvas Local View
const handleZoomToLocal = useCallback(({ lat, lon }) => {
if (!isLocalView) {
setIsLocalView(true);
setLocalViewCenter({ lat, lon });
setLocalViewZoom(10);
console.log(`App.jsx: Switching to Local View at Lat: ${lat}, Lon: ${lon}`);
}
}, [isLocalView]);
const handleExitLocalView = useCallback(() => {
if (isLocalView) { //
setIsLocalView(false);
console.log("App.jsx: Exiting Local View");
}
}, [isLocalView]);
const currentPanel = panelData[activePanel];
const [currentTime, setCurrentTime] = useState('');
const [population, setPopulation] = useState('7.42 Billion People');
useEffect(() => {
const timer = setInterval(() => {
const now = new Date();
setCurrentTime(now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }));
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<div className="relative flex flex-col md:flex-row h-screen w-screen bg-black font-mono overflow-hidden">
{/* Conditional Rendering: แสดง GlobeCanvas หรือ LocalMap */}
{!isLocalView ? (
<GlobeCanvas
activePanel={activePanel}
isNewsMode={isNewsMode}
panelLocations={panelLocations}
globePoints={globePointsWithRussia}
globeConnections={globeConnections}
onPointClick={handlePointClick}
onZoomToLocal={handleZoomToLocal} // callback GlobeCanvas
isLocalView={isLocalView} // state GlobeCanvas OrbitControls
/>
) : (
<div className="absolute inset-0 z-0">
<LocalMap
center={localViewCenter}
zoom={localViewZoom}
points={globePointsWithRussia} //
onPointClick={handlePointClick} // Leaflet PopUp
/>
{/* ปุ่มสำหรับออกจาก Local View */}
<button
className="btn btn-secondary absolute top-4 left-1/2 -translate-x-1/2 z-30 pointer-events-auto"
onClick={handleExitLocalView}
>
Exit Local View
</button>
</div>
)}
{/* UI Elements Overlay (ด้านหน้า) - ยังคงอยู่เหนือ Globe/Map */}
<div className="absolute inset-0 flex flex-col md:flex-row justify-between items-center p-8 z-20 pointer-events-none">
{/* GlobalNav - อยู่ทางซ้ายมือของโลก */}
<div className="flex-none p-4 md:p-0 pointer-events-auto w-full md:w-auto self-start md:self-center">
<GlobalNav
activePanel={activePanel}
setActivePanel={setActivePanel}
isNewsMode={isNewsMode}
setIsNewsMode={setIsNewsMode}
/>
</div>
{/* ส่วนกลางที่ว่างอยู่สำหรับโลก (ไม่ได้มี div แยก) */}
{/* NewsPanel และ StatsPanel - อยู่ทางขวามือของโลก */}
{currentPanel && (
<div className="flex-none p-4 md:p-0 pointer-events-auto w-full md:w-auto self-end md:self-center">
<div className={`
transition-all duration-500 ease-in-out transform
${isPanelVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-full pointer-events-none'}
${currentPanel.type === 'news' ? 'md:ml-auto' : ''}
`}>
{isNewsMode && currentPanel.type === 'news' && (
<NewsPanel
title={currentPanel.title}
content={currentPanel.content}
youtubeVideoId={currentPanel.youtubeVideoId}
isActive={true}
/>
)}
{!isNewsMode && currentPanel.type === 'stats' && (
<StatsPanel
title={currentPanel.title}
data={currentPanel.data}
youtubeVideoId={currentPanel.youtubeVideoId}
isActive={true}
/>
)}
</div>
</div>
)}
</div>
{/* Top Right - Time Display & Population */}
<div className="absolute top-8 right-8 text-right text-lg z-10 pointer-events-auto text-white" style={{ textShadow: '0px 0px 5px rgba(0, 0, 0, 0.5)' }}>
{/* เปลี่ยน text-base-content เป็น text-white เพื่อสีที่สว่างขึ้น */}
{/* เพิ่ม text-shadow เพื่อให้ข้อความมีมิติและอ่านง่ายขึ้นบนพื้นหลังมืด */}
<div className="font-bold text-3xl">{currentTime}</div>
<div>{population}</div>
</div>
{/* Pop-up Detail (ถ้ามี) */}
{selectedPoint && (
<PopUpDetail
point={selectedPoint}
onClose={closePopUp}
/>
)}
</div>
);
}
export default App;

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.0 KiB

View File

@ -0,0 +1,38 @@
// src/components/ConnectionLines.jsx
import React, { useMemo } from 'react';
import { Line } from '@react-three/drei';
import { Vector3, CatmullRomCurve3 } from 'three';
import { latLonToCartesian } from '../utils/threeHelpers';
export default function ConnectionLines({ connections }) {
const linePoints = useMemo(() => {
const points = [];
connections.forEach(conn => {
const start = latLonToCartesian(conn.startLat, conn.startLon, 1.001);
const end = latLonToCartesian(conn.endLat, conn.endLon, 1.001);
const startV = new Vector3(...start);
const endV = new Vector3(...end);
//
const midV = startV.clone().lerp(endV, 0.5); //
midV.normalize().multiplyScalar(1.2); //
const curve = new CatmullRomCurve3([startV, midV, endV]);
points.push(...curve.getPoints(30)); // 30
});
return points;
}, [connections]);
if (linePoints.length === 0) return null; // render
return (
<Line
points={linePoints}
color="#ff00ff" // Magenta
lineWidth={1}
dashed={false}
segments // line array
/>
);
}

View File

@ -0,0 +1,207 @@
// src/components/EarthBody.jsx
import React, { useRef, useEffect, useImperativeHandle } from 'react';
import { useLoader, useFrame } from '@react-three/fiber';
import { Sphere } from '@react-three/drei';
import { TextureLoader, LinearSRGBColorSpace, AdditiveBlending, Euler, Quaternion, Vector3, Color } from 'three';
import { gsap } from 'gsap';
import atmosphereVertexShader from "../shaders/atmosphereVertex.glsl";
import atmosphereFragmentShader from "../shaders/atmosphereFragment.glsl";
// Clouds AtmosphereGlow components ()
function Clouds() {
const cloudsRef = useRef();
const cloudMap = useLoader(TextureLoader, '/textures/05_earthcloudmaptrans.jpg');
cloudMap.colorSpace = LinearSRGBColorSpace;
useFrame(() => {
if (cloudsRef.current) {
cloudsRef.current.rotation.y += 0.0006;
}
});
return (
<Sphere ref={cloudsRef} args={[1.005, 64, 64]}>
<meshStandardMaterial
map={cloudMap}
transparent
opacity={0.5}
blending={AdditiveBlending}
/>
</Sphere>
);
}
function AtmosphereGlow() {
const material = useRef();
useEffect(() => {
if (material.current) {
material.current.uniforms.u_color.value = new Color(0x3e90ff);
}
}, []);
return (
<Sphere args={[1.15, 64, 64]}>
<shaderMaterial
ref={material}
vertexShader={atmosphereVertexShader}
fragmentShader={atmosphereFragmentShader}
blending={AdditiveBlending}
side={2}
transparent={true}
uniforms={{
u_color: { value: new Color(0x3e90ff) },
}}
/>
</Sphere>
);
}
const EarthBody = React.forwardRef(function EarthBody(props, ref) {
const { orbitControlsRef } = props;
const [dayMap, bumpMap, specularMap, lightsMap] = useLoader(TextureLoader, [
'/textures/earth_daymap_prime_meridian_centered.jpg',
'/textures/01_earthbump1k.jpg',
'/textures/02_earthspec1k.jpg',
'/textures/03_earthlights1k.jpg',
]);
dayMap.colorSpace = LinearSRGBColorSpace;
lightsMap.colorSpace = LinearSRGBColorSpace;
const internalMeshRef = useRef();
const groupRef = useRef(); // Group for static tilt
const rotationTween = useRef(null);
// This offset aligns the prime meridian (0 longitude) on the texture correctly.
const textureLonOffsetDegrees = 70;
// Static tilt for the entire globe group (e.g., Earth's axial tilt)
const tiltAngle = -23.4 * Math.PI / 180;
useEffect(() => {
if (groupRef.current) {
groupRef.current.rotation.x = tiltAngle;
}
}, []);
useImperativeHandle(ref, () => {
return {
get mesh() {
return internalMeshRef.current;
},
rotateToLatLon: (lat, lon, duration = 1.5) => {
if (!internalMeshRef.current) {
console.warn("EarthBody.rotateToLatLon: internalMeshRef.current is null. Cannot start animation.");
return;
}
if (!orbitControlsRef.current) {
console.warn("EarthBody.rotateToLatLon: orbitControlsRef.current is null. Cannot start animation.");
return;
}
if (rotationTween.current) {
rotationTween.current.kill();
console.log("Previous rotation tween killed.");
}
console.log(`--- rotateToLatLon Called ---`);
console.log(`Target Lat: ${lat}, Target Lon: ${lon}`);
const targetRotationX = lat * Math.PI / 180;
let targetRotationY = -(lon + textureLonOffsetDegrees) * Math.PI / 180;
let currentRotationY = internalMeshRef.current.rotation.y;
// Normalize currentRotationY to be within (-PI, PI]
currentRotationY = (currentRotationY + Math.PI) % (2 * Math.PI);
if (currentRotationY > Math.PI) currentRotationY -= (2 * Math.PI);
let diff = targetRotationY - currentRotationY;
if (diff > Math.PI) {
diff -= 2 * Math.PI;
} else if (diff <= -Math.PI) {
diff += 2 * Math.PI;
}
if (diff < 0) {
diff += 2 * Math.PI;
}
const finalTargetRotationY = currentRotationY + diff;
const animationTarget = {
x: internalMeshRef.current.rotation.x,
y: internalMeshRef.current.rotation.y,
z: internalMeshRef.current.rotation.z
};
console.log("Current Euler (rad):", internalMeshRef.current.rotation);
console.log("Target Lat (x-axis, rad):", targetRotationX);
console.log("Target Lon (y-axis, adjusted rad):", finalTargetRotationY);
console.log("Calculated Delta Y (rad):", diff);
if (orbitControlsRef.current) {
orbitControlsRef.current.enabled = false; // OrbitControls
console.log("OrbitControls temporarily disabled during animation.");
}
rotationTween.current = gsap.to(animationTarget, {
duration: duration,
ease: "power2.inOut",
x: targetRotationX,
y: finalTargetRotationY,
z: 0,
onStart: () => {
console.log("GSAP animation started.");
},
onUpdate: () => {
internalMeshRef.current.rotation.set(animationTarget.x, animationTarget.y, animationTarget.z);
},
onComplete: () => {
console.log("GSAP animation complete.");
if (orbitControlsRef.current) {
orbitControlsRef.current.enabled = true; // OrbitControls
orbitControlsRef.current.target.set(0, 0, 0); // target
orbitControlsRef.current.update();
console.log("OrbitControls re-enabled (zoom only).");
}
},
});
if (rotationTween.current) {
console.log("GSAP Tween instance created. Is active:", rotationTween.current.isActive());
} else {
console.error("GSAP Tween instance was NOT created.");
}
}
};
}, [internalMeshRef, textureLonOffsetDegrees, orbitControlsRef]);
useFrame(() => {
// Only apply continuous rotation if no active tween
if (internalMeshRef.current && (!rotationTween.current || !rotationTween.current.isActive())) {
internalMeshRef.current.rotation.y += 0.0005;
}
});
return (
<group ref={groupRef}>
<Sphere ref={internalMeshRef} args={[1, 64, 64]}>
<meshStandardMaterial
map={dayMap}
bumpMap={bumpMap}
bumpScale={0.02}
roughnessMap={specularMap}
metalness={0.0}
emissiveMap={lightsMap}
emissiveIntensity={0.5}
emissive={new Color(0xffffff)}
/>
</Sphere>
<Clouds />
<AtmosphereGlow />
</group>
);
});
export default EarthBody;

View File

@ -0,0 +1,83 @@
// src/components/GlobalNav.jsx
import React from 'react';
export default function GlobalNav({ activePanel, setActivePanel, isNewsMode, setIsNewsMode }) {
const newsItems = [
{ id: 'world_news', label: 'WORLD NEWS', number: '01' },
{ id: 'euro_news', label: 'EURO NEWS', number: '02' },
{ id: 'america_news', label: 'AMERICA NEWS', number: '03' },
{ id: 'china_japan_news', label: 'CHINA / JAPAN NEWS', number: '04' },
{ id: 'russia_news', label: 'RUSSIA NEWS', number: '05' },
];
const statsItems = [
{ id: 'market_review', label: 'MARKET REVIEW', number: '01' },
{ id: 'next_dollar_currencies', label: 'CURRENCIES', number: '02' },
{ id: 'global_population', label: 'POPULATION', number: '03' },
{ id: 'energy_consumption', label: 'ENERGY', number: '04' },
{ id: 'bix_micex_index', label: 'BIX INDEX / MICEX INDEX', number: '05' },
];
const currentItems = isNewsMode ? newsItems : statsItems;
const handlePanelClick = (id) => {
setActivePanel(id);
};
return (
<div className="flex flex-col items-start p-4 z-20 pointer-events-auto text-base-content md:w-64">
<div className="mb-6 ml-6">
<span className="text-sm font-semibold uppercase tracking-wider text-gray-200">
{isNewsMode ? 'WORLD NEWS' : 'WORLD STATISTICS'}
</span>
</div>
<ul className="menu p-2 w-full bg-base-100 rounded-box shadow-xl">
{currentItems.map((item) => (
<li
key={item.id}
className={`${activePanel === item.id ? 'active' : ''}`}
onClick={() => handlePanelClick(item.id)}
>
<a className="flex items-center gap-3">
<span className={`badge badge-ghost font-bold text-xs w-6 h-6 flex items-center justify-center rounded-full
${activePanel === item.id
? 'badge-primary text-white shadow-lg'
: 'bg-transparent text-base-content/70 border border-base-content/50 group-hover:bg-base-300'
}`}>
{item.number}
</span>
{/* แก้ไขตรงนี้: ลบ whitespace-nowrap ออก */}
<span className="text-sm font-medium uppercase tracking-wide break-words">
{item.label}
</span>
</a>
</li>
))}
</ul>
<div className="tabs tabs-boxed mt-8 shadow-lg bg-base-300">
<a
role="tab"
className={`tab transition-all duration-300 ${isNewsMode ? 'tab-active bg-primary text-primary-content' : 'text-base-content'}`}
onClick={() => {
setIsNewsMode(true);
setActivePanel('world_news');
}}
>
NEWS
</a>
<a
role="tab"
className={`tab transition-all duration-300 ${!isNewsMode ? 'tab-active bg-primary text-primary-content' : 'text-base-content'}`}
onClick={() => {
setIsNewsMode(false);
setActivePanel('market_review');
}}
>
STATISTICS
</a>
</div>
</div>
);
}

View File

@ -0,0 +1,221 @@
// src/components/GlobeCanvas.jsx
import React, { useRef, Suspense, useEffect, useCallback } from 'react';
import { Canvas, useLoader, useFrame, useThree } from '@react-three/fiber';
import { OrbitControls, Html, Sphere } from '@react-three/drei';
import { TextureLoader, LinearSRGBColorSpace, BackSide } from 'three';
import * as THREE from 'three'; // *** import THREE ***
import EarthBody from './EarthBody';
import PointsOnGlobe from './PointsOnGlobe';
import ConnectionLines from './ConnectionLines';
import Satellite from './Satellite'; // *** Import Satellite ***
// Helper function to convert 3D Cartesian coordinates to Lat/Lon
// Assumes globe radius of 1 ( EarthBody )
function cartesianToLatLon(vector) {
const radius = 1; // *** ***
const lat = Math.asin(vector.y / radius) * (180 / Math.PI);
let lon = Math.atan2(vector.z / radius, vector.x / radius) * (180 / Math.PI);
if (lon > 180) lon -= 360;
else if (lon < -180) lon += 360;
return { lat, lon };
}
// StarfieldBackground
function StarfieldBackground() {
const starMap = useLoader(TextureLoader, '/textures/stars/my_starfield.png');
starMap.colorSpace = LinearSRGBColorSpace;
return (
<Sphere args={[100, 64, 64]}>
<meshBasicMaterial
map={starMap}
side={BackSide}
/>
</Sphere>
);
}
// CustomOrbitControls ( Zoom Context)
function CustomOrbitControls({ controlsRef, onZoomChange, onExitLocalView, isLocalView }) {
const { camera, gl } = useThree();
const CAMERA_ZOOM_IN_THRESHOLD = 0.8;
const CAMERA_ZOOM_OUT_THRESHOLD = 1.5;
const currentViewMode = useRef(isLocalView);
useEffect(() => {
if (controlsRef.current) {
controlsRef.current.enableDamping = true;
controlsRef.current.dampingFactor = 0.05;
controlsRef.current.enableRotate = false;
controlsRef.current.enableZoom = true;
controlsRef.current.enablePan = false;
controlsRef.current.autoRotate = false;
controlsRef.current.minPolarAngle = Math.PI / 3;
controlsRef.current.maxPolarAngle = Math.PI - Math.PI / 3;
controlsRef.current.target.set(0, 0, 0);
controlsRef.current.update();
controlsRef.current.enabled = true;
console.log("CustomOrbitControls: Initialized. Controls enabled (zoom only).");
}
}, [controlsRef, camera, gl]);
useEffect(() => {
currentViewMode.current = isLocalView;
console.log("CustomOrbitControls: isLocalView prop changed to:", isLocalView, " (currentViewMode.current updated)");
if (controlsRef.current) {
controlsRef.current.target.set(0, 0, 0);
controlsRef.current.update();
console.log("CustomOrbitControls: Reset OrbitControls target to (0,0,0) on isLocalView change.");
}
}, [isLocalView, controlsRef]);
useFrame(() => {
if (controlsRef.current) {
const distance = camera.position.length();
// console.log(`Debug: Distance=${distance.toFixed(3)}, currentViewMode=${currentViewMode.current ? 'Local' : 'Global'}`); // Debugging
if (!currentViewMode.current) {
if (distance < CAMERA_ZOOM_IN_THRESHOLD) {
if (onZoomChange) {
// *** CALCULATE THE LAT/LON OF THE CENTER OF THE SCREEN ***
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(0, 0); // Center of the screen in normalized device coordinates (-1 to +1)
raycaster.setFromCamera(mouse, camera);
// Assuming your globe is a sphere at (0,0,0) with radius 1
// *** ***
const globeRadius = 1;
const globeSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), globeRadius);
const intersectionPoint = new THREE.Vector3();
const intersects = raycaster.ray.intersectSphere(globeSphere, intersectionPoint);
let lat = 0;
let lon = 0;
if (intersects) {
const coords = cartesianToLatLon(intersectionPoint);
lat = coords.lat;
lon = coords.lon;
console.log(`CustomOrbitControls: Intersected Globe at Lat: ${lat.toFixed(4)}, Lon: ${lon.toFixed(4)}`);
} else {
console.warn("CustomOrbitControls: Raycaster did not intersect the globe. Defaulting to 0,0 for Local View.");
}
console.log(`Action: Zoom IN detected (distance ${distance.toFixed(3)} < ${CAMERA_ZOOM_IN_THRESHOLD}). Switching to Local View.`);
currentViewMode.current = true;
onZoomChange({ lat, lon }); // *** lat, lon ***
}
}
} else {
if (distance > CAMERA_ZOOM_OUT_THRESHOLD) {
if (onExitLocalView) {
console.log(`Action: Zoom OUT detected (distance ${distance.toFixed(3)} > ${CAMERA_ZOOM_OUT_THRESHOLD}). Switching to Global View.`);
currentViewMode.current = false;
onExitLocalView();
}
}
}
}
});
return <OrbitControls ref={controlsRef} args={[camera, gl.domElement]} />;
}
// GlobeCanvas Component
export default function GlobeCanvas({ activePanel, isNewsMode, panelLocations, globePoints, globeConnections, onPointClick, onZoomToLocal, onExitLocalView, isLocalView }) {
const earthBodyRef = useRef();
const orbitControlsRef = useRef();
const getPanelLocation = useCallback((panelId) => {
return panelLocations[panelId] || { lat: 0, lon: 0 };
}, [panelLocations]);
useEffect(() => {
const attemptRotate = () => {
if (earthBodyRef.current && typeof earthBodyRef.current.rotateToLatLon === 'function') {
const { lat, lon } = getPanelLocation(activePanel);
if (lat !== undefined && lon !== undefined) {
console.log(`GlobeCanvas: Rotating to panel ${activePanel} at lat: ${lat}, lon: ${lon}`);
earthBodyRef.current.rotateToLatLon(lat, lon, 1.5);
} else {
console.warn(`GlobeCanvas: panelLocation for ${activePanel} is missing lat/lon. Not rotating.`);
}
return true;
}
return false;
};
const isReadyImmediately = attemptRotate();
if (!isReadyImmediately) {
const timeoutId = setTimeout(() => {
attemptRotate();
}, 500);
return () => clearTimeout(timeoutId);
}
}, [activePanel, getPanelLocation, isLocalView]);
const handleZoomDetected = useCallback(({ lat, lon }) => {
if (onZoomToLocal) {
console.log("GlobeCanvas - handleZoomDetected: Triggered. Sending Lat/Lon to App:", lat, lon);
onZoomToLocal({ lat, lon });
}
}, [onZoomToLocal]);
return (
<Canvas
camera={{ position: [0, 0, 2.5], fov: 70 }}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 0,
backgroundColor: 'black'
}}
>
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} intensity={1.5} />
<pointLight position={[-10, -10, -10]} intensity={0.8} color={0x5555ff} />
<Suspense fallback={<Html center>Loading Globe...</Html>}>
<EarthBody
ref={earthBodyRef}
orbitControlsRef={orbitControlsRef}
/>
<StarfieldBackground />
<PointsOnGlobe
locations={globePoints ? globePoints.filter(p => (isNewsMode && p.type === 'news') || (!isNewsMode && p.type === 'stats')) : []}
onPointClick={onPointClick}
/>
<ConnectionLines
connections={globeConnections}
/>
{/* *** เพิ่มดาวเทียมที่นี่ *** */}
<Satellite orbitRadius={1.1} orbitSpeed={0.05} orbitTiltDeg={25} />
<Satellite orbitRadius={1.2} orbitSpeed={0.03} orbitTiltDeg={60} />
<Satellite orbitRadius={1.3} orbitSpeed={0.07} orbitTiltDeg={-10} />
</Suspense>
<CustomOrbitControls
controlsRef={orbitControlsRef}
onZoomChange={handleZoomDetected}
onExitLocalView={onExitLocalView}
isLocalView={isLocalView}
/>
</Canvas>
);
}

View File

@ -0,0 +1,55 @@
// src/components/LocalMap.jsx
import React, { useEffect, useRef } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css'; // import CSS Leaflet
// Fix default icon issue with Leaflet and Webpack/Vite
import L from 'leaflet';
import icon from 'leaflet/dist/images/marker-icon.png';
import iconRetina from 'leaflet/dist/images/marker-icon-2x.png';
import shadow from 'leaflet/dist/images/marker-shadow.png';
let DefaultIcon = L.icon({
iconRetinaUrl: iconRetina,
iconUrl: icon,
shadowUrl: shadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
L.Marker.prototype.options.icon = DefaultIcon;
export default function LocalMap({ center, zoom, points, onPointClick }) {
const mapRef = useRef();
useEffect(() => {
if (mapRef.current) {
mapRef.current.setView(center, zoom);
}
}, [center, zoom]);
return (
<MapContainer
center={center}
zoom={zoom}
scrollWheelZoom={true} // scroll wheel
className="w-full h-full rounded-lg shadow-lg" // Tailwind classes
ref={mapRef}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{points.map(point => (
<Marker position={[point.lat, point.lon]} key={point.id} eventHandlers={{ click: () => onPointClick(point) }}>
<Popup>
<b>{point.name}</b>
<br />
{point.news && point.news[0]} {/* แสดงข่าวแรก */}
</Popup>
</Marker>
))}
</MapContainer>
);
}

View File

@ -0,0 +1,39 @@
// src/components/NewsPanel.jsx
import React from 'react';
// videoStreamUrl youtubeVideoId
export default function NewsPanel({ title, content, youtubeVideoId, isActive }) {
const panelClasses = `
card w-80 shadow-2xl z-10 p-6
transition-all duration-500 ease-in-out transform
${isActive ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-full pointer-events-none'}
${isActive ? 'glass bg-base-200 text-base-content' : ''}
`;
return (
<div className={panelClasses}>
<h2 className="card-title text-xl font-bold mb-2 uppercase">{title}</h2>
<p className="text-sm mb-4">{content}</p>
{/* ส่วนสำหรับ YouTube Video Embed */}
{youtubeVideoId && ( // youtubeVideoId
<div className="mt-4 aspect-video w-full"> {/* aspect-video ให้สัดส่วน 16:9 */}
<iframe
className="w-full h-full rounded-lg"
src={`https://www.youtube.com/embed/${youtubeVideoId}?autoplay=0&modestbranding=1&rel=0`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
)}
{/* หากไม่มี youtubeVideoId และต้องการแสดง placeholder เดิม ให้เพิ่มเงื่อนไขนี้ */}
{!youtubeVideoId && ( // YouTube ID placeholder
<div className="bg-base-300 h-24 flex items-center justify-center rounded-lg text-base-content text-sm border border-base-content/20">
[NO VIDEO STREAM AVAILABLE]
</div>
)}
</div>
);
}

View File

@ -0,0 +1,24 @@
// src/components/PointsOnGlobe.jsx
import React from 'react';
import { Sphere } from '@react-three/drei';
import { latLonToCartesian } from '../utils/threeHelpers';
export default function PointsOnGlobe({ locations, onPointClick }) {
return (
<>
{locations.map((loc) => {
const [x, y, z] = latLonToCartesian(loc.lat, loc.lon, 1.005); //
return (
<Sphere
key={loc.id}
args={[0.015, 16, 16]} //
position={[x, y, z]}
onClick={() => onPointClick(loc)} // parent
>
<meshBasicMaterial color="#00ffff" /> {/* สีฟ้า Cyan */}
</Sphere>
);
})}
</>
);
}

View File

@ -0,0 +1,50 @@
// src/components/PopUpDetail.jsx
import React, { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
export default function PopUpDetail({ point, onClose }) {
const modalRef = useRef();
useEffect(() => {
if (point) {
gsap.fromTo(modalRef.current,
{ opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1, duration: 0.5, ease: "back.out(1.7)" }
);
}
}, [point]);
if (!point) return null;
return (
// DaisyUI modal component
<div
ref={modalRef}
className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-75 z-50 pointer-events-auto"
onClick={onClose} //
>
<div
className="modal-box bg-base-300 text-base-content p-6 rounded-box shadow-2xl w-96"
onClick={(e) => e.stopPropagation()} // event propagation modal
>
<h3 className="font-bold text-lg text-primary mb-2">{point.name} Details</h3>
<p className="py-2 text-sm">Type: {point.type === 'news' ? 'News Region' : 'Statistics Region'}</p>
<div className="mt-4">
<h4 className="font-semibold text-accent mb-1">Recent Updates:</h4>
{point.news && point.news.length > 0 ? (
<ul className="list-disc list-inside text-xs">
{point.news.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
) : (
<p className="text-xs text-gray-500">No specific updates available.</p>
)}
</div>
<div className="modal-action mt-6">
<button className="btn btn-sm btn-primary" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
// src/components/Satellite.jsx
import React, { useRef, useState, useEffect } from 'react';
import { useFrame, useLoader } from '@react-three/fiber';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import * as THREE from 'three';
//
// ,
const calculateSatellitePosition = (time, orbitRadius, orbitSpeed, orbitTiltRad) => {
// ( Y)
const angle = (time * orbitSpeed) % (Math.PI * 2);
// XY
let x = Math.cos(angle) * orbitRadius;
let z = Math.sin(angle) * orbitRadius;
let y = 0; // y 0
const position = new THREE.Vector3(x, y, z);
// (Orbit Tilt)
// Quaternion
const quaternion = new THREE.Quaternion();
quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0).normalize(), orbitTiltRad); // X
position.applyQuaternion(quaternion);
return position;
};
export default function Satellite({ orbitRadius = 1.1, orbitSpeed = 0.05, orbitTiltDeg = 30 }) {
const satelliteRef = useRef();
const gltf = useLoader(GLTFLoader, '/models/satellite.glb'); //
const [satelliteScene, setSatelliteScene] = useState(null);
//
const orbitTiltRad = THREE.MathUtils.degToRad(orbitTiltDeg);
// Clone scene modify scene
useEffect(() => {
if (gltf.scene) {
setSatelliteScene(gltf.scene.clone());
}
}, [gltf]);
useFrame(({ clock }) => {
if (satelliteRef.current) {
const time = clock.getElapsedTime();
const newPosition = calculateSatellitePosition(time, orbitRadius, orbitSpeed, orbitTiltRad);
satelliteRef.current.position.copy(newPosition);
// ()
satelliteRef.current.lookAt(0, 0, 0); //
satelliteRef.current.rotation.y += Math.PI; //
}
});
if (!satelliteScene) {
return null; //
}
return (
<group ref={satelliteRef}>
<primitive object={satelliteScene} scale={0.01} /> {/* ปรับ scale ของดาวเทียมให้เหมาะสม */}
</group>
);
}

View File

@ -0,0 +1,126 @@
// src/components/StatsPanel.jsx
import React, { useState, useEffect } from 'react';
// Chart.js import:
// npm install react-chartjs-2 chart.js
// import { Line } from 'react-chartjs-2';
// import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
// ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
export default function StatsPanel({ title, data, isActive, youtubeVideoId }) {
const [fetchedData, setFetchedData] = useState(data); // state fetch
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const panelClasses = `
card w-80 shadow-2xl z-10 p-6
transition-all duration-500 ease-in-out transform
${isActive ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-full pointer-events-none'}
${isActive ? 'glass bg-base-200 text-base-content' : ''}
`;
// useEffect API
useEffect(() => {
const fetchDataFromAPI = async () => {
setLoading(true);
setError(null);
if (title === 'NEXT DOLLAR CURRENCIES') {
try {
// API ()
// base = USD, symbols = EUR, JPY, GBP, CNY, INR
const response = await fetch('https://api.exchangerate.host/latest?base=USD&symbols=EUR,JPY,GBP,CNY,INR');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.rates) {
const newCurrencies = [
{ label: 'USD/EUR', value: result.rates.EUR ? result.rates.EUR.toFixed(4) : 'N/A', trend: 'same' }, // trend API
{ label: 'USD/JPY', value: result.rates.JPY ? result.rates.JPY.toFixed(2) : 'N/A', trend: 'same' },
{ label: 'USD/GBP', value: result.rates.GBP ? result.rates.GBP.toFixed(4) : 'N/A', trend: 'same' },
{ label: 'USD/CNY', value: result.rates.CNY ? result.rates.CNY.toFixed(3) : 'N/A', trend: 'same' },
{ label: 'USD/INR', value: result.rates.INR ? result.rates.INR.toFixed(2) : 'N/A', trend: 'same' },
];
setFetchedData(newCurrencies);
} else {
setError("No rates data found in API response.");
setFetchedData(data); // Fallback to original mock data
}
} catch (e) {
console.error("Failed to fetch currency data:", e);
setError("Failed to load currency data. Please try again later.");
setFetchedData(data); // Fallback to original mock data
} finally {
setLoading(false);
}
}
// Title 'MARKET REVIEW' API
// mock data prop 'data' fetch API Key
else {
setFetchedData(data); // prop 'data'
setLoading(false);
}
};
if (isActive) { // Panel Active
fetchDataFromAPI();
// interval X
const interval = setInterval(fetchDataFromAPI, 60000); // 60
return () => clearInterval(interval);
}
}, [title, isActive, data]); // data dependency array effect data
return (
<div className={panelClasses}>
<h3 className="card-title text-lg font-bold mb-4 uppercase tracking-wide">{title}</h3>
{/* แสดงข้อมูลที่ fetch มา หรือข้อมูล mock */}
{loading ? (
<div className="flex justify-center items-center h-24">
<span className="loading loading-spinner text-primary"></span>
<p className="ml-2">Loading data...</p>
</div>
) : error ? (
<div className="text-error text-center h-24 flex items-center justify-center">
<p>{error}</p>
</div>
) : (
<>
{fetchedData && fetchedData.map && fetchedData.map((item, index) => (
<div key={index} className="flex justify-between items-center text-sm border-b border-base-content/20 pb-2 last:border-b-0 last:pb-0">
<span>{item.label}</span>
<div className="flex items-center space-x-2">
<span className="font-semibold text-white">{item.value}</span>
{item.trend === 'up' && <span className="text-success text-xs"></span>}
{item.trend === 'down' && <span className="text-error text-xs"></span>}
{item.trend === 'same' && <span className="text-neutral-content/50 text-xs"></span>}
</div>
</div>
))}
</>
)}
{/* ส่วนสำหรับ YouTube Video Embed */}
{youtubeVideoId && (
<div className="mt-4 aspect-video w-full"> {/* aspect-video ให้สัดส่วน 16:9 */}
<iframe
className="w-full h-full rounded-lg"
src={`https://www.youtube.com/embed/${youtubeVideoId}?autoplay=0&modestbranding=1&rel=0`}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
)}
{/* ส่วนสำหรับกราฟ (ถ้าต้องการ) - ตัวอย่างสำหรับ Chart.js */}
{/*
{title === "MARKET REVIEW" && fetchedData && fetchedData.chartData && ( // fetchedData
<div className="mt-4 h-24">
<Line data={fetchedData.chartData} options={defaultChartOptions} />
</div>
)}
*/}
</div>
);
}

4
src/index.css Normal file
View File

@ -0,0 +1,4 @@
@import "tailwindcss";
@plugin "daisyui"{
themes: light --default, dark --prefersdark, corporate;
}

11
src/main.jsx Normal file
View File

@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,7 @@
uniform vec3 u_color;
varying vec3 vNormal;
void main() {
float intensity = pow(0.7 - dot(vNormal, vec3(0, 0, 1.0)), 2.0); // ยิ่งมองตรงขอบ ยิ่งสว่าง
gl_FragColor = vec4(u_color, intensity);
}

View File

@ -0,0 +1,5 @@
varying vec3 vNormal;
void main() {
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

5
src/styles.css Normal file
View File

@ -0,0 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Noto%20Sans%20Thai:wght@400;500;600;700;800;900&display=swap");
body {
font-family: 'Noto Sans Thai', sans-serif;
}

21
src/utils/threeHelpers.js Normal file
View File

@ -0,0 +1,21 @@
// src/utils/threeHelpers.js
/**
* Converts latitude and longitude to 3D Cartesian coordinates on a sphere.
* @param {number} lat Latitude in degrees.
* @param {number} lon Longitude in degrees.
* @param {number} radius Radius of the sphere.
* @returns {[number, number, number]} An array [x, y, z] representing the 3D coordinates.
*/
export function latLonToCartesian(lat, lon, radius = 1) {
const phi = (lat * Math.PI) / 180; // Latitude to radians
const theta = ((lon - 90) * Math.PI) / 180; // Longitude to radians (adjust for orientation in Three.js)
const x = radius * Math.sin(phi) * Math.cos(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
return [x, y, z];
}
// ถ้ามีฟังก์ชันอื่นๆ ที่เกี่ยวข้องกับ Three.js Helpers ก็เพิ่มได้ที่นี่

10
vite.config.js Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite';
import glsl from 'vite-plugin-glsl';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), glsl()],
//assetsInclude: ['**/*.glsl'], // เพิ่มบรรทัดนี้
})