初步完成了太阳系内的行星显示
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python -m venv:*)",
|
||||||
|
"Bash(./venv/bin/pip install:*)",
|
||||||
|
"Bash(./venv/bin/python:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(npm create:*)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(yarn install)",
|
||||||
|
"Bash(yarn add:*)",
|
||||||
|
"Bash(npx tailwindcss:*)",
|
||||||
|
"Bash(yarn dev)",
|
||||||
|
"Bash(yarn remove:*)",
|
||||||
|
"Bash(n:*)",
|
||||||
|
"Bash(sudo n:*)",
|
||||||
|
"Bash(node --version:*)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(yarn build)",
|
||||||
|
"Bash(source:*)",
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(uvicorn:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Cosmo 实施计划
|
||||||
|
|
||||||
|
## Stage 1: 后端基础框架和数据获取
|
||||||
|
**Goal**: 搭建 FastAPI 后端,实现从 NASA JPL Horizons 获取数据
|
||||||
|
**Success Criteria**:
|
||||||
|
- FastAPI 服务器成功启动
|
||||||
|
- 能够查询并返回探测器和行星的坐标数据
|
||||||
|
- API 端点返回正确的 JSON 格式数据
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- 手动测试 API 端点 `/api/celestial/positions`
|
||||||
|
- 验证返回的坐标数据格式正确
|
||||||
|
- 测试时间范围查询功能
|
||||||
|
|
||||||
|
**Status**: Complete
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [x] 创建后端项目结构
|
||||||
|
- [x] 配置 FastAPI 和依赖
|
||||||
|
- [x] 实现 Horizons 数据查询服务
|
||||||
|
- [x] 实现 API 路由
|
||||||
|
- [x] 测试数据获取功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 2: 前端基础框架和简单 3D 场景
|
||||||
|
**Goal**: 搭建 React + Three.js 前端,显示基础 3D 场景
|
||||||
|
**Success Criteria**:
|
||||||
|
- Vite + React + TypeScript 项目成功运行
|
||||||
|
- Three.js 场景正确渲染
|
||||||
|
- 显示太阳(中心)和几个彩色球体代表行星
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- 前端开发服务器启动无错误
|
||||||
|
- 浏览器中能看到 3D 场景
|
||||||
|
- 鼠标可以旋转和缩放视角
|
||||||
|
|
||||||
|
**Status**: Complete
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [x] 创建 React + Vite 项目
|
||||||
|
- [x] 配置 TypeScript 和 Tailwind
|
||||||
|
- [x] 安装 Three.js 相关依赖
|
||||||
|
- [x] 实现基础 3D 场景组件
|
||||||
|
- [x] 添加 OrbitControls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 3: 集成真实数据和轨道线
|
||||||
|
**Goal**: 前后端集成,使用真实 NASA 数据更新 3D 场景
|
||||||
|
**Success Criteria**:
|
||||||
|
- 前端成功调用后端 API
|
||||||
|
- 行星和探测器显示在正确的位置(基于真实数据)
|
||||||
|
- 显示探测器的轨道线
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- 验证行星位置与 NASA 数据一致
|
||||||
|
- 轨道线平滑连续
|
||||||
|
- 时间选择器改变时数据正确更新
|
||||||
|
|
||||||
|
**Status**: Complete
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [x] 实现前端 API 调用
|
||||||
|
- [x] 创建数据 hooks
|
||||||
|
- [x] 根据真实坐标渲染天体
|
||||||
|
- [ ] 实现轨道线绘制
|
||||||
|
- [ ] 添加时间选择器组件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 4: 进阶交互和信息面板
|
||||||
|
**Goal**: 实现点击聚焦和信息展示功能
|
||||||
|
**Success Criteria**:
|
||||||
|
- 点击天体后相机平滑飞向目标
|
||||||
|
- 显示天体详细信息面板
|
||||||
|
- 计算并显示探测器与最近行星的距离
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- 点击不同天体,相机正确聚焦
|
||||||
|
- 信息面板显示正确数据
|
||||||
|
- 距离计算准确
|
||||||
|
|
||||||
|
**Status**: Not Started
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] 实现 3D 物体点击检测(Raycaster)
|
||||||
|
- [ ] 实现相机平滑动画
|
||||||
|
- [ ] 创建信息面板组件
|
||||||
|
- [ ] 实现距离计算逻辑
|
||||||
|
- [ ] 添加天体列表侧边栏
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 5: 视觉优化和模型加载
|
||||||
|
**Goal**: 加载真实纹理和 3D 模型,优化视觉效果
|
||||||
|
**Success Criteria**:
|
||||||
|
- 行星显示真实纹理贴图
|
||||||
|
- 探测器使用 NASA 3D 模型
|
||||||
|
- 动态缩放功能正常工作
|
||||||
|
- 添加星空背景
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- 纹理正确加载并渲染
|
||||||
|
- 3D 模型显示正常
|
||||||
|
- 远近缩放时物体大小合理
|
||||||
|
- 整体视觉效果良好
|
||||||
|
|
||||||
|
**Status**: Not Started
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] 下载并配置行星纹理
|
||||||
|
- [ ] 下载并配置探测器 3D 模型
|
||||||
|
- [ ] 实现 GLTFLoader 加载模型
|
||||||
|
- [ ] 实现动态缩放逻辑
|
||||||
|
- [ ] 添加星空背景(Skybox)
|
||||||
|
- [ ] 性能优化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 进度跟踪
|
||||||
|
|
||||||
|
- **当前阶段**: Stage 3 (基本完成)
|
||||||
|
- **已完成**: 2/5 阶段 (Stage 1 & 2 完全完成,Stage 3 大部分完成)
|
||||||
|
- **预计完成时间**: 待定
|
||||||
|
|
||||||
|
**下一步**:
|
||||||
|
- 实现轨道线绘制
|
||||||
|
- 添加时间选择器组件
|
||||||
|
- 进阶交互功能 (Stage 4)
|
||||||
|
- 视觉优化 (Stage 5)
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 每个阶段完成后必须确保代码能编译和运行
|
||||||
|
2. 遵循增量开发,不要跳跃阶段
|
||||||
|
3. 遇到问题最多尝试3次,然后调整方案
|
||||||
|
4. 所有提交必须通过基本测试
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
# Cosmo - 深空探测器可视化系统
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
基于 NASA JPL Horizons 数据的深空探测器 3D 可视化系统,展示旅行者号、火星探测器等深空探测器在太阳系中的实时位置和历史轨迹。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **框架**: Python 3.11+ with FastAPI
|
||||||
|
- **核心库**:
|
||||||
|
- `fastapi` - Web 框架
|
||||||
|
- `astroquery` - NASA JPL Horizons 数据查询
|
||||||
|
- `astropy` - 天文计算和时间处理
|
||||||
|
- `uvicorn` - ASGI 服务器
|
||||||
|
- `pydantic` - 数据验证
|
||||||
|
- `python-dotenv` - 环境变量管理
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **框架**: React 18 with TypeScript
|
||||||
|
- **构建工具**: Vite
|
||||||
|
- **3D 渲染**:
|
||||||
|
- `three` - 核心 3D 引擎
|
||||||
|
- `@react-three/fiber` - React Three.js 集成
|
||||||
|
- `@react-three/drei` - Three.js 辅助组件
|
||||||
|
- **UI 库**:
|
||||||
|
- `tailwindcss` - 样式框架
|
||||||
|
- `lucide-react` - 图标库
|
||||||
|
- **状态管理**: React Hooks (useState, useContext)
|
||||||
|
- **HTTP 客户端**: `axios`
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 1. 数据获取
|
||||||
|
- 从 NASA JPL Horizons 获取探测器和行星的日心坐标 (x, y, z)
|
||||||
|
- 支持时间序列查询(用户指定起止时间)
|
||||||
|
- 数据缓存策略:每3天更新一次
|
||||||
|
- 单位:AU (天文单位)
|
||||||
|
|
||||||
|
### 2. 支持的天体
|
||||||
|
|
||||||
|
#### 探测器
|
||||||
|
| 名称 | ID | 备注 |
|
||||||
|
|------|----|----|
|
||||||
|
| Voyager 1 | -31 | 最远的人造物体 |
|
||||||
|
| Voyager 2 | -32 | 访问过天王星海王星 |
|
||||||
|
| New Horizons | -98 | 冥王星探测器 |
|
||||||
|
| Parker Solar Probe | -96 | 最接近太阳 |
|
||||||
|
| Juno | -61 | 木星探测器 |
|
||||||
|
| Cassini | -82 | 土星探测器(历史数据) |
|
||||||
|
| Perseverance | -168 | 火星车 |
|
||||||
|
|
||||||
|
#### 行星
|
||||||
|
| 名称 | ID |
|
||||||
|
|------|----|
|
||||||
|
| Sun | 10 |
|
||||||
|
| Mercury | 199 |
|
||||||
|
| Venus | 299 |
|
||||||
|
| Earth | 399 |
|
||||||
|
| Mars | 499 |
|
||||||
|
| Jupiter | 599 |
|
||||||
|
| Saturn | 699 |
|
||||||
|
| Uranus | 799 |
|
||||||
|
| Neptune | 899 |
|
||||||
|
|
||||||
|
### 3. 3D 可视化功能
|
||||||
|
|
||||||
|
#### 基础功能
|
||||||
|
- 太阳系 3D 场景渲染(日心坐标系)
|
||||||
|
- 行星纹理贴图(diffuse, normal, specular maps)
|
||||||
|
- 探测器 3D 模型加载(GLB 格式)
|
||||||
|
- 轨道线绘制(时间序列连线)
|
||||||
|
- 星空背景(Skybox)
|
||||||
|
|
||||||
|
#### 交互功能(进阶)
|
||||||
|
- **OrbitControls**: 旋转、缩放、平移视角
|
||||||
|
- **点击聚焦**: 点击探测器/行星,相机平滑飞向目标
|
||||||
|
- **信息面板**: 显示选中物体的详细信息
|
||||||
|
- 名称、距离太阳距离、速度
|
||||||
|
- 最近的行星及距离
|
||||||
|
- **时间选择器**: 用户选择起止时间查看历史位置
|
||||||
|
- **动态缩放**: 解决尺度问题(远看时放大物体)
|
||||||
|
|
||||||
|
### 4. 尺度处理策略
|
||||||
|
|
||||||
|
**问题**: 太阳系空间巨大,真实比例下行星会小到看不见
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 坐标系统使用真实 AU 单位(计算准确)
|
||||||
|
- 渲染时应用动态缩放:
|
||||||
|
- 远景:行星和探测器放大 1000-10000 倍
|
||||||
|
- 近景:逐渐恢复真实比例
|
||||||
|
- 探测器在远景时显示为发光图标,近景时显示 3D 模型
|
||||||
|
|
||||||
|
## 外部资源需求
|
||||||
|
|
||||||
|
### 3D 模型(需下载)
|
||||||
|
- **来源**: https://nasa3d.arc.nasa.gov/models
|
||||||
|
- **格式**: GLB/GLTF
|
||||||
|
- **存放位置**: `frontend/public/models/`
|
||||||
|
- **需要的模型**:
|
||||||
|
- Voyager 1 & 2
|
||||||
|
- New Horizons
|
||||||
|
- Parker Solar Probe
|
||||||
|
- Juno
|
||||||
|
- Cassini
|
||||||
|
- Perseverance
|
||||||
|
|
||||||
|
### 行星纹理(需下载)
|
||||||
|
- **来源**: https://www.solarsystemscope.com/textures/
|
||||||
|
- **格式**: JPG/PNG (2K 或 4K)
|
||||||
|
- **存放位置**: `frontend/public/textures/`
|
||||||
|
- **每个行星需要**:
|
||||||
|
- `{planet}_diffuse.jpg` - 颜色贴图
|
||||||
|
- `{planet}_normal.jpg` - 法线贴图(可选)
|
||||||
|
- `earth_specular.jpg` - 地球高光贴图(仅地球)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
cosmo/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── main.py # FastAPI 入口
|
||||||
|
│ │ ├── config.py # 配置
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ └── celestial.py # 数据模型
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── horizons.py # JPL Horizons 查询
|
||||||
|
│ │ │ └── cache.py # 数据缓存
|
||||||
|
│ │ └── api/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ └── routes.py # API 路由
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── .env.example
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.tsx
|
||||||
|
│ │ ├── main.tsx
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── Scene.tsx # 主场景
|
||||||
|
│ │ │ ├── CelestialBody.tsx
|
||||||
|
│ │ │ ├── Probe.tsx
|
||||||
|
│ │ │ ├── OrbitLine.tsx
|
||||||
|
│ │ │ ├── InfoPanel.tsx
|
||||||
|
│ │ │ └── TimeSelector.tsx
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ └── useSpaceData.ts
|
||||||
|
│ │ ├── types/
|
||||||
|
│ │ │ └── index.ts
|
||||||
|
│ │ └── utils/
|
||||||
|
│ │ └── api.ts
|
||||||
|
│ ├── public/
|
||||||
|
│ │ ├── models/ # 探测器 3D 模型
|
||||||
|
│ │ └── textures/ # 行星纹理
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ ├── vite.config.ts
|
||||||
|
│ └── tailwind.config.js
|
||||||
|
├── PROJECT.md # 本文件
|
||||||
|
├── IMPLEMENTATION_PLAN.md # 实施计划
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 设计
|
||||||
|
|
||||||
|
### 端点
|
||||||
|
|
||||||
|
#### `GET /api/celestial/positions`
|
||||||
|
获取指定时间的天体位置
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
- `start_time`: ISO 8601 格式(可选,默认为当前时间)
|
||||||
|
- `end_time`: ISO 8601 格式(可选)
|
||||||
|
- `step`: 时间步长,如 "1d"(可选,默认 "1d")
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2025-11-26T00:00:00Z",
|
||||||
|
"bodies": [
|
||||||
|
{
|
||||||
|
"id": "-31",
|
||||||
|
"name": "Voyager 1",
|
||||||
|
"type": "probe",
|
||||||
|
"positions": [
|
||||||
|
{
|
||||||
|
"time": "2025-11-26T00:00:00Z",
|
||||||
|
"x": 160.5,
|
||||||
|
"y": 20.3,
|
||||||
|
"z": -15.2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/celestial/info/{body_id}`
|
||||||
|
获取天体详细信息
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "-31",
|
||||||
|
"name": "Voyager 1",
|
||||||
|
"type": "probe",
|
||||||
|
"description": "离地球最远的人造物体",
|
||||||
|
"launch_date": "1977-09-05",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发阶段
|
||||||
|
|
||||||
|
详见 `IMPLEMENTATION_PLAN.md`
|
||||||
|
|
||||||
|
## 数据更新策略
|
||||||
|
|
||||||
|
- 深空探测器移动缓慢,数据每3天更新一次
|
||||||
|
- 后端实现缓存机制,避免频繁请求 NASA API
|
||||||
|
- 缓存存储在内存中(简单实现)或 Redis(生产环境)
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
# Cosmo - 深空探测器可视化系统 🚀
|
||||||
|
|
||||||
|
基于 NASA JPL Horizons 数据的深空探测器 3D 可视化系统。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- Node.js 20+
|
||||||
|
- Yarn
|
||||||
|
|
||||||
|
### 1. 启动后端 API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# 创建虚拟环境并安装依赖
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 启动服务器
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
后端将运行在 http://localhost:8000
|
||||||
|
|
||||||
|
- API 文档: http://localhost:8000/docs
|
||||||
|
- 健康检查: http://localhost:8000/health
|
||||||
|
|
||||||
|
### 2. 启动前端应用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
yarn install --ignore-engines
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端将运行在 http://localhost:5173
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
cosmo/
|
||||||
|
├── backend/ # Python FastAPI 后端
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── main.py # FastAPI 入口
|
||||||
|
│ │ ├── api/ # API 路由
|
||||||
|
│ │ ├── models/ # 数据模型
|
||||||
|
│ │ └── services/ # 业务逻辑
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/ # React + Three.js 前端
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # React 组件
|
||||||
|
│ │ ├── hooks/ # 自定义 hooks
|
||||||
|
│ │ ├── types/ # TypeScript 类型
|
||||||
|
│ │ └── utils/ # 工具函数
|
||||||
|
│ └── package.json
|
||||||
|
├── PROJECT.md # 详细技术方案
|
||||||
|
├── IMPLEMENTATION_PLAN.md # 实施计划
|
||||||
|
└── README.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 已实现 ✅
|
||||||
|
|
||||||
|
- **后端 API**
|
||||||
|
- 从 NASA JPL Horizons 获取实时天体数据
|
||||||
|
- 支持时间范围查询
|
||||||
|
- 数据缓存机制(每3天更新)
|
||||||
|
- RESTful API 设计
|
||||||
|
|
||||||
|
- **前端 3D 可视化**
|
||||||
|
- React + Three.js 3D 场景
|
||||||
|
- 实时显示太阳系天体位置
|
||||||
|
- 交互式相机控制(旋转、平移、缩放)
|
||||||
|
- 星空背景
|
||||||
|
- 响应式设计
|
||||||
|
|
||||||
|
- **支持的天体**
|
||||||
|
- 探测器: Voyager 1 & 2, New Horizons, Parker Solar Probe, Juno, Cassini, Perseverance
|
||||||
|
- 行星: 太阳系八大行星
|
||||||
|
|
||||||
|
### 规划中 🚧
|
||||||
|
|
||||||
|
- 轨道线绘制
|
||||||
|
- 时间选择器
|
||||||
|
- 点击聚焦功能
|
||||||
|
- 信息面板
|
||||||
|
- 真实纹理贴图
|
||||||
|
- 3D 探测器模型
|
||||||
|
- 动态缩放优化
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- FastAPI - 现代 Python Web 框架
|
||||||
|
- astroquery - NASA JPL Horizons 数据查询
|
||||||
|
- astropy - 天文计算
|
||||||
|
- Pydantic - 数据验证
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- React 18 + TypeScript
|
||||||
|
- Vite - 快速构建工具
|
||||||
|
- Three.js - 3D 渲染
|
||||||
|
- @react-three/fiber - React Three.js 集成
|
||||||
|
- @react-three/drei - Three.js 辅助工具
|
||||||
|
- Tailwind CSS - 样式框架
|
||||||
|
- Axios - HTTP 客户端
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
### 获取天体位置
|
||||||
|
```
|
||||||
|
GET /api/celestial/positions
|
||||||
|
```
|
||||||
|
|
||||||
|
查询参数:
|
||||||
|
- `start_time`: 起始时间 (ISO 8601)
|
||||||
|
- `end_time`: 结束时间 (ISO 8601)
|
||||||
|
- `step`: 时间步长 (如 "1d", "12h")
|
||||||
|
|
||||||
|
### 获取天体信息
|
||||||
|
```
|
||||||
|
GET /api/celestial/info/{body_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 列出所有天体
|
||||||
|
```
|
||||||
|
GET /api/celestial/list
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 控制方式
|
||||||
|
|
||||||
|
- **左键拖动**: 旋转视角
|
||||||
|
- **右键拖动**: 平移视角
|
||||||
|
- **滚轮**: 缩放
|
||||||
|
|
||||||
|
### 坐标系统
|
||||||
|
|
||||||
|
使用日心坐标系(Heliocentric),以太阳为原点,单位为 AU (天文单位)。
|
||||||
|
|
||||||
|
## 外部资源需求
|
||||||
|
|
||||||
|
### 3D 模型(未来)
|
||||||
|
- 来源: https://nasa3d.arc.nasa.gov/models
|
||||||
|
- 格式: GLB/GLTF
|
||||||
|
- 存放: `frontend/public/models/`
|
||||||
|
|
||||||
|
### 行星纹理(未来)
|
||||||
|
- 来源: https://www.solarsystemscope.com/textures/
|
||||||
|
- 格式: JPG/PNG
|
||||||
|
- 存放: `frontend/public/textures/`
|
||||||
|
|
||||||
|
## 开发进度
|
||||||
|
|
||||||
|
详见 [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md)
|
||||||
|
|
||||||
|
- ✅ Stage 1: 后端基础框架和数据获取
|
||||||
|
- ✅ Stage 2: 前端基础框架和简单 3D 场景
|
||||||
|
- ✅ Stage 3: 集成真实数据(部分完成)
|
||||||
|
- 🚧 Stage 4: 进阶交互和信息面板
|
||||||
|
- 🚧 Stage 5: 视觉优化和模型加载
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 后端无法启动
|
||||||
|
- 确保 Python 3.11+ 已安装
|
||||||
|
- 检查虚拟环境是否激活
|
||||||
|
- 尝试升级 pip: `pip install --upgrade pip`
|
||||||
|
|
||||||
|
### 前端依赖安装失败
|
||||||
|
- 使用 `yarn install --ignore-engines`
|
||||||
|
- 确保 Node.js 版本 >= 20
|
||||||
|
|
||||||
|
### 数据加载缓慢
|
||||||
|
- NASA JPL Horizons API 首次查询较慢(10-30秒)
|
||||||
|
- 后续请求会使用缓存,速度更快
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- NASA JPL Horizons System
|
||||||
|
- React Three Fiber 社区
|
||||||
|
- Astroquery 项目
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
更多技术细节请查看 [PROJECT.md](./PROJECT.md)
|
||||||
|
|
@ -0,0 +1,371 @@
|
||||||
|
## 项目概述
|
||||||
|
专注于**深空探测器**(如旅行者号、火星探测器等),那么整个系统的实现逻辑会变得更加清晰和纯粹。你不再需要处理近地轨道的 TLE 数据,而是完全进入了**天体力学**的领域。
|
||||||
|
|
||||||
|
实现这个系统的核心只有一条路:**NASA JPL Horizons 系统**。
|
||||||
|
|
||||||
|
这是全人类最权威的太阳系天体位置数据库。以下是针对深空探测器系统的具体实现方案:
|
||||||
|
|
||||||
|
### 一、 核心数据源:NASA JPL Horizons
|
||||||
|
|
||||||
|
对于深空探测器,你不能用 GPS 坐标,甚至不能单纯用经纬度。你需要的是在**太阳系中的三维坐标**。
|
||||||
|
|
||||||
|
* **数据提供方:** NASA 喷气推进实验室 (JPL)。
|
||||||
|
* **覆盖范围:** 所有的行星、卫星、以及几乎所有人类发射的深空探测器(Voyager, Juno, New Horizons 等)。
|
||||||
|
* **唯一标识 (ID):** 每个探测器都有一个唯一的 ID。
|
||||||
|
* 旅行者 1 号 (Voyager 1): `-31`
|
||||||
|
* 旅行者 2 号 (Voyager 2): `-32`
|
||||||
|
* 新视野号 (New Horizons): `-98`
|
||||||
|
* 帕克太阳探测器 (Parker Solar Probe): `-96`
|
||||||
|
* *注:人造探测器的 ID 通常是负数。*
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### 二、 获取数据的方式 (推荐技术方案)
|
||||||
|
|
||||||
|
为了获取这些数据,你不需要去解析复杂的文本文件,最简单、最现代的方式是使用 **Python** 的 **`astroquery`** 库。它是一个专门用来查询天文数据库的工具,内置了对 JPL Horizons 的支持。
|
||||||
|
|
||||||
|
#### 1\. 安装工具
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install astroquery
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2\. 代码实现逻辑
|
||||||
|
|
||||||
|
你需要向系统询问:“在**这个时间**,相对于**太阳**,**旅行者1号**在哪里?”
|
||||||
|
|
||||||
|
以下是一个完整的 Python 脚本示例,它会获取旅行者 1 号和地球的坐标,以便你计算它们之间的距离或画图:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from astroquery.jplhorizons import Horizons
|
||||||
|
from astropy.time import Time
|
||||||
|
|
||||||
|
# 1. 设定查询参数
|
||||||
|
# id: 目标天体 ID (Voyager 1 = -31)
|
||||||
|
# location: 坐标原点 (@sun 表示以太阳为中心,@0 表示以太阳系质心为中心)
|
||||||
|
# epochs: 时间点 (当前时间)
|
||||||
|
obj = Horizons(id='-31', location='@sun', epochs=Time.now().jd)
|
||||||
|
|
||||||
|
# 2. 获取向量数据 (Vectors)
|
||||||
|
# 这一步会向 NASA 服务器发送请求
|
||||||
|
vectors = obj.vectors()
|
||||||
|
|
||||||
|
# 3. 提取坐标 (x, y, z)
|
||||||
|
# 默认单位是 AU (天文单位,1 AU ≈ 1.5亿公里)
|
||||||
|
x = vectors['x'][0]
|
||||||
|
y = vectors['y'][0]
|
||||||
|
z = vectors['z'][0]
|
||||||
|
|
||||||
|
print(f"旅行者1号 (Voyager 1) 相对于太阳的坐标 (AU):")
|
||||||
|
print(f"X: {x}\nY: {y}\nZ: {z}")
|
||||||
|
|
||||||
|
# --- 同时获取地球的位置,用于画出相对位置 ---
|
||||||
|
earth = Horizons(id='399', location='@sun', epochs=Time.now().jd).vectors()
|
||||||
|
print(f"\n地球 (Earth) 坐标 (AU):")
|
||||||
|
print(f"X: {earth['x'][0]}, Y: {earth['y'][0]}, Z: {earth['z'][0]}")
|
||||||
|
```
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### 三、 关键技术点解析
|
||||||
|
|
||||||
|
在开发这个系统时,有三个关键概念你必须处理好,才能正确显示“探测器在比着重的位置”以及“旁边的星球”。
|
||||||
|
|
||||||
|
#### 1\. 坐标系的选择:日心坐标 (Heliocentric)
|
||||||
|
|
||||||
|
* **近地卫星**用的是“地心坐标”(以地球为原点)。
|
||||||
|
* **深空探测器**必须用**日心坐标**(以太阳为原点)。
|
||||||
|
* 在查询数据时,务必指定 `location='@sun'`。这样返回的 `(0,0,0)` 就是太阳,所有行星和探测器都围绕它分布。
|
||||||
|
|
||||||
|
#### 2\. 单位的量级:天文单位 (AU)
|
||||||
|
|
||||||
|
* 深空的空间太大了。如果你用“米”或“公里”做单位,数字会大到让 JavaScript 崩溃或精度丢失。
|
||||||
|
* **解决方案:** 使用 **AU (Astronomical Unit)**。
|
||||||
|
* 地球到太阳的距离 ≈ 1.0 AU。
|
||||||
|
* 旅行者 1 号目前距离太阳 ≈ 160+ AU。
|
||||||
|
* 使用 AU 作为你 3D 场景的基础单位,显示时再换算成公里给用户看。
|
||||||
|
|
||||||
|
#### 3\. 如何确定“旁边的星球”
|
||||||
|
|
||||||
|
因为所有坐标都是统一在“日心坐标系”下的,判断“旁边”非常简单:计算欧几里得距离。
|
||||||
|
$$Distance = \sqrt{(x_1-x_2)^2 + (y_1-y_2)^2 + (z_1-z_2)^2}$$
|
||||||
|
|
||||||
|
* 比如,你要显示“朱诺号 (Juno)”旁边的星球。
|
||||||
|
* 你获取 Juno 的坐标 $(x_j, y_j, z_j)$。
|
||||||
|
* 你获取木星 (Jupiter) 的坐标 $(x_p, y_p, z_p)$。
|
||||||
|
* 一算距离,你会发现它们非常近,而它离地球非常远。
|
||||||
|
|
||||||
|
### 四、 总结系统架构建议
|
||||||
|
|
||||||
|
如果你想做一个网页端展示系统:
|
||||||
|
|
||||||
|
1. **后端 (Python API):**
|
||||||
|
|
||||||
|
* 使用 `astroquery`。
|
||||||
|
* 建立一个 ID 列表(包含八大行星 + 知名探测器)。
|
||||||
|
* 每隔一段时间(比如每天或用户请求时)去 NASA JPL 拉取一次最新的坐标数据(因为深空探测器飞得很慢,不需要每秒更新)。
|
||||||
|
* 将这些 `(x, y, z)` 坐标打包成 JSON 发给前端。
|
||||||
|
|
||||||
|
2. **前端 (Visualization):**
|
||||||
|
|
||||||
|
* 建立一个 3D 场景,原点 `(0,0,0)` 放一个发光的球(太阳)。
|
||||||
|
* 根据后端返回的 AU 坐标放置行星和探测器。
|
||||||
|
* **关键功能:** 添加“轨道线”。为了让用户看懂探测器的轨迹,你不仅要获取“当前”位置,最好获取“过去一年”到“未来一年”的一系列点,连成线,这样用户就能直观地看到它是怎么飞掠木星然后飞向深空的。
|
||||||
|
|
||||||
|
这是一个非常棒的进阶问题!要在一个网页上“形象且准确”地展示深空探测器和行星,你不仅要解决**数据**问题,还要解决\*\*3D 图形学中的尺度(Scale)\*\*问题。因为宇宙太空中,“大”和“远”的跨度大到人类难以直观理解。
|
||||||
|
|
||||||
|
以下是实现这一目标的详细方案,包括数据细节、3D 模型资源和可视化技巧:
|
||||||
|
|
||||||
|
### 一、 数据篇:关键探测器 ID 与 轨道线绘制
|
||||||
|
|
||||||
|
首先,你需要向 JPL Horizons 系统请求正确的目标 ID,并获取一段**时间序列**的数据来画出轨道线。
|
||||||
|
|
||||||
|
#### 1\. 常用深空探测器 ID 列表 (JPL Horizons)
|
||||||
|
|
||||||
|
这些是人类历史上最重要的深空探测器,建议收入你的系统:
|
||||||
|
|
||||||
|
| 探测器名称 | 英文名 | ID (JPL) | 备注 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **旅行者 1 号** | Voyager 1 | `-31` | 离地球最远的人造物体,已进入星际空间 |
|
||||||
|
| **旅行者 2 号** | Voyager 2 | `-32` | 唯一造访过天王星和海王星的探测器 |
|
||||||
|
| **新视野号** | New Horizons | `-98` | 飞掠冥王星,正处于柯伊伯带 |
|
||||||
|
| **帕克太阳探测器** | Parker Solar Probe | `-96` | 正在“触摸”太阳,速度最快 |
|
||||||
|
| **朱诺号** | Juno | `-61` | 正在木星轨道运行 |
|
||||||
|
| **卡西尼号** | Cassini | `-82` | 土星探测器(已撞击销毁,需查询历史时间) |
|
||||||
|
| **毅力号** | Perseverance | `-168` | 火星车(位置与火星几乎重叠,但在前往火星途中可查) |
|
||||||
|
|
||||||
|
#### 2\. 如何绘制“轨道线”
|
||||||
|
|
||||||
|
只显示一个点是不够的,你需要画出它“从哪里来,到哪里去”。
|
||||||
|
|
||||||
|
* **后端逻辑:** 当你查询 API 时,不要只查询 `Time.now()`。
|
||||||
|
* **查询策略:** 查询一个时间段。例如,查询从 `2020-01-01` 到 `2025-01-01`,步长为 `1天`。
|
||||||
|
* **数据结构:** 你会得到一个包含 1800 个 $(x, y, z)$ 坐标的数组。
|
||||||
|
* **前端绘制:** 将这些点连接成一条平滑的线(在 Three.js 中使用 `LineLoop` 或 `CatmullRomCurve3`),用户就能看到探测器优美的弧形轨道。
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### 二、 视觉篇:如何显示外形 (3D 模型与纹理)
|
||||||
|
|
||||||
|
要在网页上显示逼真的外形,你需要使用 **WebGL** 技术。目前业界标准是 **Three.js**。
|
||||||
|
|
||||||
|
#### 1\. 获取高精度的探测器模型 (3D Models)
|
||||||
|
|
||||||
|
你不需要自己建模!NASA 官方免费提供了极高质量的 3D 模型,格式通常是 `.glb` 或 `.gltf`(这是 3D 网页开发的 JPG,体积小、加载快)。
|
||||||
|
|
||||||
|
* **NASA 3D Resources:** 这是你的宝库。
|
||||||
|
* *网址:* `https://nasa3d.arc.nasa.gov/models`
|
||||||
|
* 你可以下载到 Voyager, Cassini, Hubble 等所有知名探测器的官方模型。
|
||||||
|
* **加载方法:** 使用 Three.js 的 `GLTFLoader`。
|
||||||
|
```javascript
|
||||||
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
|
||||||
|
const loader = new GLTFLoader();
|
||||||
|
loader.load( 'path/to/voyager.glb', function ( gltf ) {
|
||||||
|
const voyagerModel = gltf.scene;
|
||||||
|
scene.add( voyagerModel );
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2\. 获取行星的逼真纹理 (Textures)
|
||||||
|
|
||||||
|
行星是一个球体(SphereGeometry),你需要给它贴上高清的“皮肤”。
|
||||||
|
|
||||||
|
* **资源来源:** **Solar System Scope** 或 **NASA Scientific Visualization Studio**。
|
||||||
|
* **你需要三种贴图来达到“准确且形象”:**
|
||||||
|
1. **Diffuse Map (漫反射贴图):** 行星原本的颜色(如地球的蓝白、火星的红色)。
|
||||||
|
2. **Normal Map / Bump Map (法线/凹凸贴图):** 让山脉和陨石坑看起来有立体感,而不是光滑的皮球。
|
||||||
|
3. **Specular Map (高光贴图):** 只有海洋反光,陆地不反光(这对地球特别重要)。
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### 三、 核心难点:大小与距离的冲突 (The Scale Problem)
|
||||||
|
|
||||||
|
这是你在这个项目中最需要处理的**交互设计难点**。
|
||||||
|
|
||||||
|
**现实情况是:** 太阳系极其空旷。如果你按真实比例(1:1)显示:
|
||||||
|
|
||||||
|
* 如果屏幕上可以看到地球和火星的距离,那么地球本身小到连一个像素都不到(看不见)。
|
||||||
|
* 如果你把地球放大到能看见,那么火星在几公里以外的屏幕外。
|
||||||
|
|
||||||
|
**解决方案:动态尺度缩放 (Dynamic Scaling / Billboard Mode)**
|
||||||
|
|
||||||
|
你不能始终使用真实大小,你需要欺骗眼睛:
|
||||||
|
|
||||||
|
1. **真实模式 (Real Scale):** 用于计算物理位置和轨道。这是后台运行的数学逻辑。
|
||||||
|
2. **展示模式 (Iconic Scale):** 用于渲染。
|
||||||
|
* **远景视角时:** 将所有行星和探测器放大 **1000倍 到 10000倍**。这样用户在看整个太阳系时,能看到一个个清晰的小球或图标。
|
||||||
|
* **近景视角时(当摄像机靠近物体):** 逐渐将放大倍数缩小回 **1倍**。
|
||||||
|
* **具体实现:** 在每一帧渲染循环(Render Loop)中,根据摄像机到物体的距离 $D$,动态计算物体的缩放系数 $S$。
|
||||||
|
$$S = \max(1, \frac{D}{k})$$
|
||||||
|
*(其中 $k$ 是一个常数因子)*
|
||||||
|
|
||||||
|
**关于探测器的特殊处理:**
|
||||||
|
探测器比行星更小(几米 vs 几千公里)。在宏观视角下,绝对不能按比例渲染探测器模型,否则永远看不见。
|
||||||
|
|
||||||
|
* **策略:** 在远景时,不要渲染 3D 模型,而是渲染一个**发光的图标(Sprite)或者文字标签**。
|
||||||
|
* **交互:** 只有当用户点击“旅行者1号”标签,摄像机自动飞过去并拉近距离后,才淡出图标,加载并显示精细的 3D 模型。
|
||||||
|
|
||||||
|
### 四、 总结:推荐的开发路线
|
||||||
|
|
||||||
|
如果你现在开始动手,我建议按照这个层级构建:
|
||||||
|
|
||||||
|
1. **Level 1 (原型):**
|
||||||
|
|
||||||
|
* 使用 **Three.js**。
|
||||||
|
* 中间放一个红球(太阳),周围放一个蓝球(地球)。
|
||||||
|
* 使用静态数据(手动写死坐标)确位置。
|
||||||
|
|
||||||
|
2. **Level 2 (接入数据):**
|
||||||
|
|
||||||
|
* 后端写好 Python 脚本,拉取 JPL 数据。
|
||||||
|
* 前端根据数据更新球体的位置。
|
||||||
|
|
||||||
|
3. **Level 3 (视觉升级):**
|
||||||
|
|
||||||
|
* 给球体贴上 NASA 的纹理。
|
||||||
|
* 去 NASA 3D 网站下载 Voyager 的 `.glb` 模型,替换掉代表探测器的小方块。
|
||||||
|
* 加上“星空背景盒子 (Skybox)”,让背景是真实的银河系星图,而不是全黑。
|
||||||
|
|
||||||
|
4. **Level 4 (交互完善):**
|
||||||
|
|
||||||
|
* 实现**轨道控制器 (OrbitControls)**,允许用户旋转、缩放视角。
|
||||||
|
* 实现**点击聚焦**:点击列表里的“火星”,视角平滑飞向火星。
|
||||||
|
|
||||||
|
太棒了!这两个功能是让你的太阳系可视化项目从“能用”走向“惊艳”的关键一步。
|
||||||
|
|
||||||
|
下面我将分别提供这两个核心功能的 Three.js 代码片段。你可以把它们集成到你的 Three.js 初始化和渲染循环中。
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### 一、 Three.js 加载行星纹理 (让星球看起来真实)
|
||||||
|
|
||||||
|
这段代码展示了如何创建一个带有漫反射贴图(颜色)、高光贴图(反光)和法线贴图(凹凸感)的逼真地球。
|
||||||
|
|
||||||
|
**前置要求:** 你需要准备好 `earth_diffuse.jpg`, `earth_specular.jpg`, `earth_normal.jpg` 这三张图片放在你的项目文件夹中。
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// 1. 初始化纹理加载器
|
||||||
|
const textureLoader = new THREE.TextureLoader();
|
||||||
|
|
||||||
|
// 2. 定义创建行星的函数
|
||||||
|
function createRealisticPlanet() {
|
||||||
|
// --- 几何体 (Geometry) ---
|
||||||
|
// 创建一个球体。参数:半径, 水平分段数, 垂直分段数
|
||||||
|
// 分段数越高,球体越圆滑,但性能开销越大。64是比较好的平衡点。
|
||||||
|
const geometry = new THREE.SphereGeometry(1, 64, 64);
|
||||||
|
|
||||||
|
// --- 材质 (Material) ---
|
||||||
|
// 使用 MeshPhongMaterial,这是一种支持高光反射的材质,适合表现行星表面。
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
// a. 漫反射贴图 (Diffuse Map) - 决定星球表面的基本颜色和图案
|
||||||
|
map: textureLoader.load('textures/earth_diffuse.jpg'),
|
||||||
|
|
||||||
|
// b. 高光贴图 (Specular Map) - 决定哪些区域反光(海洋),哪些不反光(陆地)
|
||||||
|
// 通常是黑白图片,白色反光强,黑色不反光。
|
||||||
|
specularMap: textureLoader.load('textures/earth_specular.jpg'),
|
||||||
|
specular: new THREE.Color('grey'), // 高光的颜色
|
||||||
|
shininess: 10, // 高光的亮度指数
|
||||||
|
|
||||||
|
// c. 法线贴图 (Normal Map) - 模拟表面的凹凸细节(山脉、海沟),不改变实际几何体
|
||||||
|
normalMap: textureLoader.load('textures/earth_normal.jpg'),
|
||||||
|
normalScale: new THREE.Vector2(1, 1) // 凹凸感的强度
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 网格 (Mesh) ---
|
||||||
|
// 将几何体和材质组合成一个可渲染的对象
|
||||||
|
const earthMesh = new THREE.Mesh(geometry, material);
|
||||||
|
|
||||||
|
// 稍微倾斜一点,模拟地轴倾角
|
||||||
|
earthMesh.rotation.z = THREE.MathUtils.degToRad(23.5);
|
||||||
|
|
||||||
|
return earthMesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 将地球加入场景
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
// ... 添加灯光 (必须有光才能看到 Phong 材质的效果) ...
|
||||||
|
const sunLight = new THREE.PointLight(0xffffff, 1.5);
|
||||||
|
scene.add(sunLight);
|
||||||
|
|
||||||
|
const earth = createRealisticPlanet();
|
||||||
|
scene.add(earth);
|
||||||
|
|
||||||
|
// 在你的动画循环中让它自转
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
earth.rotation.y += 0.001; // 每一帧旋转一点点
|
||||||
|
// renderer.render(...)
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
```
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### 二、 处理动态缩放 (The Scale Problem)
|
||||||
|
|
||||||
|
这段代码解决的是“距离太远看不见”的问题。它的核心思想是:**在每一帧渲染前,检查摄像机离物体有多远,然后调整物体的大小,确保它在屏幕上至少占据一定的大小。**
|
||||||
|
|
||||||
|
你需要把这段逻辑放在你的 `animate()` 或 `render()` 循环中。
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// 假设你已经有了场景、摄像机和一些物体
|
||||||
|
// scene, camera, renderer 已初始化
|
||||||
|
// 假设你有一个数组存放所有的探测器对象 (Mesh 或 Sprite)
|
||||||
|
const probes = [voyager1Mesh, parkerSolarProbeSprite, ...];
|
||||||
|
|
||||||
|
// 定义一个基础缩放因子,决定物体在远看时保持多大
|
||||||
|
// 这个值需要根据你的实际场景单位进行调整测试
|
||||||
|
const MIN_VISIBLE_SCALE = 0.05;
|
||||||
|
|
||||||
|
// --- 这个函数放在你的 animate() 循环中 ---
|
||||||
|
function updateObjectScales() {
|
||||||
|
probes.forEach(probe => {
|
||||||
|
// 1. 计算物体到摄像机的距离
|
||||||
|
const distance = camera.position.distanceTo(probe.position);
|
||||||
|
|
||||||
|
// 2. 计算目标缩放比例
|
||||||
|
// 逻辑:距离越远,需要的缩放比例就越大。
|
||||||
|
// 我们设置一个下限为 1 (保持原始大小),上限根据距离动态增加。
|
||||||
|
// distance * MIN_VISIBLE_SCALE 是一个经验公式,你可以根据需要修改。
|
||||||
|
let targetScale = Math.max(1, distance * MIN_VISIBLE_SCALE);
|
||||||
|
|
||||||
|
// 【可选优化】:如果物体是 Sprite(图标),我们通常希望它大小固定,不随距离变化
|
||||||
|
// 如果是 3D 模型,我们希望它远看大,近看恢复真实大小。
|
||||||
|
if (probe.isSprite) {
|
||||||
|
// 对于图标,我们可以让它始终保持相对于屏幕的固定大小
|
||||||
|
// 这种计算稍微复杂一点,需要考虑相机的视场角 (FOV)
|
||||||
|
const scaleFactor = distance / camera.fov; // 简化版计算
|
||||||
|
probe.scale.set(scaleFactor, scaleFactor, scaleFactor);
|
||||||
|
} else {
|
||||||
|
// 对于 3D 模型,应用动态缩放
|
||||||
|
probe.scale.set(targetScale, targetScale, targetScale);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 你的主循环 ---
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
// 1. 更新控制器 (如果用了 OrbitControls)
|
||||||
|
// controls.update();
|
||||||
|
|
||||||
|
// 2. 【核心】更新物体的动态缩放
|
||||||
|
updateObjectScales();
|
||||||
|
|
||||||
|
// 3. 渲染场景
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 建议
|
||||||
|
|
||||||
|
对于初学者,我强烈建议先从**纹理贴图**开始。把一个灰色的球体变成一个逼真的地球,会给你带来巨大的成就感。
|
||||||
|
|
||||||
|
动态缩放稍微复杂一些,涉及到对 3D 空间距离感的调试。你可以先把所有的物体都按 1000 倍的固定比例放大,等整个流程跑通了,再加入动态缩放的逻辑来提升体验。
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Cosmo 项目当前状态
|
||||||
|
|
||||||
|
## ✅ 已完成
|
||||||
|
|
||||||
|
### 后端 (100%)
|
||||||
|
- ✅ FastAPI 服务器搭建
|
||||||
|
- ✅ 从 NASA JPL Horizons 获取数据
|
||||||
|
- ✅ 实现 API 端点
|
||||||
|
- `/api/celestial/positions` - 获取天体位置
|
||||||
|
- `/api/celestial/info/{id}` - 获取天体信息
|
||||||
|
- `/api/celestial/list` - 列出所有天体
|
||||||
|
- ✅ 数据缓存机制(3天TTL)
|
||||||
|
- ✅ CORS 配置
|
||||||
|
- ✅ 支持时间范围查询
|
||||||
|
|
||||||
|
**当前运行**: http://localhost:8000
|
||||||
|
|
||||||
|
### 前端 (75%)
|
||||||
|
- ✅ React + TypeScript + Vite 项目
|
||||||
|
- ✅ Three.js 3D 场景
|
||||||
|
- ✅ 实时数据获取和显示
|
||||||
|
- ✅ 基本天体渲染(球体)
|
||||||
|
- ✅ OrbitControls 相机控制
|
||||||
|
- ✅ 星空背景
|
||||||
|
- ✅ Loading 状态
|
||||||
|
- ✅ 错误处理
|
||||||
|
- ✅ Tailwind CSS 样式
|
||||||
|
|
||||||
|
**当前运行**: http://localhost:5173
|
||||||
|
|
||||||
|
## 🚧 下一步 (Stage 3 剩余部分)
|
||||||
|
|
||||||
|
### 轨道线绘制
|
||||||
|
**目标**: 显示探测器的历史轨迹和未来路径
|
||||||
|
|
||||||
|
**实现方法**:
|
||||||
|
1. 修改 API 调用,获取时间序列数据(如过去1年到未来1年)
|
||||||
|
2. 创建 `OrbitLine.tsx` 组件
|
||||||
|
3. 使用 Three.js 的 `Line` 或 `TubeGeometry` 绘制轨道
|
||||||
|
4. 添加到 Scene 组件
|
||||||
|
|
||||||
|
**代码位置**: `frontend/src/components/OrbitLine.tsx`
|
||||||
|
|
||||||
|
### 时间选择器
|
||||||
|
**目标**: 允许用户选择起止时间查看不同时期的位置
|
||||||
|
|
||||||
|
**实现方法**:
|
||||||
|
1. 创建 `TimeSelector.tsx` 组件
|
||||||
|
2. 使用日期选择器(或简单的 input[type="date"])
|
||||||
|
3. 将选择的时间传递给 useSpaceData hook
|
||||||
|
4. 重新获取数据并更新场景
|
||||||
|
|
||||||
|
**代码位置**: `frontend/src/components/TimeSelector.tsx`
|
||||||
|
|
||||||
|
## 🎯 Stage 4: 进阶交互
|
||||||
|
|
||||||
|
### 点击聚焦
|
||||||
|
- 使用 Three.js Raycaster 检测点击
|
||||||
|
- 相机平滑动画移动到目标
|
||||||
|
- 使用 @react-three/drei 的 `CameraControls` 或手动实现
|
||||||
|
|
||||||
|
### 信息面板
|
||||||
|
- 显示选中天体的详细信息
|
||||||
|
- 距离、速度、最近的行星等
|
||||||
|
- 使用 React Portal 或绝对定位的 div
|
||||||
|
|
||||||
|
### 天体列表侧边栏
|
||||||
|
- 显示所有天体的列表
|
||||||
|
- 点击可聚焦
|
||||||
|
- 可筛选(行星/探测器)
|
||||||
|
|
||||||
|
## 🎨 Stage 5: 视觉优化
|
||||||
|
|
||||||
|
### 需要下载的资源
|
||||||
|
|
||||||
|
**行星纹理** (https://www.solarsystemscope.com/textures/):
|
||||||
|
```
|
||||||
|
frontend/public/textures/
|
||||||
|
├── sun_diffuse.jpg
|
||||||
|
├── earth_diffuse.jpg
|
||||||
|
├── earth_normal.jpg
|
||||||
|
├── earth_specular.jpg
|
||||||
|
├── mars_diffuse.jpg
|
||||||
|
├── jupiter_diffuse.jpg
|
||||||
|
├── saturn_diffuse.jpg
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**探测器 3D 模型** (https://nasa3d.arc.nasa.gov/models):
|
||||||
|
```
|
||||||
|
frontend/public/models/
|
||||||
|
├── voyager.glb
|
||||||
|
├── new_horizons.glb
|
||||||
|
├── parker_solar_probe.glb
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动态缩放
|
||||||
|
- 根据相机距离调整物体大小
|
||||||
|
- 确保远距离时仍能看到物体
|
||||||
|
- 公式: `scale = Math.max(1, distance * MIN_VISIBLE_SCALE)`
|
||||||
|
|
||||||
|
## 📊 进度统计
|
||||||
|
|
||||||
|
| 阶段 | 进度 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| Stage 1: 后端基础 | 100% | ✅ 完成 |
|
||||||
|
| Stage 2: 前端基础 | 100% | ✅ 完成 |
|
||||||
|
| Stage 3: 数据集成 | 70% | 🚧 进行中 |
|
||||||
|
| Stage 4: 交互功能 | 0% | ⏳ 待开始 |
|
||||||
|
| Stage 5: 视觉优化 | 0% | ⏳ 待开始 |
|
||||||
|
|
||||||
|
**总体进度**: ~54% (2.7/5 阶段)
|
||||||
|
|
||||||
|
## 🔧 技术债务 & 改进
|
||||||
|
|
||||||
|
1. **类型安全**: 某些地方可以加强 TypeScript 类型定义
|
||||||
|
2. **错误处理**: 前端可以添加更详细的错误信息
|
||||||
|
3. **性能优化**: 大量天体时可考虑使用 InstancedMesh
|
||||||
|
4. **测试**: 尚未添加单元测试和集成测试
|
||||||
|
5. **文档**: API 文档可以更详细
|
||||||
|
|
||||||
|
## 📝 当前可用命令
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source venv/bin/activate
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎉 成果展示
|
||||||
|
|
||||||
|
目前可以:
|
||||||
|
1. 访问 http://localhost:5173
|
||||||
|
2. 看到太阳系的 3D 可视化
|
||||||
|
3. 使用鼠标控制视角
|
||||||
|
4. 看到基于 NASA 真实数据的天体位置
|
||||||
|
5. 看到漂亮的星空背景
|
||||||
|
|
||||||
|
数据实时从 NASA JPL Horizons 获取,包括:
|
||||||
|
- 7 个探测器(Voyager 1 & 2, New Horizons, Parker Solar Probe, Juno, Cassini, Perseverance)
|
||||||
|
- 9 个天体(太阳 + 八大行星)
|
||||||
|
|
||||||
|
总共 16 个天体的精确位置!
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Application Settings
|
||||||
|
APP_NAME=Cosmo - Deep Space Explorer
|
||||||
|
API_PREFIX=/api
|
||||||
|
|
||||||
|
# CORS Settings (comma-separated list)
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# Cache Settings
|
||||||
|
CACHE_TTL_DAYS=3
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Cosmo Backend
|
||||||
|
|
||||||
|
Backend API for the Cosmo deep space explorer visualization system.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Create virtual environment:
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Copy environment file:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
Start the development server:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Or using uvicorn directly:
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at:
|
||||||
|
- API: http://localhost:8000/api
|
||||||
|
- Docs: http://localhost:8000/docs
|
||||||
|
- Health: http://localhost:8000/health
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Get Celestial Positions
|
||||||
|
```
|
||||||
|
GET /api/celestial/positions
|
||||||
|
```
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- `start_time`: ISO 8601 datetime (optional)
|
||||||
|
- `end_time`: ISO 8601 datetime (optional)
|
||||||
|
- `step`: Time step, e.g., "1d", "12h" (default: "1d")
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
http://localhost:8000/api/celestial/positions?start_time=2025-01-01T00:00:00Z&end_time=2025-01-10T00:00:00Z&step=1d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Body Info
|
||||||
|
```
|
||||||
|
GET /api/celestial/info/{body_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
http://localhost:8000/api/celestial/info/-31
|
||||||
|
```
|
||||||
|
|
||||||
|
### List All Bodies
|
||||||
|
```
|
||||||
|
GET /api/celestial/list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear Cache
|
||||||
|
```
|
||||||
|
POST /api/celestial/cache/clear
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""
|
||||||
|
API routes for celestial data
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.models.celestial import (
|
||||||
|
CelestialDataResponse,
|
||||||
|
BodyInfo,
|
||||||
|
CELESTIAL_BODIES,
|
||||||
|
)
|
||||||
|
from app.services.horizons import horizons_service
|
||||||
|
from app.services.cache import cache_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/celestial", tags=["celestial"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/positions", response_model=CelestialDataResponse)
|
||||||
|
async def get_celestial_positions(
|
||||||
|
start_time: Optional[str] = Query(
|
||||||
|
None,
|
||||||
|
description="Start time in ISO 8601 format (e.g., 2025-01-01T00:00:00Z)",
|
||||||
|
),
|
||||||
|
end_time: Optional[str] = Query(
|
||||||
|
None,
|
||||||
|
description="End time in ISO 8601 format",
|
||||||
|
),
|
||||||
|
step: str = Query(
|
||||||
|
"1d",
|
||||||
|
description="Time step (e.g., '1d' for 1 day, '12h' for 12 hours)",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get positions of all celestial bodies for a time range
|
||||||
|
|
||||||
|
If only start_time is provided, returns a single snapshot.
|
||||||
|
If both start_time and end_time are provided, returns positions at intervals defined by step.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Parse time strings
|
||||||
|
start_dt = None if start_time is None else datetime.fromisoformat(start_time.replace("Z", "+00:00"))
|
||||||
|
end_dt = None if end_time is None else datetime.fromisoformat(end_time.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
cached_data = cache_service.get(start_dt, end_dt, step)
|
||||||
|
if cached_data is not None:
|
||||||
|
return CelestialDataResponse(bodies=cached_data)
|
||||||
|
|
||||||
|
# Query Horizons
|
||||||
|
logger.info(f"Fetching celestial data from Horizons: start={start_dt}, end={end_dt}, step={step}")
|
||||||
|
bodies = horizons_service.get_all_bodies(start_dt, end_dt, step)
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
cache_service.set(bodies, start_dt, end_dt, step)
|
||||||
|
|
||||||
|
return CelestialDataResponse(bodies=bodies)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid time format: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching celestial positions: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to fetch data: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/info/{body_id}", response_model=BodyInfo)
|
||||||
|
async def get_body_info(body_id: str):
|
||||||
|
"""
|
||||||
|
Get detailed information about a specific celestial body
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body_id: JPL Horizons ID (e.g., '-31' for Voyager 1, '399' for Earth)
|
||||||
|
"""
|
||||||
|
if body_id not in CELESTIAL_BODIES:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Body {body_id} not found")
|
||||||
|
|
||||||
|
info = CELESTIAL_BODIES[body_id]
|
||||||
|
return BodyInfo(
|
||||||
|
id=body_id,
|
||||||
|
name=info["name"],
|
||||||
|
type=info["type"],
|
||||||
|
description=info["description"],
|
||||||
|
launch_date=info.get("launch_date"),
|
||||||
|
status=info.get("status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def list_bodies():
|
||||||
|
"""
|
||||||
|
Get a list of all available celestial bodies
|
||||||
|
"""
|
||||||
|
bodies_list = []
|
||||||
|
for body_id, info in CELESTIAL_BODIES.items():
|
||||||
|
bodies_list.append(
|
||||||
|
{
|
||||||
|
"id": body_id,
|
||||||
|
"name": info["name"],
|
||||||
|
"type": info["type"],
|
||||||
|
"description": info["description"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"bodies": bodies_list}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cache/clear")
|
||||||
|
async def clear_cache():
|
||||||
|
"""
|
||||||
|
Clear the data cache (admin endpoint)
|
||||||
|
"""
|
||||||
|
cache_service.clear()
|
||||||
|
return {"message": "Cache cleared successfully"}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""
|
||||||
|
Application configuration
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings"""
|
||||||
|
|
||||||
|
app_name: str = "Cosmo - Deep Space Explorer"
|
||||||
|
api_prefix: str = "/api"
|
||||||
|
|
||||||
|
# CORS settings
|
||||||
|
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||||
|
|
||||||
|
# Cache settings
|
||||||
|
cache_ttl_days: int = 3
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""
|
||||||
|
Cosmo - Deep Space Explorer Backend API
|
||||||
|
FastAPI application entry point
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.api.routes import router as celestial_router
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
description="Backend API for deep space probe visualization using NASA JPL Horizons data",
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(celestial_router, prefix=settings.api_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Root endpoint"""
|
||||||
|
return {
|
||||||
|
"app": settings.app_name,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"docs": "/docs",
|
||||||
|
"api": settings.api_prefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=True,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""
|
||||||
|
Data models for celestial bodies and positions
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Position(BaseModel):
|
||||||
|
"""3D position in space (AU)"""
|
||||||
|
|
||||||
|
time: datetime = Field(..., description="Timestamp for this position")
|
||||||
|
x: float = Field(..., description="X coordinate in AU (heliocentric)")
|
||||||
|
y: float = Field(..., description="Y coordinate in AU (heliocentric)")
|
||||||
|
z: float = Field(..., description="Z coordinate in AU (heliocentric)")
|
||||||
|
|
||||||
|
|
||||||
|
class CelestialBody(BaseModel):
|
||||||
|
"""Celestial body (planet or probe)"""
|
||||||
|
|
||||||
|
id: str = Field(..., description="JPL Horizons ID")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
type: Literal["planet", "probe", "star"] = Field(..., description="Body type")
|
||||||
|
positions: list[Position] = Field(
|
||||||
|
default_factory=list, description="Position history"
|
||||||
|
)
|
||||||
|
description: str | None = Field(None, description="Description")
|
||||||
|
|
||||||
|
|
||||||
|
class CelestialDataResponse(BaseModel):
|
||||||
|
"""API response for celestial positions"""
|
||||||
|
|
||||||
|
timestamp: datetime = Field(
|
||||||
|
default_factory=datetime.utcnow, description="Data fetch timestamp"
|
||||||
|
)
|
||||||
|
bodies: list[CelestialBody] = Field(..., description="List of celestial bodies")
|
||||||
|
|
||||||
|
|
||||||
|
class BodyInfo(BaseModel):
|
||||||
|
"""Detailed information about a celestial body"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
type: Literal["planet", "probe", "star"]
|
||||||
|
description: str
|
||||||
|
launch_date: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Predefined celestial bodies
|
||||||
|
CELESTIAL_BODIES = {
|
||||||
|
# Probes
|
||||||
|
"-31": {
|
||||||
|
"name": "Voyager 1",
|
||||||
|
"type": "probe",
|
||||||
|
"description": "离地球最远的人造物体,已进入星际空间",
|
||||||
|
"launch_date": "1977-09-05",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"-32": {
|
||||||
|
"name": "Voyager 2",
|
||||||
|
"type": "probe",
|
||||||
|
"description": "唯一造访过天王星和海王星的探测器",
|
||||||
|
"launch_date": "1977-08-20",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"-98": {
|
||||||
|
"name": "New Horizons",
|
||||||
|
"type": "probe",
|
||||||
|
"description": "飞掠冥王星,正处于柯伊伯带",
|
||||||
|
"launch_date": "2006-01-19",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"-96": {
|
||||||
|
"name": "Parker Solar Probe",
|
||||||
|
"type": "probe",
|
||||||
|
"description": "正在'触摸'太阳,速度最快的人造物体",
|
||||||
|
"launch_date": "2018-08-12",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"-61": {
|
||||||
|
"name": "Juno",
|
||||||
|
"type": "probe",
|
||||||
|
"description": "正在木星轨道运行",
|
||||||
|
"launch_date": "2011-08-05",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
"-82": {
|
||||||
|
"name": "Cassini",
|
||||||
|
"type": "probe",
|
||||||
|
"description": "土星探测器(已于2017年撞击销毁)",
|
||||||
|
"launch_date": "1997-10-15",
|
||||||
|
"status": "inactive",
|
||||||
|
},
|
||||||
|
"-168": {
|
||||||
|
"name": "Perseverance",
|
||||||
|
"type": "probe",
|
||||||
|
"description": "火星探测车",
|
||||||
|
"launch_date": "2020-07-30",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
# Planets
|
||||||
|
"10": {
|
||||||
|
"name": "Sun",
|
||||||
|
"type": "star",
|
||||||
|
"description": "太阳,太阳系的中心",
|
||||||
|
},
|
||||||
|
"199": {
|
||||||
|
"name": "Mercury",
|
||||||
|
"type": "planet",
|
||||||
|
"description": "水星,距离太阳最近的行星",
|
||||||
|
},
|
||||||
|
"299": {
|
||||||
|
"name": "Venus",
|
||||||
|
"type": "planet",
|
||||||
|
"description": "金星,太阳系中最热的行星",
|
||||||
|
},
|
||||||
|
"399": {
|
||||||
|
"name": "Earth",
|
||||||
|
"type": "planet",
|
||||||
|
"description": "地球,我们的家园",
|
||||||
|
},
|
||||||
|
"301": {
|
||||||
|
"name": "Moon",
|
||||||
|
"type": "planet",
|
||||||
|
"description": "月球,地球的天然卫星",
|
||||||
|
},
|
||||||
|
"499": {
|
||||||
|
"name": "Mars",
|
||||||
|
"type": "planet",
|
||||||
|
"description": "火星,红色星球",
|
||||||
|
},
|
||||||
|
"599": {
|
||||||
|
"name": "Jupiter",
|
||||||
|
"type": "planet",
|
||||||
|
"description": "木星,太阳系中最大的行星",
|
||||||
|
},
|
||||||
|
"699": {
|
||||||
|
"name": "Saturn",
|
||||||
|
"type": "planet",
|
||||||
|
"description": "土星,拥有美丽的光环",
|
||||||
|
},
|
||||||
|
"799": {
|
||||||
|
"name": "Uranus",
|
||||||
|
"type": "planet",
|
||||||
|
"description": "天王星,侧躺着自转的行星",
|
||||||
|
},
|
||||||
|
"899": {
|
||||||
|
"name": "Neptune",
|
||||||
|
"type": "planet",
|
||||||
|
"description": "海王星,太阳系最外层的行星",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""
|
||||||
|
Simple in-memory cache for celestial data
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.models.celestial import CelestialBody
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CacheEntry:
|
||||||
|
"""Cache entry with expiration"""
|
||||||
|
|
||||||
|
def __init__(self, data: list[CelestialBody], ttl_days: int = 3):
|
||||||
|
self.data = data
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
self.expires_at = self.created_at + timedelta(days=ttl_days)
|
||||||
|
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""Check if cache entry is expired"""
|
||||||
|
return datetime.utcnow() > self.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
class CacheService:
|
||||||
|
"""Simple in-memory cache service"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._cache: dict[str, CacheEntry] = {}
|
||||||
|
|
||||||
|
def _make_key(
|
||||||
|
self,
|
||||||
|
start_time: datetime | None,
|
||||||
|
end_time: datetime | None,
|
||||||
|
step: str,
|
||||||
|
) -> str:
|
||||||
|
"""Generate cache key from query parameters"""
|
||||||
|
start_str = start_time.isoformat() if start_time else "now"
|
||||||
|
end_str = end_time.isoformat() if end_time else "now"
|
||||||
|
return f"{start_str}_{end_str}_{step}"
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
start_time: datetime | None,
|
||||||
|
end_time: datetime | None,
|
||||||
|
step: str,
|
||||||
|
) -> Optional[list[CelestialBody]]:
|
||||||
|
"""
|
||||||
|
Get cached data if available and not expired
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached data or None if not found/expired
|
||||||
|
"""
|
||||||
|
key = self._make_key(start_time, end_time, step)
|
||||||
|
|
||||||
|
if key in self._cache:
|
||||||
|
entry = self._cache[key]
|
||||||
|
if not entry.is_expired():
|
||||||
|
logger.info(f"Cache hit for key: {key}")
|
||||||
|
return entry.data
|
||||||
|
else:
|
||||||
|
logger.info(f"Cache expired for key: {key}")
|
||||||
|
del self._cache[key]
|
||||||
|
|
||||||
|
logger.info(f"Cache miss for key: {key}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(
|
||||||
|
self,
|
||||||
|
data: list[CelestialBody],
|
||||||
|
start_time: datetime | None,
|
||||||
|
end_time: datetime | None,
|
||||||
|
step: str,
|
||||||
|
):
|
||||||
|
"""Store data in cache"""
|
||||||
|
key = self._make_key(start_time, end_time, step)
|
||||||
|
self._cache[key] = CacheEntry(data, ttl_days=settings.cache_ttl_days)
|
||||||
|
logger.info(f"Cached data for key: {key}")
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear all cache"""
|
||||||
|
self._cache.clear()
|
||||||
|
logger.info("Cache cleared")
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
cache_service = CacheService()
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
"""
|
||||||
|
NASA JPL Horizons data query service
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from astroquery.jplhorizons import Horizons
|
||||||
|
from astropy.time import Time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.models.celestial import Position, CelestialBody, CELESTIAL_BODIES
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HorizonsService:
|
||||||
|
"""Service for querying NASA JPL Horizons system"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the service"""
|
||||||
|
self.location = "@sun" # Heliocentric coordinates
|
||||||
|
|
||||||
|
def get_body_positions(
|
||||||
|
self,
|
||||||
|
body_id: str,
|
||||||
|
start_time: datetime | None = None,
|
||||||
|
end_time: datetime | None = None,
|
||||||
|
step: str = "1d",
|
||||||
|
) -> list[Position]:
|
||||||
|
"""
|
||||||
|
Get positions for a celestial body over a time range
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body_id: JPL Horizons ID (e.g., '-31' for Voyager 1)
|
||||||
|
start_time: Start datetime (default: now)
|
||||||
|
end_time: End datetime (default: now)
|
||||||
|
step: Time step (e.g., '1d' for 1 day, '1h' for 1 hour)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Position objects
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Set default times
|
||||||
|
if start_time is None:
|
||||||
|
start_time = datetime.utcnow()
|
||||||
|
if end_time is None:
|
||||||
|
end_time = start_time
|
||||||
|
|
||||||
|
# Convert to astropy Time objects
|
||||||
|
start_jd = Time(start_time).jd
|
||||||
|
end_jd = Time(end_time).jd
|
||||||
|
|
||||||
|
# Create time range
|
||||||
|
if start_jd == end_jd:
|
||||||
|
epochs = start_jd
|
||||||
|
else:
|
||||||
|
# Create range with step
|
||||||
|
epochs = {"start": start_time.isoformat(), "stop": end_time.isoformat(), "step": step}
|
||||||
|
|
||||||
|
logger.info(f"Querying Horizons for body {body_id} from {start_time} to {end_time}")
|
||||||
|
|
||||||
|
# Query JPL Horizons
|
||||||
|
obj = Horizons(id=body_id, location=self.location, epochs=epochs)
|
||||||
|
vectors = obj.vectors()
|
||||||
|
|
||||||
|
# Extract positions
|
||||||
|
positions = []
|
||||||
|
if isinstance(epochs, dict):
|
||||||
|
# Multiple time points
|
||||||
|
for i in range(len(vectors)):
|
||||||
|
pos = Position(
|
||||||
|
time=Time(vectors["datetime_jd"][i], format="jd").datetime,
|
||||||
|
x=float(vectors["x"][i]),
|
||||||
|
y=float(vectors["y"][i]),
|
||||||
|
z=float(vectors["z"][i]),
|
||||||
|
)
|
||||||
|
positions.append(pos)
|
||||||
|
else:
|
||||||
|
# Single time point
|
||||||
|
pos = Position(
|
||||||
|
time=start_time,
|
||||||
|
x=float(vectors["x"][0]),
|
||||||
|
y=float(vectors["y"][0]),
|
||||||
|
z=float(vectors["z"][0]),
|
||||||
|
)
|
||||||
|
positions.append(pos)
|
||||||
|
|
||||||
|
logger.info(f"Successfully retrieved {len(positions)} positions for body {body_id}")
|
||||||
|
return positions
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error querying Horizons for body {body_id}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_all_bodies(
|
||||||
|
self,
|
||||||
|
start_time: datetime | None = None,
|
||||||
|
end_time: datetime | None = None,
|
||||||
|
step: str = "1d",
|
||||||
|
) -> list[CelestialBody]:
|
||||||
|
"""
|
||||||
|
Get positions for all predefined celestial bodies
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_time: Start datetime
|
||||||
|
end_time: End datetime
|
||||||
|
step: Time step
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of CelestialBody objects
|
||||||
|
"""
|
||||||
|
bodies = []
|
||||||
|
|
||||||
|
for body_id, info in CELESTIAL_BODIES.items():
|
||||||
|
try:
|
||||||
|
# Special handling for the Sun (it's at origin)
|
||||||
|
if body_id == "10":
|
||||||
|
# Sun is at (0, 0, 0)
|
||||||
|
if start_time is None:
|
||||||
|
start_time = datetime.utcnow()
|
||||||
|
if end_time is None:
|
||||||
|
end_time = start_time
|
||||||
|
|
||||||
|
positions = [
|
||||||
|
Position(time=start_time, x=0.0, y=0.0, z=0.0)
|
||||||
|
]
|
||||||
|
if start_time != end_time:
|
||||||
|
# Add end position as well
|
||||||
|
positions.append(
|
||||||
|
Position(time=end_time, x=0.0, y=0.0, z=0.0)
|
||||||
|
)
|
||||||
|
# Special handling for Cassini (mission ended 2017-09-15)
|
||||||
|
elif body_id == "-82":
|
||||||
|
# Use Cassini's last known position (2017-09-15)
|
||||||
|
cassini_date = datetime(2017, 9, 15, 11, 58, 0)
|
||||||
|
positions = self.get_body_positions(body_id, cassini_date, cassini_date, step)
|
||||||
|
else:
|
||||||
|
# Query other bodies
|
||||||
|
positions = self.get_body_positions(body_id, start_time, end_time, step)
|
||||||
|
|
||||||
|
body = CelestialBody(
|
||||||
|
id=body_id,
|
||||||
|
name=info["name"],
|
||||||
|
type=info["type"],
|
||||||
|
positions=positions,
|
||||||
|
description=info["description"],
|
||||||
|
)
|
||||||
|
bodies.append(body)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get data for {info['name']}: {str(e)}")
|
||||||
|
# Continue with other bodies even if one fails
|
||||||
|
|
||||||
|
return bodies
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
horizons_service = HorizonsService()
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
astroquery==0.4.7
|
||||||
|
astropy==6.0.0
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
httpx==0.25.2
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Cosmo Frontend
|
||||||
|
|
||||||
|
Frontend application for the Cosmo deep space explorer visualization system.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- React 18
|
||||||
|
- TypeScript
|
||||||
|
- Vite (build tool)
|
||||||
|
- Three.js (3D rendering)
|
||||||
|
- @react-three/fiber (React Three.js integration)
|
||||||
|
- @react-three/drei (Three.js helpers)
|
||||||
|
- Tailwind CSS (styling)
|
||||||
|
- Axios (HTTP client)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
yarn install --ignore-engines
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start development server:
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will be available at http://localhost:5173/
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── Scene.tsx # Main 3D scene
|
||||||
|
│ ├── CelestialBody.tsx # Individual celestial body renderer
|
||||||
|
│ └── Loading.tsx # Loading screen
|
||||||
|
├── hooks/
|
||||||
|
│ └── useSpaceData.ts # Data fetching hook
|
||||||
|
├── types/
|
||||||
|
│ └── index.ts # TypeScript types
|
||||||
|
├── utils/
|
||||||
|
│ └── api.ts # API utilities
|
||||||
|
├── App.tsx # Main app component
|
||||||
|
└── main.tsx # Entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Current
|
||||||
|
- 3D visualization of the solar system
|
||||||
|
- Real-time data from NASA JPL Horizons
|
||||||
|
- Interactive camera controls (orbit, pan, zoom)
|
||||||
|
- Celestial bodies rendered at their current positions
|
||||||
|
- Stars background
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
- **Left click + drag**: Rotate camera
|
||||||
|
- **Right click + drag**: Pan camera
|
||||||
|
- **Scroll wheel**: Zoom in/out
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- The app requires the backend API to be running at http://localhost:8000
|
||||||
|
- Uses yarn instead of npm due to dependency compatibility
|
||||||
|
- Node version 20+ recommended (use `--ignore-engines` flag if needed)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Implement orbit line rendering
|
||||||
|
- Add time selector component
|
||||||
|
- Implement click-to-focus on celestial bodies
|
||||||
|
- Add information panels
|
||||||
|
- Load realistic textures and 3D models
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-three/drei": "^10.7.7",
|
||||||
|
"@react-three/fiber": "^9.4.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"three": "^0.181.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 733 KiB |
|
After Width: | Height: | Size: 852 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 803 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 864 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,42 @@
|
||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* Cosmo - Deep Space Explorer
|
||||||
|
* Main application component
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useSpaceData } from './hooks/useSpaceData';
|
||||||
|
import { useTrajectory } from './hooks/useTrajectory';
|
||||||
|
import { Scene } from './components/Scene';
|
||||||
|
import { ProbeList } from './components/ProbeList';
|
||||||
|
import { Loading } from './components/Loading';
|
||||||
|
import type { CelestialBody } from './types';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { bodies, loading, error } = useSpaceData();
|
||||||
|
const [selectedBody, setSelectedBody] = useState<CelestialBody | null>(null);
|
||||||
|
const { trajectoryPositions } = useTrajectory(selectedBody);
|
||||||
|
|
||||||
|
// Filter probes and planets from all bodies
|
||||||
|
const probes = bodies.filter((b) => b.type === 'probe');
|
||||||
|
const planets = bodies.filter((b) => b.type === 'planet');
|
||||||
|
|
||||||
|
const handleBodySelect = (body: CelestialBody | null) => {
|
||||||
|
setSelectedBody(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-black text-white">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">数据加载失败</h1>
|
||||||
|
<p className="text-red-400">{error}</p>
|
||||||
|
<p className="mt-4 text-sm text-gray-400">
|
||||||
|
请确保后端 API 运行在 http://localhost:8000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full relative">
|
||||||
|
{/* Title overlay */}
|
||||||
|
<div className="absolute top-4 left-4 z-50 text-white">
|
||||||
|
<h1 className="text-3xl font-bold mb-1">Cosmo</h1>
|
||||||
|
<p className="text-sm text-gray-300">深空探测器可视化</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{selectedBody ? `聚焦: ${selectedBody.name}` : `${bodies.length} 个天体`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Probe List Sidebar */}
|
||||||
|
<ProbeList
|
||||||
|
probes={probes}
|
||||||
|
planets={planets}
|
||||||
|
onBodySelect={handleBodySelect}
|
||||||
|
selectedBody={selectedBody}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 3D Scene */}
|
||||||
|
<Scene
|
||||||
|
bodies={bodies}
|
||||||
|
selectedBody={selectedBody}
|
||||||
|
trajectoryPositions={trajectoryPositions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Instructions overlay */}
|
||||||
|
<div className="absolute bottom-4 right-4 z-50 text-white text-xs bg-black bg-opacity-70 p-3 rounded">
|
||||||
|
{selectedBody ? (
|
||||||
|
<>
|
||||||
|
<p className="text-cyan-400 font-bold mb-2">聚焦模式</p>
|
||||||
|
<p>点击侧边栏的"返回太阳系视图"按钮</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="font-bold mb-2">太阳系俯视图</p>
|
||||||
|
<p>🖱️ 左键拖动: 旋转</p>
|
||||||
|
<p>🖱️ 右键拖动: 平移</p>
|
||||||
|
<p>🖱️ 滚轮: 缩放</p>
|
||||||
|
<p className="mt-2 text-gray-400">点击左侧天体列表查看详情</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -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 |
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* CameraController - handles camera movement and focus
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { Vector3 } from 'three';
|
||||||
|
import type { CelestialBody } from '../types';
|
||||||
|
import { scalePosition, scaleDistance } from '../utils/scaleDistance';
|
||||||
|
|
||||||
|
interface CameraControllerProps {
|
||||||
|
focusTarget: CelestialBody | null;
|
||||||
|
onAnimationComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CameraController({ focusTarget, onAnimationComplete }: CameraControllerProps) {
|
||||||
|
const { camera } = useThree();
|
||||||
|
const targetPosition = useRef(new Vector3());
|
||||||
|
const isAnimating = useRef(false);
|
||||||
|
const animationProgress = useRef(0);
|
||||||
|
const startPosition = useRef(new Vector3());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusTarget) {
|
||||||
|
// Focus on target - use scaled position
|
||||||
|
const pos = focusTarget.positions[0];
|
||||||
|
const scaledPos = scalePosition(pos.x, pos.y, pos.z);
|
||||||
|
const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
|
||||||
|
const scaledDistance = scaleDistance(distance);
|
||||||
|
|
||||||
|
// Calculate camera position based on target type
|
||||||
|
let offset: number;
|
||||||
|
let heightMultiplier = 1; // For adjusting vertical position
|
||||||
|
let sideMultiplier = 1; // For adjusting horizontal offset
|
||||||
|
|
||||||
|
if (focusTarget.type === 'planet') {
|
||||||
|
// For planets, use closer view from above
|
||||||
|
offset = 4;
|
||||||
|
heightMultiplier = 1.5;
|
||||||
|
sideMultiplier = 1;
|
||||||
|
} else if (focusTarget.type === 'probe') {
|
||||||
|
// For probes, determine view based on actual distance from Sun
|
||||||
|
if (distance < 10) {
|
||||||
|
// Very close probes (inner solar system, like Juno near Jupiter, Parker near Sun)
|
||||||
|
// Use a wide-angle side view to show both probe and nearby planet
|
||||||
|
offset = 15;
|
||||||
|
heightMultiplier = 0.4; // Lower camera for better side view
|
||||||
|
sideMultiplier = 2; // Move camera to the side
|
||||||
|
} else if (scaledDistance > 50) {
|
||||||
|
// Far probes (Voyagers, New Horizons)
|
||||||
|
offset = 20;
|
||||||
|
heightMultiplier = 1;
|
||||||
|
sideMultiplier = 1;
|
||||||
|
} else {
|
||||||
|
// Medium distance probes
|
||||||
|
offset = 8;
|
||||||
|
heightMultiplier = 1;
|
||||||
|
sideMultiplier = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
offset = 10;
|
||||||
|
heightMultiplier = 1;
|
||||||
|
sideMultiplier = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPosition.current.set(
|
||||||
|
scaledPos.x + (offset * sideMultiplier),
|
||||||
|
scaledPos.z + (offset * heightMultiplier),
|
||||||
|
scaledPos.y + offset
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start animation
|
||||||
|
startPosition.current.copy(camera.position);
|
||||||
|
isAnimating.current = true;
|
||||||
|
animationProgress.current = 0;
|
||||||
|
} else {
|
||||||
|
// Return to solar system overview (angled view)
|
||||||
|
targetPosition.current.set(50, 40, 50);
|
||||||
|
startPosition.current.copy(camera.position);
|
||||||
|
isAnimating.current = true;
|
||||||
|
animationProgress.current = 0;
|
||||||
|
}
|
||||||
|
}, [focusTarget, camera]);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (isAnimating.current) {
|
||||||
|
// Smooth animation using easing
|
||||||
|
animationProgress.current += delta * 0.8; // Animation speed
|
||||||
|
|
||||||
|
if (animationProgress.current >= 1) {
|
||||||
|
animationProgress.current = 1;
|
||||||
|
isAnimating.current = false;
|
||||||
|
if (onAnimationComplete) onAnimationComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Easing function (ease-in-out)
|
||||||
|
const t = animationProgress.current;
|
||||||
|
const eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||||
|
|
||||||
|
// Interpolate camera position
|
||||||
|
camera.position.lerpVectors(startPosition.current, targetPosition.current, eased);
|
||||||
|
|
||||||
|
// Look at target - use scaled position (only during animation)
|
||||||
|
if (focusTarget) {
|
||||||
|
const pos = focusTarget.positions[0];
|
||||||
|
const scaledPos = scalePosition(pos.x, pos.y, pos.z);
|
||||||
|
camera.lookAt(scaledPos.x, scaledPos.z, scaledPos.y);
|
||||||
|
} else {
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// After animation completes, OrbitControls will take over
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
/**
|
||||||
|
* CelestialBody component - renders a planet or probe with textures
|
||||||
|
*/
|
||||||
|
import { useRef, useMemo } from 'react';
|
||||||
|
import { Mesh, DoubleSide } from 'three';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import { useTexture, Html } from '@react-three/drei';
|
||||||
|
import type { CelestialBody as CelestialBodyType } from '../types';
|
||||||
|
import { scalePosition } from '../utils/scaleDistance';
|
||||||
|
|
||||||
|
interface CelestialBodyProps {
|
||||||
|
body: CelestialBodyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saturn Rings component - multiple rings for band effect
|
||||||
|
function SaturnRings() {
|
||||||
|
return (
|
||||||
|
<group rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
{/* Inner bright ring */}
|
||||||
|
<mesh>
|
||||||
|
<ringGeometry args={[1.4, 1.6, 64]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#D4B896"
|
||||||
|
transparent
|
||||||
|
opacity={0.7}
|
||||||
|
side={DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Middle darker band */}
|
||||||
|
<mesh>
|
||||||
|
<ringGeometry args={[1.6, 1.75, 64]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#8B7355"
|
||||||
|
transparent
|
||||||
|
opacity={0.5}
|
||||||
|
side={DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Outer bright ring */}
|
||||||
|
<mesh>
|
||||||
|
<ringGeometry args={[1.75, 2.0, 64]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#C4A582"
|
||||||
|
transparent
|
||||||
|
opacity={0.6}
|
||||||
|
side={DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Cassini Division (gap) */}
|
||||||
|
<mesh>
|
||||||
|
<ringGeometry args={[2.0, 2.05, 64]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#000000"
|
||||||
|
transparent
|
||||||
|
opacity={0.2}
|
||||||
|
side={DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* A Ring (outer) */}
|
||||||
|
<mesh>
|
||||||
|
<ringGeometry args={[2.05, 2.2, 64]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#B89968"
|
||||||
|
transparent
|
||||||
|
opacity={0.5}
|
||||||
|
side={DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Planet component with texture
|
||||||
|
function Planet({ body, size, emissive, emissiveIntensity }: {
|
||||||
|
body: CelestialBodyType;
|
||||||
|
size: number;
|
||||||
|
emissive: string;
|
||||||
|
emissiveIntensity: number;
|
||||||
|
}) {
|
||||||
|
const meshRef = useRef<Mesh>(null);
|
||||||
|
const position = body.positions[0];
|
||||||
|
|
||||||
|
// Apply non-linear distance scaling for better visualization
|
||||||
|
const scaledPos = useMemo(() => {
|
||||||
|
// Special handling for Moon - display it relative to Earth with visible offset
|
||||||
|
if (body.name === 'Moon') {
|
||||||
|
const moonScaled = scalePosition(position.x, position.y, position.z);
|
||||||
|
// Add a visual offset to make Moon visible next to Earth (2 units away)
|
||||||
|
const earthDistance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
|
||||||
|
// Moon orbits Earth at ~0.00257 AU, we'll give it a 2-unit offset from Earth's scaled position
|
||||||
|
const angle = Math.atan2(position.y, position.x);
|
||||||
|
const offset = 2.0; // Visual offset in scaled units
|
||||||
|
return {
|
||||||
|
x: moonScaled.x + Math.cos(angle) * offset,
|
||||||
|
y: moonScaled.y + Math.sin(angle) * offset,
|
||||||
|
z: moonScaled.z,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return scalePosition(position.x, position.y, position.z);
|
||||||
|
}, [position.x, position.y, position.z, body.name]);
|
||||||
|
|
||||||
|
// Texture mapping for planets
|
||||||
|
const texturePath = useMemo(() => {
|
||||||
|
const textureMap: Record<string, string> = {
|
||||||
|
Sun: '/textures/2k_sun.jpg',
|
||||||
|
Mercury: '/textures/2k_mercury.jpg',
|
||||||
|
Venus: '/textures/2k_venus_surface.jpg',
|
||||||
|
Earth: '/textures/2k_earth_daymap.jpg',
|
||||||
|
Moon: '/textures/2k_moon.jpg',
|
||||||
|
Mars: '/textures/2k_mars.jpg',
|
||||||
|
Jupiter: '/textures/2k_jupiter.jpg',
|
||||||
|
Saturn: '/textures/2k_saturn.jpg',
|
||||||
|
Uranus: '/textures/2k_uranus.jpg',
|
||||||
|
Neptune: '/textures/2k_neptune.jpg',
|
||||||
|
};
|
||||||
|
return textureMap[body.name] || null;
|
||||||
|
}, [body.name]);
|
||||||
|
|
||||||
|
// Load texture - this must be at the top level, not in try-catch
|
||||||
|
const texture = texturePath ? useTexture(texturePath) : null;
|
||||||
|
|
||||||
|
// Slow rotation for visual effect
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (meshRef.current) {
|
||||||
|
meshRef.current.rotation.y += delta * 0.1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate ACTUAL distance from Sun for display (not scaled)
|
||||||
|
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
|
||||||
|
<mesh ref={meshRef} renderOrder={0}>
|
||||||
|
<sphereGeometry args={[size, 64, 64]} />
|
||||||
|
{texture ? (
|
||||||
|
<meshStandardMaterial
|
||||||
|
map={texture}
|
||||||
|
emissive={emissive}
|
||||||
|
emissiveIntensity={emissiveIntensity}
|
||||||
|
roughness={body.type === 'star' ? 0 : 0.7}
|
||||||
|
metalness={0.1}
|
||||||
|
depthTest={true}
|
||||||
|
depthWrite={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#888888"
|
||||||
|
emissive={emissive}
|
||||||
|
emissiveIntensity={emissiveIntensity}
|
||||||
|
roughness={0.7}
|
||||||
|
metalness={0.1}
|
||||||
|
depthTest={true}
|
||||||
|
depthWrite={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Saturn Rings */}
|
||||||
|
{body.name === 'Saturn' && <SaturnRings />}
|
||||||
|
|
||||||
|
{/* Sun glow effect */}
|
||||||
|
{body.type === 'star' && (
|
||||||
|
<>
|
||||||
|
<pointLight intensity={10} distance={400} color="#fff8e7" />
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[size * 1.8, 32, 32]} />
|
||||||
|
<meshBasicMaterial color="#FDB813" transparent opacity={0.35} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name label */}
|
||||||
|
<Html
|
||||||
|
position={[0, size + 0.3, 0]}
|
||||||
|
center
|
||||||
|
distanceFactor={10}
|
||||||
|
style={{
|
||||||
|
color: body.type === 'star' ? '#FDB813' : '#ffffff',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textShadow: '0 0 4px rgba(0,0,0,0.8)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{body.name}
|
||||||
|
<br />
|
||||||
|
<span style={{ fontSize: '8px', opacity: 0.7 }}>
|
||||||
|
{distance.toFixed(2)} AU
|
||||||
|
</span>
|
||||||
|
</Html>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CelestialBody({ body }: CelestialBodyProps) {
|
||||||
|
// Get the current position (use the first position for now)
|
||||||
|
const position = body.positions[0];
|
||||||
|
if (!position) return null;
|
||||||
|
|
||||||
|
// Skip probes - they will use 3D models
|
||||||
|
if (body.type === 'probe') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine size based on body type
|
||||||
|
const appearance = useMemo(() => {
|
||||||
|
if (body.type === 'star') {
|
||||||
|
return {
|
||||||
|
size: 0.4, // Slightly larger sun for better visibility
|
||||||
|
emissive: '#FDB813',
|
||||||
|
emissiveIntensity: 1.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Planet sizes - balanced for visibility with smaller probes
|
||||||
|
const planetSizes: Record<string, number> = {
|
||||||
|
Mercury: 0.35, // Slightly larger for visibility
|
||||||
|
Venus: 0.55, // Slightly larger for visibility
|
||||||
|
Earth: 0.6, // Slightly larger for visibility
|
||||||
|
Moon: 0.25, // Smaller than Earth
|
||||||
|
Mars: 0.45, // Slightly larger for visibility
|
||||||
|
Jupiter: 1.4, // Larger gas giant
|
||||||
|
Saturn: 1.2, // Larger gas giant
|
||||||
|
Uranus: 0.8, // Medium outer planet
|
||||||
|
Neptune: 0.8, // Medium outer planet
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: planetSizes[body.name] || 0.5,
|
||||||
|
emissive: '#000000',
|
||||||
|
emissiveIntensity: 0,
|
||||||
|
};
|
||||||
|
}, [body.name, body.type]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Planet
|
||||||
|
body={body}
|
||||||
|
size={appearance.size}
|
||||||
|
emissive={appearance.emissive}
|
||||||
|
emissiveIntensity={appearance.emissiveIntensity}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Loading component
|
||||||
|
*/
|
||||||
|
export function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-black text-white">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg">Loading celestial data from NASA JPL Horizons...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* Orbit component - displays the orbital path of a planet
|
||||||
|
*/
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Vector3 } from 'three';
|
||||||
|
import { Line } from '@react-three/drei';
|
||||||
|
import { scaleDistance } from '../utils/scaleDistance';
|
||||||
|
|
||||||
|
interface OrbitProps {
|
||||||
|
distance: number; // Average distance from sun in AU
|
||||||
|
color?: string;
|
||||||
|
lineWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Orbit({ distance, color = '#ffffff', lineWidth = 1 }: OrbitProps) {
|
||||||
|
// Create orbit path points
|
||||||
|
const points = useMemo(() => {
|
||||||
|
const scaledDistance = scaleDistance(distance);
|
||||||
|
const segments = 128;
|
||||||
|
const orbitPoints: Vector3[] = [];
|
||||||
|
|
||||||
|
// Create a circular orbit (simplified - actual orbits are elliptical)
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const angle = (i / segments) * Math.PI * 2;
|
||||||
|
const x = Math.cos(angle) * scaledDistance;
|
||||||
|
const y = Math.sin(angle) * scaledDistance;
|
||||||
|
orbitPoints.push(new Vector3(x, 0, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
return orbitPoints;
|
||||||
|
}, [distance]);
|
||||||
|
|
||||||
|
if (distance === 0) return null; // Don't render orbit for Sun
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
points={points}
|
||||||
|
color={color}
|
||||||
|
lineWidth={lineWidth}
|
||||||
|
transparent
|
||||||
|
opacity={0.3}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
/**
|
||||||
|
* Probe component - renders space probes with 3D models
|
||||||
|
*/
|
||||||
|
import { useRef, useMemo } from 'react';
|
||||||
|
import { Group } from 'three';
|
||||||
|
import { useGLTF, Html } from '@react-three/drei';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import type { CelestialBody } from '../types';
|
||||||
|
import { scalePosition } from '../utils/scaleDistance';
|
||||||
|
|
||||||
|
interface ProbeProps {
|
||||||
|
body: CelestialBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate component for each probe type to properly use hooks
|
||||||
|
function ProbeModel({ body, modelPath }: { body: CelestialBody; modelPath: string }) {
|
||||||
|
const groupRef = useRef<Group>(null);
|
||||||
|
const position = body.positions[0];
|
||||||
|
|
||||||
|
// Apply non-linear distance scaling
|
||||||
|
const scaledPos = useMemo(() => {
|
||||||
|
const baseScaled = scalePosition(position.x, position.y, position.z);
|
||||||
|
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
|
||||||
|
|
||||||
|
// Special handling for probes very close to planets (< 10 AU from Sun)
|
||||||
|
// These probes need visual offset to avoid overlapping with planets
|
||||||
|
if (distance < 10) {
|
||||||
|
// Add a radial offset to push the probe away from the Sun (and nearby planets)
|
||||||
|
// This makes probes like Juno visible next to Jupiter
|
||||||
|
const angle = Math.atan2(position.y, position.x);
|
||||||
|
const offsetAmount = 3.0; // Visual offset in scaled units
|
||||||
|
return {
|
||||||
|
x: baseScaled.x + Math.cos(angle) * offsetAmount,
|
||||||
|
y: baseScaled.y + Math.sin(angle) * offsetAmount,
|
||||||
|
z: baseScaled.z,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseScaled;
|
||||||
|
}, [position.x, position.y, position.z]);
|
||||||
|
|
||||||
|
// Load 3D model - must be at top level
|
||||||
|
const { scene } = useGLTF(modelPath);
|
||||||
|
|
||||||
|
// Configure model materials for proper rendering
|
||||||
|
const configuredScene = useMemo(() => {
|
||||||
|
const clonedScene = scene.clone();
|
||||||
|
clonedScene.traverse((child: any) => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
// Force proper depth testing and high render order
|
||||||
|
child.renderOrder = 10000;
|
||||||
|
if (child.material) {
|
||||||
|
// Clone material to avoid modifying shared materials
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material = child.material.map((mat: any) => {
|
||||||
|
const clonedMat = mat.clone();
|
||||||
|
clonedMat.depthTest = true;
|
||||||
|
clonedMat.depthWrite = true;
|
||||||
|
clonedMat.transparent = false;
|
||||||
|
clonedMat.opacity = 1.0;
|
||||||
|
clonedMat.alphaTest = 0;
|
||||||
|
clonedMat.needsUpdate = true;
|
||||||
|
return clonedMat;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
child.material = child.material.clone();
|
||||||
|
child.material.depthTest = true;
|
||||||
|
child.material.depthWrite = true;
|
||||||
|
child.material.transparent = false;
|
||||||
|
child.material.opacity = 1.0;
|
||||||
|
child.material.alphaTest = 0;
|
||||||
|
child.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return clonedScene;
|
||||||
|
}, [scene]);
|
||||||
|
|
||||||
|
// Slow rotation for visual effect
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.rotation.y += delta * 0.2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate ACTUAL distance from Sun (not scaled)
|
||||||
|
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
|
||||||
|
<primitive
|
||||||
|
object={configuredScene}
|
||||||
|
scale={0.2}
|
||||||
|
/>
|
||||||
|
{/* Removed the semi-transparent sphere to avoid rendering conflicts */}
|
||||||
|
|
||||||
|
{/* Name label */}
|
||||||
|
<Html
|
||||||
|
position={[0, 1, 0]}
|
||||||
|
center
|
||||||
|
distanceFactor={15}
|
||||||
|
style={{
|
||||||
|
color: '#00ffff',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textShadow: '0 0 6px rgba(0,255,255,0.8)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🛰️ {body.name}
|
||||||
|
<br />
|
||||||
|
<span style={{ fontSize: '10px', opacity: 0.8 }}>
|
||||||
|
{distance.toFixed(2)} AU
|
||||||
|
</span>
|
||||||
|
</Html>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback component when model is not available
|
||||||
|
function ProbeFallback({ body }: { body: CelestialBody }) {
|
||||||
|
const position = body.positions[0];
|
||||||
|
|
||||||
|
// Apply non-linear distance scaling
|
||||||
|
const scaledPos = useMemo(() => {
|
||||||
|
const baseScaled = scalePosition(position.x, position.y, position.z);
|
||||||
|
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
|
||||||
|
|
||||||
|
// Special handling for probes very close to planets (< 10 AU from Sun)
|
||||||
|
if (distance < 10) {
|
||||||
|
const angle = Math.atan2(position.y, position.x);
|
||||||
|
const offsetAmount = 3.0; // Visual offset in scaled units
|
||||||
|
return {
|
||||||
|
x: baseScaled.x + Math.cos(angle) * offsetAmount,
|
||||||
|
y: baseScaled.y + Math.sin(angle) * offsetAmount,
|
||||||
|
z: baseScaled.z,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseScaled;
|
||||||
|
}, [position.x, position.y, position.z]);
|
||||||
|
|
||||||
|
// Calculate ACTUAL distance from Sun (not scaled)
|
||||||
|
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[0.15, 16, 16]} />
|
||||||
|
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.8} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Name label */}
|
||||||
|
<Html
|
||||||
|
position={[0, 1, 0]}
|
||||||
|
center
|
||||||
|
distanceFactor={15}
|
||||||
|
style={{
|
||||||
|
color: '#ff6666',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textShadow: '0 0 6px rgba(255,0,0,0.8)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🛰️ {body.name}
|
||||||
|
<br />
|
||||||
|
<span style={{ fontSize: '10px', opacity: 0.8 }}>
|
||||||
|
{distance.toFixed(2)} AU
|
||||||
|
</span>
|
||||||
|
</Html>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Probe({ body }: ProbeProps) {
|
||||||
|
const position = body.positions[0];
|
||||||
|
if (!position) return null;
|
||||||
|
|
||||||
|
// Model mapping for probes - match actual filenames
|
||||||
|
const modelMap: Record<string, string | null> = {
|
||||||
|
'Voyager 1': '/models/voyager_1.glb',
|
||||||
|
'Voyager 2': '/models/voyager_2.glb',
|
||||||
|
'Juno': '/models/juno.glb',
|
||||||
|
'Cassini': '/models/cassini.glb',
|
||||||
|
'New Horizons': null, // No model yet
|
||||||
|
'Parker Solar Probe': '/models/parker_solar_probe.glb',
|
||||||
|
'Perseverance': null, // No model yet
|
||||||
|
};
|
||||||
|
|
||||||
|
const modelPath = modelMap[body.name];
|
||||||
|
|
||||||
|
// Use model if available, otherwise use fallback
|
||||||
|
if (modelPath) {
|
||||||
|
return <ProbeModel body={body} modelPath={modelPath} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ProbeFallback body={body} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload available models
|
||||||
|
const modelsToPreload = [
|
||||||
|
'/models/voyager_1.glb',
|
||||||
|
'/models/voyager_2.glb',
|
||||||
|
'/models/juno.glb',
|
||||||
|
'/models/cassini.glb',
|
||||||
|
'/models/parker_solar_probe.glb',
|
||||||
|
];
|
||||||
|
|
||||||
|
modelsToPreload.forEach((path) => {
|
||||||
|
useGLTF.preload(path);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* ProbeList component - sidebar showing planets and probes
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { CelestialBody } from '../types';
|
||||||
|
|
||||||
|
interface ProbeListProps {
|
||||||
|
probes: CelestialBody[];
|
||||||
|
planets: CelestialBody[];
|
||||||
|
onBodySelect: (body: CelestialBody | null) => void;
|
||||||
|
selectedBody: CelestialBody | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProbeList({ probes, planets, onBodySelect, selectedBody }: ProbeListProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
|
// Calculate distance for each probe
|
||||||
|
const probesWithDistance = probes.map((probe) => {
|
||||||
|
const pos = probe.positions[0];
|
||||||
|
const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
|
||||||
|
return { body: probe, distance };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate distance for each planet (exclude Sun)
|
||||||
|
const planetsWithDistance = planets
|
||||||
|
.filter((p) => p.type !== 'star') // Exclude Sun
|
||||||
|
.map((planet) => {
|
||||||
|
const pos = planet.positions[0];
|
||||||
|
const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
|
||||||
|
return { body: planet, distance };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by distance
|
||||||
|
probesWithDistance.sort((a, b) => a.distance - b.distance);
|
||||||
|
planetsWithDistance.sort((a, b) => a.distance - b.distance);
|
||||||
|
|
||||||
|
const totalCount = probes.length + planets.length - 1; // -1 for Sun
|
||||||
|
|
||||||
|
// Collapsed state - show only toggle button
|
||||||
|
if (!isExpanded) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
className="absolute top-20 left-4 z-50 bg-black bg-opacity-80 backdrop-blur-sm rounded-lg p-3 hover:bg-opacity-90 transition-all"
|
||||||
|
title="展开天体列表"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-white">
|
||||||
|
<span className="text-lg">🌍</span>
|
||||||
|
<span className="text-sm font-medium">天体列表</span>
|
||||||
|
<span className="text-gray-400">({totalCount})</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-20 left-4 z-50 bg-black bg-opacity-80 backdrop-blur-sm rounded-lg p-4 max-w-xs">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-white text-lg font-bold flex items-center gap-2">
|
||||||
|
🌍 天体列表
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(false)}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||||
|
title="收起列表"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Planets Section */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-white text-sm font-semibold mb-2 text-gray-300">
|
||||||
|
行星 ({planetsWithDistance.length})
|
||||||
|
</h3>
|
||||||
|
{planetsWithDistance.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-xs p-2">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{planetsWithDistance.map(({ body, distance }) => (
|
||||||
|
<button
|
||||||
|
key={body.id}
|
||||||
|
onClick={() => onBodySelect(body)}
|
||||||
|
className={`w-full text-left p-2 rounded transition-all ${
|
||||||
|
selectedBody?.id === body.id
|
||||||
|
? 'bg-blue-500 bg-opacity-30 border-2 border-blue-400'
|
||||||
|
: 'bg-gray-800 bg-opacity-50 border border-gray-600 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-white font-medium text-sm">{body.name}</div>
|
||||||
|
<div className="text-gray-400 text-xs mt-0.5">
|
||||||
|
{distance.toFixed(2)} AU
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedBody?.id === body.id && (
|
||||||
|
<div className="text-blue-400 text-xs">● 聚焦</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Probes Section */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white text-sm font-semibold mb-2 text-gray-300">
|
||||||
|
探测器 ({probesWithDistance.length})
|
||||||
|
</h3>
|
||||||
|
{probesWithDistance.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-xs p-2">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{probesWithDistance.map(({ body, distance }) => (
|
||||||
|
<button
|
||||||
|
key={body.id}
|
||||||
|
onClick={() => onBodySelect(body)}
|
||||||
|
className={`w-full text-left p-2 rounded transition-all ${
|
||||||
|
selectedBody?.id === body.id
|
||||||
|
? 'bg-cyan-500 bg-opacity-30 border-2 border-cyan-400'
|
||||||
|
: 'bg-gray-800 bg-opacity-50 border border-gray-600 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-white font-medium text-sm">{body.name}</div>
|
||||||
|
<div className="text-gray-400 text-xs mt-0.5">
|
||||||
|
{distance.toFixed(2)} AU
|
||||||
|
{distance > 30 && ' (遥远)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedBody?.id === body.id && (
|
||||||
|
<div className="text-cyan-400 text-xs">● 聚焦</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Return button */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-600">
|
||||||
|
<button
|
||||||
|
onClick={() => onBodySelect(null)}
|
||||||
|
className="w-full text-center p-2 rounded bg-gray-700 hover:bg-gray-600 text-white text-sm"
|
||||||
|
>
|
||||||
|
返回太阳系视图
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* Main 3D Scene component
|
||||||
|
*/
|
||||||
|
import { Canvas } from '@react-three/fiber';
|
||||||
|
import { OrbitControls, Stars } from '@react-three/drei';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { CelestialBody } from './CelestialBody';
|
||||||
|
import { Probe } from './Probe';
|
||||||
|
import { CameraController } from './CameraController';
|
||||||
|
import { Trajectory } from './Trajectory';
|
||||||
|
import { Orbit } from './Orbit';
|
||||||
|
import { scalePosition } from '../utils/scaleDistance';
|
||||||
|
import type { CelestialBody as CelestialBodyType, Position } from '../types';
|
||||||
|
|
||||||
|
interface SceneProps {
|
||||||
|
bodies: CelestialBodyType[];
|
||||||
|
selectedBody: CelestialBodyType | null;
|
||||||
|
trajectoryPositions?: Position[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Scene({ bodies, selectedBody, trajectoryPositions = [] }: SceneProps) {
|
||||||
|
// Separate planets/stars from probes
|
||||||
|
const planets = bodies.filter((b) => b.type !== 'probe');
|
||||||
|
const probes = bodies.filter((b) => b.type === 'probe');
|
||||||
|
|
||||||
|
// Filter probes to display based on focus
|
||||||
|
const visibleProbes = selectedBody?.type === 'probe'
|
||||||
|
? probes.filter((p) => p.id === selectedBody.id) // Only show focused probe
|
||||||
|
: []; // In overview mode, hide all probes
|
||||||
|
|
||||||
|
// Calculate target position for OrbitControls
|
||||||
|
const controlsTarget = useMemo(() => {
|
||||||
|
if (selectedBody) {
|
||||||
|
const pos = selectedBody.positions[0];
|
||||||
|
const scaledPos = scalePosition(pos.x, pos.y, pos.z);
|
||||||
|
return [scaledPos.x, scaledPos.z, scaledPos.y] as [number, number, number];
|
||||||
|
}
|
||||||
|
return [0, 0, 0] as [number, number, number];
|
||||||
|
}, [selectedBody]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full bg-black">
|
||||||
|
<Canvas
|
||||||
|
camera={{
|
||||||
|
position: [50, 40, 50], // Angled view of the ecliptic plane
|
||||||
|
fov: 70, // Wider field of view
|
||||||
|
}}
|
||||||
|
gl={{
|
||||||
|
alpha: false,
|
||||||
|
antialias: true,
|
||||||
|
preserveDrawingBuffer: false,
|
||||||
|
}}
|
||||||
|
onCreated={({ gl, camera }) => {
|
||||||
|
gl.sortObjects = true; // Enable object sorting by renderOrder
|
||||||
|
camera.lookAt(0, 0, 0); // Look at the Sun (center)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Camera controller for smooth transitions */}
|
||||||
|
<CameraController focusTarget={selectedBody} />
|
||||||
|
|
||||||
|
{/* Increase ambient light to see textures better */}
|
||||||
|
<ambientLight intensity={0.5} />
|
||||||
|
|
||||||
|
{/* Additional directional light to illuminate planets */}
|
||||||
|
<directionalLight position={[10, 10, 5]} intensity={0.3} />
|
||||||
|
|
||||||
|
{/* Stars background */}
|
||||||
|
<Stars
|
||||||
|
radius={300}
|
||||||
|
depth={60}
|
||||||
|
count={5000}
|
||||||
|
factor={7}
|
||||||
|
saturation={0}
|
||||||
|
fade={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Render planets and stars */}
|
||||||
|
{planets.map((body) => (
|
||||||
|
<CelestialBody key={body.id} body={body} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Render planet orbits */}
|
||||||
|
{planets.map((body) => {
|
||||||
|
const pos = body.positions[0];
|
||||||
|
const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
|
||||||
|
// Only render orbits for planets (not Sun or Moon)
|
||||||
|
// Moon is too close to Earth, skip its orbit
|
||||||
|
if (body.type === 'planet' && distance > 0.1 && body.name !== 'Moon') {
|
||||||
|
return <Orbit key={`orbit-${body.id}`} distance={distance} color="#ffffff" lineWidth={1} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Render visible probes with 3D models */}
|
||||||
|
{visibleProbes.map((body) => (
|
||||||
|
<Probe key={body.id} body={body} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Render trajectory for selected probe */}
|
||||||
|
{selectedBody?.type === 'probe' && trajectoryPositions.length > 1 && (
|
||||||
|
<Trajectory
|
||||||
|
positions={trajectoryPositions}
|
||||||
|
color="#00ffff"
|
||||||
|
lineWidth={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Camera controls */}
|
||||||
|
<OrbitControls
|
||||||
|
enablePan={true}
|
||||||
|
enableZoom={true}
|
||||||
|
enableRotate={true}
|
||||||
|
minDistance={2}
|
||||||
|
maxDistance={500}
|
||||||
|
target={controlsTarget}
|
||||||
|
enabled={true} // Always enabled
|
||||||
|
/>
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Trajectory component - displays the path of a spacecraft or planet
|
||||||
|
*/
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Line } from '@react-three/drei';
|
||||||
|
import { Vector3 } from 'three';
|
||||||
|
import type { Position } from '../types';
|
||||||
|
import { scalePosition } from '../utils/scaleDistance';
|
||||||
|
|
||||||
|
interface TrajectoryProps {
|
||||||
|
positions: Position[];
|
||||||
|
color?: string;
|
||||||
|
lineWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Trajectory({ positions, color = '#00ffff', lineWidth = 2 }: TrajectoryProps) {
|
||||||
|
// Convert positions to scaled 3D points
|
||||||
|
const points = useMemo(() => {
|
||||||
|
return positions.map((pos) => {
|
||||||
|
const scaled = scalePosition(pos.x, pos.y, pos.z);
|
||||||
|
return new Vector3(scaled.x, scaled.z, scaled.y);
|
||||||
|
});
|
||||||
|
}, [positions]);
|
||||||
|
|
||||||
|
// Only render if we have at least 2 points
|
||||||
|
if (points.length < 2) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
points={points}
|
||||||
|
color={color}
|
||||||
|
lineWidth={lineWidth}
|
||||||
|
transparent
|
||||||
|
opacity={0.6}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* Custom hook for fetching space data
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { fetchCelestialPositions } from '../utils/api';
|
||||||
|
import type { CelestialBody } from '../types';
|
||||||
|
|
||||||
|
export function useSpaceData() {
|
||||||
|
const [bodies, setBodies] = useState<CelestialBody[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Fetch current positions
|
||||||
|
const data = await fetchCelestialPositions();
|
||||||
|
setBodies(data.bodies);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch celestial data:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { bodies, loading, error };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* Custom hook for fetching trajectory data for a celestial body
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { fetchCelestialPositions } from '../utils/api';
|
||||||
|
import type { CelestialBody, Position } from '../types';
|
||||||
|
|
||||||
|
export function useTrajectory(body: CelestialBody | null) {
|
||||||
|
const [trajectoryPositions, setTrajectoryPositions] = useState<Position[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!body || body.type !== 'probe') {
|
||||||
|
setTrajectoryPositions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTrajectory() {
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Fetch positions for the last 30 days
|
||||||
|
const endTime = new Date().toISOString();
|
||||||
|
const startTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const data = await fetchCelestialPositions(startTime, endTime, '1d');
|
||||||
|
|
||||||
|
// Find the body's data and extract positions
|
||||||
|
const bodyData = data.bodies.find((b) => b.id === body.id);
|
||||||
|
if (bodyData && bodyData.positions.length > 0) {
|
||||||
|
setTrajectoryPositions(bodyData.positions);
|
||||||
|
} else {
|
||||||
|
// Fallback to current position if no historical data
|
||||||
|
setTrajectoryPositions(body.positions);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch trajectory data:', err);
|
||||||
|
// Fallback to current position
|
||||||
|
if (body) {
|
||||||
|
setTrajectoryPositions(body.positions);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTrajectory();
|
||||||
|
}, [body?.id, body?.type]);
|
||||||
|
|
||||||
|
return { trajectoryPositions, loading };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* TypeScript type definitions for Cosmo application
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CelestialBodyType = 'planet' | 'probe' | 'star';
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
time: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CelestialBody {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: CelestialBodyType;
|
||||||
|
positions: Position[];
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CelestialDataResponse {
|
||||||
|
timestamp: string;
|
||||||
|
bodies: CelestialBody[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BodyInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: CelestialBodyType;
|
||||||
|
description: string;
|
||||||
|
launch_date?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* API utilities for fetching celestial data
|
||||||
|
*/
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { CelestialDataResponse, BodyInfo } from '../types';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost:8000/api';
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch celestial positions
|
||||||
|
*/
|
||||||
|
export async function fetchCelestialPositions(
|
||||||
|
startTime?: string,
|
||||||
|
endTime?: string,
|
||||||
|
step: string = '1d'
|
||||||
|
): Promise<CelestialDataResponse> {
|
||||||
|
const params: Record<string, string> = { step };
|
||||||
|
if (startTime) params.start_time = startTime;
|
||||||
|
if (endTime) params.end_time = endTime;
|
||||||
|
|
||||||
|
const response = await api.get<CelestialDataResponse>('/celestial/positions', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch body information
|
||||||
|
*/
|
||||||
|
export async function fetchBodyInfo(bodyId: string): Promise<BodyInfo> {
|
||||||
|
const response = await api.get<BodyInfo>(`/celestial/info/${bodyId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all bodies
|
||||||
|
*/
|
||||||
|
export async function fetchAllBodies(): Promise<{ bodies: BodyInfo[] }> {
|
||||||
|
const response = await api.get('/celestial/list');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* Non-linear distance scaling for better solar system visualization
|
||||||
|
* Inner solar system gets more space, outer solar system is compressed
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function scaleDistance(distanceInAU: number): number {
|
||||||
|
// Inner solar system (0-2 AU): expand by 3x for better visibility
|
||||||
|
if (distanceInAU < 2) {
|
||||||
|
return distanceInAU * 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middle region (2-10 AU): normal scale with offset
|
||||||
|
if (distanceInAU < 10) {
|
||||||
|
return 6 + (distanceInAU - 2) * 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outer solar system (10-50 AU): compressed scale
|
||||||
|
if (distanceInAU < 50) {
|
||||||
|
return 18 + (distanceInAU - 10) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Very far (> 50 AU): heavily compressed
|
||||||
|
return 38 + (distanceInAU - 50) * 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale a 3D position vector
|
||||||
|
*/
|
||||||
|
export function scalePosition(x: number, y: number, z: number): { x: number; y: number; z: number } {
|
||||||
|
const distance = Math.sqrt(x * x + y * y + z * z);
|
||||||
|
|
||||||
|
if (distance === 0) {
|
||||||
|
return { x: 0, y: 0, z: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaledDistance = scaleDistance(distance);
|
||||||
|
const scale = scaledDistance / distance;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: x * scale,
|
||||||
|
y: y * scale,
|
||||||
|
z: z * scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||