Initial commit of Worldview_Dashboard
21
.gitignore
vendored
Normal 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
@ -0,0 +1,106 @@
|
||||
# Worldview Dashboard
|
||||
|
||||
[](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
@ -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
@ -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
37
package.json
Normal 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
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/models/satellite.glb
Normal file
BIN
public/textures/00_earthmap1k.jpg
Normal file
|
After Width: | Height: | Size: 336 KiB |
BIN
public/textures/01_earthbump1k.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/textures/02_earthspec1k.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
public/textures/03_earthlights1k.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
public/textures/04_earthcloudmap.jpg
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
public/textures/05_earthcloudmaptrans.jpg
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
public/textures/earth_daymap_prime_meridian_centered.jpg
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/textures/stars/circle.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/textures/stars/my_starfield.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
1
public/vite.svg
Normal 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
@ -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
@ -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 |
38
src/components/ConnectionLines.jsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
207
src/components/EarthBody.jsx
Normal 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;
|
||||
83
src/components/GlobalNav.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
src/components/GlobeCanvas.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/LocalMap.jsx
Normal 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='© <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>
|
||||
);
|
||||
}
|
||||
39
src/components/NewsPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/PointsOnGlobe.jsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
src/components/PopUpDetail.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/Satellite.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/components/StatsPanel.jsx
Normal 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
@ -0,0 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui"{
|
||||
themes: light --default, dark --prefersdark, corporate;
|
||||
}
|
||||
11
src/main.jsx
Normal 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>,
|
||||
)
|
||||
7
src/shaders/atmosphereFragment.glsl
Normal 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);
|
||||
}
|
||||
5
src/shaders/atmosphereVertex.glsl
Normal 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
@ -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
@ -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
@ -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'], // เพิ่มบรรทัดนี้
|
||||
})
|
||||